From 0315122148750244a0ed1698b6b17bb4c1ab6acd Mon Sep 17 00:00:00 2001 From: rpwolff Date: Sun, 8 Feb 2026 22:01:37 +0100 Subject: [PATCH] scheint zu klappen. --- config.json | 2 +- data/mmp.sqlite | Bin 0 -> 45056 bytes mmp_logger.py | 164 +++++++++++++++++++++++++++++++----------------- 3 files changed, 109 insertions(+), 57 deletions(-) create mode 100644 data/mmp.sqlite diff --git a/config.json b/config.json index 3fbc167..5dcf458 100644 --- a/config.json +++ b/config.json @@ -5,7 +5,7 @@ "read_timeout_sec": 25, "connect_timeout_sec": 10, "command": "ostatus", - "prompt_timeout_sec": 25 + "prompt_timeout_sec": 8 }, "report": { "write_markdown": true, diff --git a/data/mmp.sqlite b/data/mmp.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..9c0b549851b136c2daa4d30fe94301c58916f0c3 GIT binary patch literal 45056 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 literal 0 HcmV?d00001 diff --git a/mmp_logger.py b/mmp_logger.py index b3609f7..f76add0 100755 --- a/mmp_logger.py +++ b/mmp_logger.py @@ -1,21 +1,40 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +""" +mmp_logger.py +- Pollt BayTech MMP (seriell->TCP bridge, raw TCP) per Kommando "ostatus" +- Schreibt Messwerte in SQLite +- Fehlende Felder werden mit letztem Messwert ersetzt (filled + filled_fields werden protokolliert) +- Report als Markdown (und optional HTML/PDF) nach ./reports/ + +Projektidee: Alles in EINEM Verzeichnis. +""" + import argparse import datetime as dt import json import os import re -import sqlite3 import socket +import sqlite3 import time from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple -# Prompt endet mit '>' (laut deiner Beschreibung) +# ------------------------- +# Parsing / Prompt +# ------------------------- + PROMPT_END = b">" NUM_RE = re.compile(r"([-+]?\d+(\.\d+)?)") +FILL_KEYS = ["current_a", "peak_a", "voltage_v", "power_w", "va", "state"] + + +# ------------------------- +# Data classes +# ------------------------- @dataclass class DeviceCfg: @@ -23,7 +42,7 @@ class DeviceCfg: host: str port: int enabled: bool = True - username: Optional[str] = None + username: Optional[str] = None # nicht genutzt (kein Login), bleibt für Zukunft password: Optional[str] = None @@ -111,7 +130,7 @@ def db_init(con: sqlite3.Connection) -> None: poll_run_id INTEGER NOT NULL, device_id INTEGER NOT NULL, outlet_id INTEGER NOT NULL, - ts TEXT NOT NULL, -- UTC ISO + ts TEXT NOT NULL, current_a REAL, peak_a REAL, voltage_v REAL, @@ -135,11 +154,11 @@ def get_or_create_device(con: sqlite3.Connection, d: DeviceCfg) -> int: cur = con.execute("SELECT id FROM device WHERE name=?", (d.name,)) row = cur.fetchone() if row: - device_id = row[0] + device_id = int(row[0]) con.execute("UPDATE device SET host=?, port=?, last_seen_at=? WHERE id=?", (d.host, d.port, now, device_id)) con.commit() - return int(device_id) + return device_id cur = con.execute( "INSERT INTO device(name,host,port,created_at,last_seen_at) VALUES(?,?,?,?,?)", @@ -204,17 +223,15 @@ def cost_center_for(outlet_name: str, cc_map: Dict[str, str]) -> Tuple[str, str] # ------------------------- -# Telnet / Fetch / Parse +# Raw TCP (seriell->TCP bridge) # ------------------------- -PROMPT_END = b">" - -def tcp_read_all(sock: socket.socket, read_timeout: int) -> bytes: +def tcp_read_all(sock: socket.socket, read_timeout: int, stop_on_prompt: bool = True) -> bytes: """ - Liest bis: - - Prompt '>' erkannt wird, ODER - - Gegenseite schließt (recv == b''), ODER - - read_timeout abläuft (socket.timeout) + Liest Daten bis: + - prompt '>' gesehen (optional) + - remote close (recv == b'') + - timeout """ sock.settimeout(read_timeout) buf = bytearray() @@ -224,11 +241,9 @@ def tcp_read_all(sock: socket.socket, read_timeout: int) -> bytes: except socket.timeout: break if not chunk: - # Session geschlossen break buf += chunk - # Prompt irgendwo im Buffer? - if PROMPT_END in buf: + if stop_on_prompt and (PROMPT_END in buf): break return bytes(buf) @@ -242,46 +257,49 @@ def tcp_fetch_ostatus_raw( enter_first: bool = True, prompt_pause_sec: float = 2.0, cmd_pause_sec: float = 0.2, + stop_on_prompt: bool = True ) -> bytes: """ - Raw TCP (seriell->TCP). Ablauf: + Ablauf (weil Session ggf. schließt): 1) connect - 2) optional: ENTER senden, kurz warten - 3) kurz lesen (prompt_timeout) - - falls kein '>' kommt: nochmal ENTER, Pause + 2) ENTER senden (wecken), Pause + 3) kurz lesen (prompt_timeout) -> wenn kein Prompt: nochmal ENTER+Pause+kurz lesen 4) cmd senden, kurze Pause - 5) lesen bis prompt / close / timeout + 5) lesen bis prompt/close/timeout """ with socket.create_connection((host, port), timeout=connect_timeout) as s: - # 1) "Session wecken" + pre = b"" if enter_first: s.sendall(b"\r\n") time.sleep(prompt_pause_sec) - # 2) kurzer Versuch: gibt's schon Output/Prompt? - pre = tcp_read_all(s, read_timeout=prompt_timeout) + pre += tcp_read_all(s, read_timeout=prompt_timeout, stop_on_prompt=stop_on_prompt) - # 3) falls kein Prompt: nochmal Enter + Pause - if PROMPT_END not in pre: + if stop_on_prompt and (PROMPT_END not in pre): + # nochmal wecken s.sendall(b"\r\n") time.sleep(prompt_pause_sec) - pre += tcp_read_all(s, read_timeout=prompt_timeout) + pre += tcp_read_all(s, read_timeout=prompt_timeout, stop_on_prompt=stop_on_prompt) - # 4) Kommando senden + # Kommando s.sendall(cmd.encode("utf-8") + b"\r\n") time.sleep(cmd_pause_sec) - # 5) Antwort lesen (Bridge kann danach schließen) - out = tcp_read_all(s, read_timeout=read_timeout) + out = tcp_read_all(s, read_timeout=read_timeout, stop_on_prompt=stop_on_prompt) return pre + out + +# ------------------------- +# Parse ostatus output +# ------------------------- + def parse_ostatus(text: str) -> List[dict]: """ - Parst Tabellenzeilen mit '|' (dein Format). - Beispielzeile: + Erwartet Tabellenzeilen mit '|'. + Beispiel: | W Power1 | 0.0 A | 0.0 A | 230.3 V | 0 W | 4 VA | On | """ - rows = [] + rows: List[dict] = [] for ln in text.splitlines(): if "|" not in ln: continue @@ -319,8 +337,6 @@ def parse_ostatus(text: str) -> List[dict]: # Fill logic # ------------------------- -FILL_KEYS = ["current_a", "peak_a", "voltage_v", "power_w", "va", "state"] - def apply_fill(row: dict, last: Optional[dict]) -> Tuple[dict, int, int]: if not last: return row, 0, 0 @@ -338,7 +354,11 @@ def apply_fill(row: dict, last: Optional[dict]) -> Tuple[dict, int, int]: # ------------------------- def poll_device(con: sqlite3.Connection, dev: DeviceCfg, cc_map: Dict[str, str], - cmd: str, connect_timeout: int, prompt_timeout: int, read_timeout: int) -> None: + cmd: str, connect_timeout: int, prompt_timeout: int, read_timeout: int, + enter_first: bool, prompt_pause_sec: float, + stop_on_prompt: bool, + debug_dump_raw: bool, + project_root: str) -> None: device_id = get_or_create_device(con, dev) started = utc_now_iso() t0 = time.time() @@ -361,10 +381,22 @@ def poll_device(con: sqlite3.Connection, dev: DeviceCfg, cc_map: Dict[str, str], connect_timeout=connect_timeout, prompt_timeout=prompt_timeout, read_timeout=read_timeout, - enter_first=True, - prompt_pause_sec=2.0 + enter_first=enter_first, + prompt_pause_sec=prompt_pause_sec, + stop_on_prompt=stop_on_prompt ) + text = raw.decode("utf-8", errors="replace") + + if debug_dump_raw: + logs_dir = Path(project_root) / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + (logs_dir / f"raw_{dev.name}.txt").write_text(text, encoding="utf-8") + + rows = parse_ostatus(text) + ts = utc_now_iso() + outlets_received = len(rows) + for r in rows: outlet_name = r["outlet_name"].strip() cost_code, cost_name = cost_center_for(outlet_name, cc_map) @@ -400,6 +432,9 @@ def poll_device(con: sqlite3.Connection, dev: DeviceCfg, cc_map: Dict[str, str], """, (utc_now_iso(), outlets_received, outlets_filled, fields_filled, duration_ms, run_id)) con.commit() + # kleine Info für CLI + print(f"{dev.name}: OK outlets={outlets_received} filled_outlets={outlets_filled} filled_fields={fields_filled}") + except Exception as e: duration_ms = int((time.time() - t0) * 1000) con.execute(""" @@ -473,7 +508,6 @@ def parse_period_args(args) -> Tuple[str, str, str]: return start.isoformat(), now.isoformat(), f"last{args.last_days}d" if args.from_iso and args.to_iso: - # Name aus from/to (gekürzt) return args.from_iso, args.to_iso, "custom" if args.period == "weekly": @@ -493,8 +527,8 @@ def parse_period_args(args) -> Tuple[str, str, str]: def report(con: sqlite3.Connection, from_iso: str, to_iso: str, device_name: Optional[str]) -> str: """ - Erzeugt Markdown-Report als String (kann gedruckt + in Dateien geschrieben werden). - Energie: grob approx_Wh = Sum(power_w) * 1h (bei stündlichem Poll). + Markdown Report als String. + Energie: approx_Wh = Sum(power_w)*1h (bei stündlichem Poll). """ params = [from_iso, to_iso] dev_filter = "" @@ -540,7 +574,7 @@ def report(con: sqlite3.Connection, from_iso: str, to_iso: str, device_name: Opt """ job_rows = con.execute(q_job, params).fetchall() - md = [] + md: List[str] = [] md.append("# MMP Report") md.append("") md.append(f"- Zeitraum (UTC): **{from_iso}** .. **{to_iso}**") @@ -572,7 +606,7 @@ def report(con: sqlite3.Connection, from_iso: str, to_iso: str, device_name: Opt # ------------------------- def main(): - ap = argparse.ArgumentParser(description="MMP ostatus Logger -> SQLite + Reports (md/html/pdf)") + ap = argparse.ArgumentParser(description="MMP ostatus Logger (raw TCP) -> SQLite + Reports (md/html/pdf)") ap.add_argument("--config", required=True, help="Pfad zur config.json") sub = ap.add_subparsers(dest="cmd", required=True) @@ -591,31 +625,39 @@ def main(): cfg = load_json(args.config) proj_root = project_root_from_config(args.config) - # Projekt-Unterordner sicherstellen (alles im selben Verzeichnis) + # Projekt-Unterordner sicherstellen safe_mkdir(proj_root, "data") safe_mkdir(proj_root, "logs") safe_mkdir(proj_root, "reports") + # Pfade relativ zur config.json db_path = resolve_path(args.config, cfg["db_path"]) cc_path = resolve_path(args.config, cfg["cost_center_map"]) - cc_map = load_json(cc_path) con = db_connect(db_path) db_init(con) - tel = cfg.get("telnet", {}) - read_timeout = int(tel.get("read_timeout_sec", 25)) - connect_timeout = int(tel.get("connect_timeout_sec", 10)) - prompt_timeout = int(tel.get("prompt_timeout_sec", 25)) - cmd = tel.get("command", "ostatus") + # TCP / Timeouts / Verhalten + tcp_cfg = cfg.get("tcp", cfg.get("telnet", {})) # erlaubt "telnet" alt, oder "tcp" neu + read_timeout = int(tcp_cfg.get("read_timeout_sec", 35)) + connect_timeout = int(tcp_cfg.get("connect_timeout_sec", 10)) + prompt_timeout = int(tcp_cfg.get("prompt_timeout_sec", 8)) + cmd = tcp_cfg.get("command", "ostatus") + enter_first = bool(tcp_cfg.get("enter_first", True)) + prompt_pause_sec = float(tcp_cfg.get("prompt_pause_sec", 2.0)) + stop_on_prompt = bool(tcp_cfg.get("stop_on_prompt", True)) + debug_dump_raw = bool(tcp_cfg.get("debug_dump_raw", False)) + + # Report-Ausgabe rep_cfg = cfg.get("report", {}) write_md = bool(rep_cfg.get("write_markdown", True)) write_html = bool(rep_cfg.get("write_html", True)) write_pdf = bool(rep_cfg.get("write_pdf", False)) name_prefix = rep_cfg.get("report_name_prefix", "report") + # Devices devices: List[DeviceCfg] = [] for d in cfg.get("devices", []): devices.append(DeviceCfg( @@ -628,13 +670,24 @@ def main(): )) if args.cmd == "poll": + any_ran = False for d in devices: if not d.enabled: continue if args.device and d.name != args.device: continue - poll_device(con, d, cc_map, cmd, connect_timeout, prompt_timeout, read_timeout) - print("OK") + any_ran = True + poll_device( + con, d, cc_map, + cmd, connect_timeout, prompt_timeout, read_timeout, + enter_first=enter_first, + prompt_pause_sec=prompt_pause_sec, + stop_on_prompt=stop_on_prompt, + debug_dump_raw=debug_dump_raw, + project_root=proj_root + ) + if not any_ran: + print("Hinweis: Kein enabled device gematcht (check config.json / --device).") elif args.cmd == "report": from_iso, to_iso, suffix = parse_period_args(args) @@ -642,7 +695,6 @@ def main(): print(md_text) - # Dateiname: z.B. report_weekly, report_monthly, report_last14d, report_custom name = f"{name_prefix}_{suffix}" if args.device: name = f"{name}_{args.device}" @@ -656,6 +708,6 @@ def main(): write_pdf=write_pdf ) + if __name__ == "__main__": - main() - \ No newline at end of file + main() \ No newline at end of file