bugfixes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -18,6 +18,7 @@ from rich.console import Group
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.screen import ModalScreen
|
||||
@@ -41,6 +42,19 @@ def rel_time(ts: float) -> str:
|
||||
return f"{d // 3600}h {(d % 3600) // 60}m ago"
|
||||
|
||||
|
||||
def fmt_duration(seconds: Optional[float]) -> str:
|
||||
if seconds is None:
|
||||
return "?"
|
||||
s = int(round(seconds))
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
if s < 3600:
|
||||
return f"{s // 60}m {s % 60:02d}s"
|
||||
if s < 86400:
|
||||
return f"{s // 3600}h {(s % 3600) // 60:02d}m"
|
||||
return f"{s // 86400}d {(s % 86400) // 3600:02d}h"
|
||||
|
||||
|
||||
def stream_text(ts_str: str, ev: dict) -> Text:
|
||||
if ev.get("type") == "json":
|
||||
fields = ev.get("fields", {})
|
||||
@@ -109,6 +123,8 @@ class OmsApp(App[None]):
|
||||
("a", "show_all", "Show all / stream"),
|
||||
("enter", "select", "Select device"),
|
||||
("q", "quit", "Quit"),
|
||||
# priority so Ctrl+C quits even while a modal Input is focused
|
||||
Binding("ctrl+c", "quit", "Quit", priority=True, show=False),
|
||||
]
|
||||
|
||||
COLUMNS = [
|
||||
@@ -337,6 +353,17 @@ class OmsApp(App[None]):
|
||||
meta.add_row("link (C)", dev.c_field or "?")
|
||||
meta.add_row("rssi", "?" if dev.rssi is None else f"{dev.rssi} dBm")
|
||||
meta.add_row("telegrams", str(dev.count))
|
||||
if dev.interval_samples >= 1:
|
||||
est = fmt_duration(dev.interval_estimate)
|
||||
last = fmt_duration(dev.interval_last)
|
||||
detail = f"~{est}"
|
||||
extra = f"last {last}, {dev.interval_samples} sample"
|
||||
extra += "s" if dev.interval_samples != 1 else ""
|
||||
interval_text = Text(detail, style="bold")
|
||||
interval_text.append(f" ({extra})", style="dim")
|
||||
meta.add_row("send interval", interval_text)
|
||||
else:
|
||||
meta.add_row("send interval", Text("— (need ≥2 telegrams)", style="dim"))
|
||||
meta.add_row(
|
||||
"first seen", time.strftime("%H:%M:%S", time.localtime(dev.first_seen))
|
||||
)
|
||||
|
||||
@@ -19,6 +19,8 @@ FIELDNAMES = [
|
||||
"last_seen",
|
||||
"count",
|
||||
"rssi",
|
||||
"interval_s",
|
||||
"interval_samples",
|
||||
"encryption",
|
||||
"security_mode",
|
||||
"status",
|
||||
@@ -50,6 +52,11 @@ def export_devices(path: str, devices: Iterable[Device]) -> int:
|
||||
"last_seen": _fmt_time(d.last_seen),
|
||||
"count": d.count,
|
||||
"rssi": "" if d.rssi is None else d.rssi,
|
||||
"interval_s": (
|
||||
"" if d.interval_estimate is None
|
||||
else round(d.interval_estimate, 1)
|
||||
),
|
||||
"interval_samples": d.interval_samples,
|
||||
"encryption": d.encryption,
|
||||
"security_mode": d.sec_mode or "",
|
||||
"status": d.status,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
@@ -42,6 +43,37 @@ class Device:
|
||||
default_factory=lambda: deque(maxlen=500)
|
||||
)
|
||||
_last_count_at: float = 0.0 # for de-duplicating multi-meter DLL bursts
|
||||
# arrival times of counted telegrams, for estimating the send interval
|
||||
rx_times: Deque[float] = field(default_factory=lambda: deque(maxlen=64))
|
||||
|
||||
@property
|
||||
def intervals(self) -> List[float]:
|
||||
t = list(self.rx_times)
|
||||
return [b - a for a, b in zip(t, t[1:])]
|
||||
|
||||
@property
|
||||
def interval_samples(self) -> int:
|
||||
return max(0, len(self.rx_times) - 1)
|
||||
|
||||
@property
|
||||
def interval_estimate(self) -> Optional[float]:
|
||||
"""Best estimate of the transmit period: median of observed gaps.
|
||||
|
||||
Median is robust to missed telegrams (which show up as gaps that are
|
||||
multiples of the true period) and to the odd retransmission burst.
|
||||
"""
|
||||
gaps = self.intervals
|
||||
return statistics.median(gaps) if gaps else None
|
||||
|
||||
@property
|
||||
def interval_last(self) -> Optional[float]:
|
||||
gaps = self.intervals
|
||||
return gaps[-1] if gaps else None
|
||||
|
||||
@property
|
||||
def interval_min(self) -> Optional[float]:
|
||||
gaps = self.intervals
|
||||
return min(gaps) if gaps else None
|
||||
|
||||
@property
|
||||
def encryption(self) -> str:
|
||||
@@ -113,6 +145,20 @@ class DeviceStore:
|
||||
def ordered(self) -> List[Device]:
|
||||
return [self.devices[i] for i in self.order]
|
||||
|
||||
@staticmethod
|
||||
def _note_telegram(dev: Device, now: float) -> None:
|
||||
"""Count one telegram and record its arrival, de-duplicating the
|
||||
multi-line burst a single telegram produces (DLL[+DLL]+ELL+TPL+JSON,
|
||||
all within microseconds) so counts and the interval stay accurate.
|
||||
|
||||
Driven by *any* telegram-level event, so a DLL line we fail to parse
|
||||
can't silently zero out the count and send-interval.
|
||||
"""
|
||||
if dev._last_count_at == 0.0 or (now - dev._last_count_at) > COUNT_DEDUP_WINDOW:
|
||||
dev.count += 1
|
||||
dev.rx_times.append(now)
|
||||
dev._last_count_at = now
|
||||
|
||||
def _get_or_create(self, device_id: str, now: float) -> Tuple[Device, bool]:
|
||||
dev = self.devices.get(device_id)
|
||||
if dev is None:
|
||||
@@ -134,11 +180,7 @@ class DeviceStore:
|
||||
if ev.get("rssi") is not None:
|
||||
dev.rssi = ev["rssi"]
|
||||
dev.last_seen = now
|
||||
# Count once per telegram, ignoring the duplicate DLL burst from a
|
||||
# second matching meter.
|
||||
if is_new or (now - dev._last_count_at) > COUNT_DEDUP_WINDOW:
|
||||
dev.count += 1
|
||||
dev._last_count_at = now
|
||||
self._note_telegram(dev, now)
|
||||
return dev, is_new
|
||||
|
||||
def apply_ell(self, ev: dict, device_id: Optional[str]) -> Optional[Device]:
|
||||
@@ -192,4 +234,8 @@ class DeviceStore:
|
||||
if from_key:
|
||||
dev.key_decrypted = True
|
||||
dev.last_seen = now
|
||||
# Also drive the counter from JSON, so telegrams still count even if
|
||||
# their DLL line failed to parse. The dedup window collapses the DLL
|
||||
# and its JSON (same telegram) into one.
|
||||
self._note_telegram(dev, now)
|
||||
return dev, is_new
|
||||
|
||||
@@ -27,7 +27,9 @@ _DLL_RE = re.compile(
|
||||
r"M=(?P<mhex>[0-9a-fA-F]+) \((?P<mfct>[A-Z?]+)\) "
|
||||
r"A=(?P<id>\w+) "
|
||||
r"VER=(?P<ver>[0-9a-fA-F]+) "
|
||||
r"TYPE=(?P<type>[0-9a-fA-F]+) \((?P<media>[^)]*)\) "
|
||||
# media can itself contain parentheses, e.g. "Radio converter (meter side)";
|
||||
# match lazily up to the " (driver ...)" that always follows.
|
||||
r"TYPE=(?P<type>[0-9a-fA-F]+) \((?P<media>.*?)\) "
|
||||
r"\(driver (?P<driver>\w+)\)"
|
||||
r"(?:.*?RSSI=(?P<rssi>-?\d+))?"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user