From dcc98f9cd86b5f327e9845eeb9be6fe4e75e9385 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 19 Jul 2026 00:55:01 -0400 Subject: [PATCH] Dashboard traffic: the packet bucket breaks out by payload class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lone "packet" meter lumped every overheard raw frame together; the traffic panel now meters each parsed payload class on its own row — channel text, trace, path, request, … — glossed and iconed from the packet viewer's shared tables, with only class-less frames left as a bare "packet". The composite packet: bucket shape runs through the stored seed (Repository.kind_counts) and the live tally (MonitorService) alike; an overheard ADVERT/ACK frame reads "raw advert"/"raw ack" so no two meters share a label. Raw-class meters stay muted — colour keeps marking the decoded families. Co-Authored-By: Claude Fable 5 --- meshterm/persistence/repository.py | 19 ++++--- meshterm/services/monitor_service.py | 14 ++++-- meshterm/ui/dashboard_screen.py | 75 +++++++++++++++++++++------- tests/test_dashboard.py | 61 ++++++++++++++++++++++ 4 files changed, 143 insertions(+), 26 deletions(-) diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index 4ac7d26..b862df1 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -1376,23 +1376,30 @@ class Repository: The persistent seed of the dashboard's traffic panel: what the recorder has heard across sessions, by kind, so the panel opens populated instead of - counting from zero every launch. Messages and acks are separate event - families (not observations) and are counted live on top of this. + counting from zero every launch. A raw ``packet`` frame carrying a parsed + payload class buckets as ``packet:`` (``packet:GRP_TXT``, + ``packet:TRACE``, …) so the panel can name what the frames were; only a + class-less frame stays a bare ``packet``. Messages and acks are separate + event families (not observations) and are counted live on top of this. Args: since: Only observations at or after this time, if given. Returns: - ``kind → count`` for every packet class ever stored (in the window). + ``bucket → count`` for every packet class ever stored (in the window). """ - sql = "SELECT kind, COUNT(*) AS n FROM observations" + sql = ( + "SELECT CASE WHEN kind = 'packet' AND payload_typename IS NOT NULL " + "THEN 'packet:' || payload_typename ELSE kind END AS bucket, " + "COUNT(*) AS n FROM observations" + ) params: list[Any] = [] if since is not None: sql += " WHERE observed_at >= ?" params.append(since.isoformat()) - sql += " GROUP BY kind" + sql += " GROUP BY bucket" rows = self._conn.execute(sql, params).fetchall() - return {row["kind"]: row["n"] for row in rows} + return {row["bucket"]: row["n"] for row in rows} def prune_observations(self, older_than: datetime) -> int: """Delete observations that aged past the retention window (housekeeping). diff --git a/meshterm/services/monitor_service.py b/meshterm/services/monitor_service.py index d7c353c..2132942 100644 --- a/meshterm/services/monitor_service.py +++ b/meshterm/services/monitor_service.py @@ -149,12 +149,15 @@ class MonitorService: return tuple(bucket - i >= self._start_bucket for i in range(ACTIVITY_BUCKETS)) def kind_counts(self) -> dict[str, int]: - """Tallies by packet class: advert/telemetry/packet, message, ack. + """Tallies by packet class: advert/telemetry/packet buckets, message, ack. Seeded from stored history at session start and grown live from there, so the numbers describe everything the recorder retains, not just this session. - Observation classes come from the packet itself (``Observation.kind``); - messages and acks are their own classes. A copy, safe to mutate. + Observation classes come from the packet itself (``Observation.kind``) — a raw + ``packet`` frame with a parsed payload class buckets as ``packet:``, + matching :meth:`~meshterm.persistence.repository.Repository.kind_counts`'s + stored seed — and messages and acks are their own classes. A copy, safe to + mutate. """ return dict(self._kind_counts) @@ -164,6 +167,11 @@ class MonitorService: self._activity[bucket] = self._activity.get(bucket, 0) + 1 obs = event.observation kind = obs.kind if obs is not None else event.kind.value + if obs is not None and kind == "packet": + # Bucket a classed raw frame by its payload class (the stored seed's shape). + typename = (obs.raw or {}).get("payload_typename") + if typename: + kind = f"packet:{typename}" self._kind_counts[kind] = self._kind_counts.get(kind, 0) + 1 if len(self._activity) > ACTIVITY_BUCKETS + 1: cutoff = bucket - ACTIVITY_BUCKETS diff --git a/meshterm/ui/dashboard_screen.py b/meshterm/ui/dashboard_screen.py index 17a518f..f5ade4f 100644 --- a/meshterm/ui/dashboard_screen.py +++ b/meshterm/ui/dashboard_screen.py @@ -11,8 +11,10 @@ stacks three reads of the mesh, coarsest first: drawn from the same :meth:`~meshterm.services.monitor_service.MonitorService` buckets. A pulse line beneath it reads the rate, who's been heard, and the busiest node of the window. -* **Traffic** — the session's tallies by packet class, each with a proportional bar, - from the monitor's kind counters. +* **Traffic** — the session's tallies by packet class, each with its icon and a + proportional bar, from the monitor's kind counters — the raw ``packet`` bucket + broken out by payload class (channel text, trace, path, …), so every known frame + class shows once heard. * **RF health** — the trailing window's reception quality: median SNR (on the trace tool's quality bar) and RSSI from stored observations, plus the radio's own live numbers — noise floor, last RSSI/SNR, airtime, battery — polled from the device the @@ -42,7 +44,13 @@ from ..core.events import MeshEvent from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow from ..persistence.repository import OBSERVATION_WINDOW from .braillechart import axis_chart, meter, timeline_rows -from .packet_viewer import KIND_STYLES +from .packet_viewer import ( + DEFAULT_ICON, + KIND_ICONS, + KIND_STYLES, + PAYLOAD_ICONS, + _PAYLOAD_GLOSS, +) from .theme import snr_style from .trace_screen import snr_bar from .tui.render import render_lines @@ -60,8 +68,38 @@ _STATS_POLL_S = 10.0 #: How many braille rows tall the activity chart draws (each row is four dot rows). _CHART_ROWS = 3 -#: The order the traffic panel lists packet classes in (heard ones not listed sort last). -_KIND_ORDER = ("advert", "telemetry", "packet", "message", "ack") +#: The order the traffic panel lists its buckets in (heard ones not listed sort last, +#: alphabetically): the decoded families first, then the raw ``packet:`` +#: classes expanding in the old lone-``packet`` slot — chat-ish frames before +#: protocol-ish ones — with the class-less ``packet`` closing the raw block. +_TRAFFIC_ORDER = ( + "advert", "telemetry", + "packet:GRP_TXT", "packet:GRP_DATA", "packet:TEXT_MSG", "packet:REQ", + "packet:RESPONSE", "packet:ANON_REQ", "packet:PATH", "packet:TRACE", + "packet:ADVERT", "packet:ACK", "packet:MULTIPART", "packet:CONTROL", "packet", + "message", "ack", +) + +#: Raw payload glosses that would read identically to a decoded family's row, retold +#: so two meters never share a label (an overheard advert frame vs. the advert event). +_RAW_GLOSS_CLASH = {"advert": "raw advert", "ack": "raw ack"} + + +def _traffic_chrome(bucket: str) -> tuple[str, str, str]: + """One traffic bucket's ``(icon, label, meter style)``. + + A decoded family keeps its kind icon, name, and colour; a ``packet:`` + bucket takes the payload class's icon and gloss over the calm muted ``packet`` + meter — colour keeps marking the decoded families, raw overheard stays quiet. + """ + if bucket.startswith("packet:"): + typename = bucket.split(":", 1)[1] + gloss = _PAYLOAD_GLOSS.get(typename, typename.lower()) + label = _RAW_GLOSS_CLASH.get(gloss, gloss) + return ( + PAYLOAD_ICONS.get(typename, DEFAULT_ICON), label, KIND_STYLES["packet"], + ) + return KIND_ICONS.get(bucket, DEFAULT_ICON), bucket, KIND_STYLES.get(bucket, "brand") #: How many character cells a traffic lane's meter spans (48 half-step levels). _TRAFFIC_METER_CELLS = 24 @@ -294,29 +332,32 @@ class DashboardScreen(Screen): # -- traffic -- def _traffic_section(self) -> list[RenderableType]: - """Tallies by packet class, each with a proportional braille meter.""" + """Tallies by packet class, each with an icon and a proportional braille meter. + + Every known class shows once heard — the raw ``packet`` bucket is broken out + by payload class (channel text, trace, path, …) instead of lumping every + overheard frame together; classes never heard stay hidden. + """ heading = Text("Traffic", style="accent") heading.append(" · by packet class · stored history + live", style="muted") counts = self._kind_counts() if not counts: return [heading, Text("nothing heard yet", style="muted")] - order = {k: i for i, k in enumerate(_KIND_ORDER)} + order = {k: i for i, k in enumerate(_TRAFFIC_ORDER)} peak = max(counts.values()) - label_w = max(len(k) for k in counts) + chrome = {bucket: _traffic_chrome(bucket) for bucket in counts} + label_w = max(len(label) for _, label, _ in chrome.values()) count_w = len(str(peak)) rows: list[RenderableType] = [heading] - for kind in sorted(counts, key=lambda k: (order.get(k, len(order)), k)): - count = counts[kind] - row = Text(f"{kind.ljust(label_w)} ", style="muted") + for bucket in sorted(counts, key=lambda k: (order.get(k, len(order)), k)): + count = counts[bucket] + icon, label, style = chrome[bucket] + row = Text(f"{icon} ") + row.append(f"{label.ljust(label_w)} ", style="muted") row.append(f"{count:>{count_w}} ") # The shared braille meter, full-height with no track: the lane resolves # 48 levels across its 24 cells (two half-steps per cell). - row.append_text( - meter( - count / peak, _TRAFFIC_METER_CELLS, - style=KIND_STYLES.get(kind, "brand"), - ) - ) + row.append_text(meter(count / peak, _TRAFFIC_METER_CELLS, style=style)) rows.append(row) return rows diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 0b2f5ee..dd84b23 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -125,6 +125,31 @@ def test_dashboard_pulse_drops_heard_only_when_it_wont_fit() -> None: assert "heard" not in tight and "nodes" in tight +def test_dashboard_traffic_breaks_packets_out_by_payload_class() -> None: + """The raw ``packet`` bucket meters per payload class, glossed and iconed.""" + screen = _screen( + window=[_obs()], + kinds={"advert": 4, "packet:GRP_TXT": 3, "packet:TRACE": 2, "packet": 1}, + ) + body = _plain(_stripped(screen.render_body(100))) + assert "channel text" in body and "trace" in body # glossed, not raw typenames + assert "GRP_TXT" not in body + # The class-less remainder keeps a plain "packet" row alongside the classed ones. + assert "packet" in body + # Decoded families before the raw classes, class-less packet closing the block. + assert body.index("advert") < body.index("channel text") < body.index("trace") + + +def test_dashboard_traffic_disambiguates_raw_advert_and_ack_rows() -> None: + """An overheard ADVERT/ACK frame's row reads "raw …" so no two meters match.""" + screen = _screen( + window=[_obs()], + kinds={"advert": 4, "packet:ADVERT": 2, "ack": 3, "packet:ACK": 1}, + ) + body = _plain(_stripped(screen.render_body(100))) + assert "raw advert" in body and "raw ack" in body + + def test_dashboard_live_observations_land_in_the_window() -> None: """A hub observation folds into the trailing window and repaints the charts.""" screen = _screen() @@ -156,6 +181,42 @@ def test_dashboard_radio_rows_render_device_stats() -> None: assert "4.01 V" in body +def test_repository_kind_counts_bucket_packets_by_payload_class(tmp_path: Path) -> None: + """Stored packet frames with a payload class seed as ``packet:`` buckets.""" + repo = Repository(tmp_path / "kinds.db") + run = repo.start_run("monitor", {}, None) + repo.record_observation(run, _obs(node="a", kind="advert")) + repo.record_observation( + run, _obs(node="", kind="packet", raw={"payload_typename": "GRP_TXT"}) + ) + repo.record_observation( + run, _obs(node="", kind="packet", raw={"payload_typename": "GRP_TXT"}) + ) + repo.record_observation(run, _obs(node="", kind="packet")) # class-less frame + + counts = repo.kind_counts() + assert counts["advert"] == 1 + assert counts["packet:GRP_TXT"] == 2 + assert counts["packet"] == 1 # only the class-less frame stays a bare packet + repo.close() + + +def test_monitor_tallies_live_packets_by_payload_class(tmp_path: Path) -> None: + """A live classed packet observation lands in its ``packet:`` bucket.""" + from meshterm.services.monitor_service import MonitorService + + monitor = MonitorService.__new__(MonitorService) + monitor._activity = {} + monitor._kind_counts = {} + monitor._count_packet( + MeshEvent.observation_event( + _obs(node="", kind="packet", raw={"payload_typename": "TRACE"}) + ) + ) + monitor._count_packet(MeshEvent.observation_event(_obs(kind="advert"))) + assert monitor._kind_counts == {"packet:TRACE": 1, "advert": 1} + + # --- the repository window -----------------------------------------------------------