scheint zu klappen.

This commit is contained in:
2026-02-08 22:01:37 +01:00
parent a84497eb32
commit 0315122148
3 changed files with 109 additions and 57 deletions

View File

@ -5,7 +5,7 @@
"read_timeout_sec": 25, "read_timeout_sec": 25,
"connect_timeout_sec": 10, "connect_timeout_sec": 10,
"command": "ostatus", "command": "ostatus",
"prompt_timeout_sec": 25 "prompt_timeout_sec": 8
}, },
"report": { "report": {
"write_markdown": true, "write_markdown": true,

BIN
data/mmp.sqlite Normal file

Binary file not shown.

View File

@ -1,21 +1,40 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- 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 argparse
import datetime as dt import datetime as dt
import json import json
import os import os
import re import re
import sqlite3
import socket import socket
import sqlite3
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
# Prompt endet mit '>' (laut deiner Beschreibung) # -------------------------
# Parsing / Prompt
# -------------------------
PROMPT_END = b">" PROMPT_END = b">"
NUM_RE = re.compile(r"([-+]?\d+(\.\d+)?)") NUM_RE = re.compile(r"([-+]?\d+(\.\d+)?)")
FILL_KEYS = ["current_a", "peak_a", "voltage_v", "power_w", "va", "state"]
# -------------------------
# Data classes
# -------------------------
@dataclass @dataclass
class DeviceCfg: class DeviceCfg:
@ -23,7 +42,7 @@ class DeviceCfg:
host: str host: str
port: int port: int
enabled: bool = True enabled: bool = True
username: Optional[str] = None username: Optional[str] = None # nicht genutzt (kein Login), bleibt für Zukunft
password: Optional[str] = None password: Optional[str] = None
@ -111,7 +130,7 @@ def db_init(con: sqlite3.Connection) -> None:
poll_run_id INTEGER NOT NULL, poll_run_id INTEGER NOT NULL,
device_id INTEGER NOT NULL, device_id INTEGER NOT NULL,
outlet_id INTEGER NOT NULL, outlet_id INTEGER NOT NULL,
ts TEXT NOT NULL, -- UTC ISO ts TEXT NOT NULL,
current_a REAL, current_a REAL,
peak_a REAL, peak_a REAL,
voltage_v 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,)) cur = con.execute("SELECT id FROM device WHERE name=?", (d.name,))
row = cur.fetchone() row = cur.fetchone()
if row: if row:
device_id = row[0] device_id = int(row[0])
con.execute("UPDATE device SET host=?, port=?, last_seen_at=? WHERE id=?", con.execute("UPDATE device SET host=?, port=?, last_seen_at=? WHERE id=?",
(d.host, d.port, now, device_id)) (d.host, d.port, now, device_id))
con.commit() con.commit()
return int(device_id) return device_id
cur = con.execute( cur = con.execute(
"INSERT INTO device(name,host,port,created_at,last_seen_at) VALUES(?,?,?,?,?)", "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, stop_on_prompt: bool = True) -> bytes:
def tcp_read_all(sock: socket.socket, read_timeout: int) -> bytes:
""" """
Liest bis: Liest Daten bis:
- Prompt '>' erkannt wird, ODER - prompt '>' gesehen (optional)
- Gegenseite schließt (recv == b''), ODER - remote close (recv == b'')
- read_timeout abläuft (socket.timeout) - timeout
""" """
sock.settimeout(read_timeout) sock.settimeout(read_timeout)
buf = bytearray() buf = bytearray()
@ -224,11 +241,9 @@ def tcp_read_all(sock: socket.socket, read_timeout: int) -> bytes:
except socket.timeout: except socket.timeout:
break break
if not chunk: if not chunk:
# Session geschlossen
break break
buf += chunk buf += chunk
# Prompt irgendwo im Buffer? if stop_on_prompt and (PROMPT_END in buf):
if PROMPT_END in buf:
break break
return bytes(buf) return bytes(buf)
@ -242,46 +257,49 @@ def tcp_fetch_ostatus_raw(
enter_first: bool = True, enter_first: bool = True,
prompt_pause_sec: float = 2.0, prompt_pause_sec: float = 2.0,
cmd_pause_sec: float = 0.2, cmd_pause_sec: float = 0.2,
stop_on_prompt: bool = True
) -> bytes: ) -> bytes:
""" """
Raw TCP (seriell->TCP). Ablauf: Ablauf (weil Session ggf. schließt):
1) connect 1) connect
2) optional: ENTER senden, kurz warten 2) ENTER senden (wecken), Pause
3) kurz lesen (prompt_timeout) 3) kurz lesen (prompt_timeout) -> wenn kein Prompt: nochmal ENTER+Pause+kurz lesen
- falls kein '>' kommt: nochmal ENTER, Pause
4) cmd senden, kurze Pause 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: with socket.create_connection((host, port), timeout=connect_timeout) as s:
# 1) "Session wecken" pre = b""
if enter_first: if enter_first:
s.sendall(b"\r\n") s.sendall(b"\r\n")
time.sleep(prompt_pause_sec) time.sleep(prompt_pause_sec)
# 2) kurzer Versuch: gibt's schon Output/Prompt? pre += tcp_read_all(s, read_timeout=prompt_timeout, stop_on_prompt=stop_on_prompt)
pre = tcp_read_all(s, read_timeout=prompt_timeout)
# 3) falls kein Prompt: nochmal Enter + Pause if stop_on_prompt and (PROMPT_END not in pre):
if PROMPT_END not in pre: # nochmal wecken
s.sendall(b"\r\n") s.sendall(b"\r\n")
time.sleep(prompt_pause_sec) 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") s.sendall(cmd.encode("utf-8") + b"\r\n")
time.sleep(cmd_pause_sec) time.sleep(cmd_pause_sec)
# 5) Antwort lesen (Bridge kann danach schließen) out = tcp_read_all(s, read_timeout=read_timeout, stop_on_prompt=stop_on_prompt)
out = tcp_read_all(s, read_timeout=read_timeout)
return pre + out return pre + out
# -------------------------
# Parse ostatus output
# -------------------------
def parse_ostatus(text: str) -> List[dict]: def parse_ostatus(text: str) -> List[dict]:
""" """
Parst Tabellenzeilen mit '|' (dein Format). Erwartet Tabellenzeilen mit '|'.
Beispielzeile: Beispiel:
| W Power1 | 0.0 A | 0.0 A | 230.3 V | 0 W | 4 VA | On | | 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(): for ln in text.splitlines():
if "|" not in ln: if "|" not in ln:
continue continue
@ -319,8 +337,6 @@ def parse_ostatus(text: str) -> List[dict]:
# Fill logic # 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]: def apply_fill(row: dict, last: Optional[dict]) -> Tuple[dict, int, int]:
if not last: if not last:
return row, 0, 0 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], 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) device_id = get_or_create_device(con, dev)
started = utc_now_iso() started = utc_now_iso()
t0 = time.time() t0 = time.time()
@ -361,10 +381,22 @@ def poll_device(con: sqlite3.Connection, dev: DeviceCfg, cc_map: Dict[str, str],
connect_timeout=connect_timeout, connect_timeout=connect_timeout,
prompt_timeout=prompt_timeout, prompt_timeout=prompt_timeout,
read_timeout=read_timeout, read_timeout=read_timeout,
enter_first=True, enter_first=enter_first,
prompt_pause_sec=2.0 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: for r in rows:
outlet_name = r["outlet_name"].strip() outlet_name = r["outlet_name"].strip()
cost_code, cost_name = cost_center_for(outlet_name, cc_map) 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)) """, (utc_now_iso(), outlets_received, outlets_filled, fields_filled, duration_ms, run_id))
con.commit() 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: except Exception as e:
duration_ms = int((time.time() - t0) * 1000) duration_ms = int((time.time() - t0) * 1000)
con.execute(""" 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" return start.isoformat(), now.isoformat(), f"last{args.last_days}d"
if args.from_iso and args.to_iso: if args.from_iso and args.to_iso:
# Name aus from/to (gekürzt)
return args.from_iso, args.to_iso, "custom" return args.from_iso, args.to_iso, "custom"
if args.period == "weekly": 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: 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). Markdown Report als String.
Energie: grob approx_Wh = Sum(power_w) * 1h (bei stündlichem Poll). Energie: approx_Wh = Sum(power_w)*1h (bei stündlichem Poll).
""" """
params = [from_iso, to_iso] params = [from_iso, to_iso]
dev_filter = "" 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() job_rows = con.execute(q_job, params).fetchall()
md = [] md: List[str] = []
md.append("# MMP Report") md.append("# MMP Report")
md.append("") md.append("")
md.append(f"- Zeitraum (UTC): **{from_iso}** .. **{to_iso}**") 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(): 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") ap.add_argument("--config", required=True, help="Pfad zur config.json")
sub = ap.add_subparsers(dest="cmd", required=True) sub = ap.add_subparsers(dest="cmd", required=True)
@ -591,31 +625,39 @@ def main():
cfg = load_json(args.config) cfg = load_json(args.config)
proj_root = project_root_from_config(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, "data")
safe_mkdir(proj_root, "logs") safe_mkdir(proj_root, "logs")
safe_mkdir(proj_root, "reports") safe_mkdir(proj_root, "reports")
# Pfade relativ zur config.json
db_path = resolve_path(args.config, cfg["db_path"]) db_path = resolve_path(args.config, cfg["db_path"])
cc_path = resolve_path(args.config, cfg["cost_center_map"]) cc_path = resolve_path(args.config, cfg["cost_center_map"])
cc_map = load_json(cc_path) cc_map = load_json(cc_path)
con = db_connect(db_path) con = db_connect(db_path)
db_init(con) db_init(con)
tel = cfg.get("telnet", {}) # TCP / Timeouts / Verhalten
read_timeout = int(tel.get("read_timeout_sec", 25)) tcp_cfg = cfg.get("tcp", cfg.get("telnet", {})) # erlaubt "telnet" alt, oder "tcp" neu
connect_timeout = int(tel.get("connect_timeout_sec", 10)) read_timeout = int(tcp_cfg.get("read_timeout_sec", 35))
prompt_timeout = int(tel.get("prompt_timeout_sec", 25)) connect_timeout = int(tcp_cfg.get("connect_timeout_sec", 10))
cmd = tel.get("command", "ostatus") 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", {}) rep_cfg = cfg.get("report", {})
write_md = bool(rep_cfg.get("write_markdown", True)) write_md = bool(rep_cfg.get("write_markdown", True))
write_html = bool(rep_cfg.get("write_html", True)) write_html = bool(rep_cfg.get("write_html", True))
write_pdf = bool(rep_cfg.get("write_pdf", False)) write_pdf = bool(rep_cfg.get("write_pdf", False))
name_prefix = rep_cfg.get("report_name_prefix", "report") name_prefix = rep_cfg.get("report_name_prefix", "report")
# Devices
devices: List[DeviceCfg] = [] devices: List[DeviceCfg] = []
for d in cfg.get("devices", []): for d in cfg.get("devices", []):
devices.append(DeviceCfg( devices.append(DeviceCfg(
@ -628,13 +670,24 @@ def main():
)) ))
if args.cmd == "poll": if args.cmd == "poll":
any_ran = False
for d in devices: for d in devices:
if not d.enabled: if not d.enabled:
continue continue
if args.device and d.name != args.device: if args.device and d.name != args.device:
continue continue
poll_device(con, d, cc_map, cmd, connect_timeout, prompt_timeout, read_timeout) any_ran = True
print("OK") 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": elif args.cmd == "report":
from_iso, to_iso, suffix = parse_period_args(args) from_iso, to_iso, suffix = parse_period_args(args)
@ -642,7 +695,6 @@ def main():
print(md_text) print(md_text)
# Dateiname: z.B. report_weekly, report_monthly, report_last14d, report_custom
name = f"{name_prefix}_{suffix}" name = f"{name_prefix}_{suffix}"
if args.device: if args.device:
name = f"{name}_{args.device}" name = f"{name}_{args.device}"
@ -656,6 +708,6 @@ def main():
write_pdf=write_pdf write_pdf=write_pdf
) )
if __name__ == "__main__": if __name__ == "__main__":
main() main()