diff --git a/meshterm/ui/dashboard_screen.py b/meshterm/ui/dashboard_screen.py index db318bf..f8da643 100644 --- a/meshterm/ui/dashboard_screen.py +++ b/meshterm/ui/dashboard_screen.py @@ -12,10 +12,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 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. +* **Traffic** — the frames heard on the air, tallied by class (chan text, trace, + path, …) under the names and icons the live feed gives them, each with a + proportional bar. Every frame counts once: what the device *reported* about a frame + (its advert event, a decoded message) is not counted a second time beside it. * **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 @@ -45,14 +45,8 @@ 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, axis_chrome, axis_label_w, meter, timeline_rows -from .packet_viewer import ( - _PAYLOAD_GLOSS, - DEFAULT_ICON, - KIND_ICONS, - KIND_STYLES, - PAYLOAD_ICONS, -) -from .theme import glyph, snr_style +from .packet_viewer import KIND_STYLES, payload_marks +from .theme import snr_style from .trace_screen import snr_bar from .tui.render import render_lines from .tui.screen import Screen @@ -69,13 +63,10 @@ _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 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. +#: The order the traffic panel lists its classes in (heard ones not listed sort last, +#: alphabetically): chat-ish frames before protocol-ish ones, the class-less ``packet`` +#: closing the list. _TRAFFIC_ORDER = ( - "advert", - "telemetry", "packet:GRP_TXT", "packet:GRP_DATA", "packet:TEXT_MSG", @@ -89,31 +80,27 @@ _TRAFFIC_ORDER = ( "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 _is_frame_bucket(bucket: str) -> bool: + """Whether a tally bucket counts frames heard on the air (``packet[:]``). + + The monitor also tallies what the *device* reported — ``advert``, ``telemetry``, + ``message``, ``ack`` — but each of those is a frame already counted under its own + class (the advert event *and* its RX-logged ``ADVERT`` frame), so the traffic panel + counts frames only: every packet once, under the name the live feed gives it. + """ + return bucket == "packet" or bucket.startswith("packet:") -def _traffic_chrome(bucket: str) -> tuple[str, str, str]: - """One traffic bucket's ``(icon, label, meter style)``. +def _traffic_chrome(bucket: str) -> tuple[str, str]: + """One traffic bucket's ``(icon, label)`` — the live feed's, from the same function. - 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. Icons are mapped to single glyphs on PICOCALC via :func:`~meshterm.ui.theme.glyph`. """ - if bucket.startswith("packet:"): - typename = bucket.split(":", 1)[1] - gloss = _PAYLOAD_GLOSS.get(typename, typename.lower()) - label = _RAW_GLOSS_CLASH.get(gloss, gloss) - emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON) - return glyph(emoji), label, KIND_STYLES["packet"] - emoji = KIND_ICONS.get(bucket, DEFAULT_ICON) - return glyph(emoji), bucket, KIND_STYLES.get(bucket, "brand") + typename = bucket.split(":", 1)[1] if ":" in bucket else None + return payload_marks(typename) #: How many character cells a traffic lane's meter spans (48 half-step levels). @@ -439,26 +426,28 @@ class DashboardScreen(Screen): # -- traffic -- def _traffic_section(self) -> list[RenderableType]: - """Tallies by packet class, each with an icon and a proportional braille meter. + """Frames heard on the air, by class, each with an icon and a proportional 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. + Every class shows once heard, named as the live feed names it; classes never + heard stay hidden. Every bar is the one colour the feed draws a frame's class in + (``packet``'s): the icon and the label say which class a row is, so colour has + nothing left to say here. """ heading = Text("Traffic", style="accent") heading.append(" · by packet class · stored history + live", style="muted") - counts = self._kind_counts() + counts = {k: n for k, n in self._kind_counts().items() if _is_frame_bucket(k)} if not counts: return [heading, Text("nothing heard yet", style="muted")] order = {k: i for i, k in enumerate(_TRAFFIC_ORDER)} peak = max(counts.values()) chrome = {bucket: _traffic_chrome(bucket) for bucket in counts} - label_w = max(len(label) for _, label, _ in chrome.values()) + label_w = max(len(label) for _, label in chrome.values()) count_w = len(str(peak)) + style = KIND_STYLES["packet"] rows: list[RenderableType] = [heading] for bucket in sorted(counts, key=lambda k: (order.get(k, len(order)), k)): count = counts[bucket] - icon, label, style = chrome[bucket] + icon, label = chrome[bucket] row = Text(f"{icon} ") row.append(f"{label.ljust(label_w)} ", style="muted") row.append(f"{count:>{count_w}} ") diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index c106dc0..f4399bc 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -281,14 +281,23 @@ def class_marks(entry: PacketEntry) -> tuple[str, str]: """ if entry.kind == "packet": raw = entry.raw if isinstance(entry.raw, dict) else {} - typename = raw.get("payload_typename") - if typename: - emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON) - return glyph(emoji), _PAYLOAD_GLOSS.get(typename, typename.lower()) - return glyph(KIND_ICONS["packet"]), "packet" + return payload_marks(raw.get("payload_typename")) return kind_icon(entry.kind), entry.kind +def payload_marks(typename: str | None) -> tuple[str, str]: + """A raw frame's icon and class label from its payload typename alone. + + :func:`class_marks`'s half for a ``packet`` frame, for a surface that holds a tally by + typename rather than a packet (the dashboard's traffic rows), so a class reads the same + there as it does in the live feed's class lane. ``None`` is the class-less frame. + """ + if typename: + emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON) + return glyph(emoji), _PAYLOAD_GLOSS.get(typename, typename.lower()) + return glyph(KIND_ICONS["packet"]), "packet" + + def class_chrome(entry: PacketEntry) -> tuple[str, str]: """The class headline the viewer's card leads with — :func:`class_marks`, shouted. diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 7032251..9a8e3ff 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -80,7 +80,7 @@ def test_dashboard_renders_all_three_sections() -> None: screen = _screen( window=[_obs(), _obs(node="3d63", node_type=2, snr=-2.0)], histogram=[3] + [0] * (ACTIVITY_BUCKETS - 1), - kinds={"advert": 5, "ack": 1}, + kinds={"packet:ADVERT": 5, "packet:ACK": 1}, ) body = _plain(screen.render_body(100)) assert "Activity" in body and "Traffic" in body and "RF health" in body @@ -140,28 +140,41 @@ def test_dashboard_pulse_drops_heard_only_when_it_wont_fit() -> None: def test_dashboard_traffic_breaks_packets_out_by_payload_class() -> None: - """The raw ``packet`` bucket meters per payload class, glossed and iconed.""" + """Each frame class meters on its own row, glossed and iconed as the live feed does.""" screen = _screen( window=[_obs()], - kinds={"advert": 4, "packet:GRP_TXT": 3, "packet:TRACE": 2, "packet": 1}, + kinds={"packet:GRP_TXT": 3, "packet:TRACE": 2, "packet": 1}, ) body = _plain(screen.render_body(100)) assert "chan 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("chan text") < body.index("trace") + # The class-less remainder keeps a plain "packet" row after the classed ones. + assert body.index("chan text") < body.index("trace") < body.rindex("packet") -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(screen.render_body(100)) - assert "raw advert" in body and "raw ack" in body +def test_dashboard_traffic_counts_each_frame_once_under_the_feeds_names() -> None: + """The device's own reports stay out: an advert is its ADVERT frame, counted once. + + The monitor tallies the advert *event* and the RX-logged frame it arrived in, and a + decoded message beside its ``chan text`` frame; counting both lists one packet twice. + The frame's row takes the live feed's name — ``advert``, never a "raw" variant. + """ + from meshterm.ui.packet_viewer import payload_marks + + kinds = { + "advert": 40, + "telemetry": 9, + "message": 7, + "ack": 30, + "packet:ADVERT": 2, + "packet:ACK": 1, + } + lines = _plain(_screen(window=[_obs()], kinds=kinds).render_body(100)).splitlines() + start = next(i for i, ln in enumerate(lines) if ln.startswith("Traffic")) + rows = lines[start + 1 : lines.index("", start)] + assert [r.split()[1:3] for r in rows] == [["advert", "2"], ["ack", "1"]] + assert not any("raw" in r or "telemetry" in r or "message" in r for r in rows) + assert payload_marks("ADVERT")[1] == "advert" and payload_marks(None)[1] == "packet" def test_dashboard_live_observations_land_in_the_window() -> None: diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 47973f7..09b8dd4 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -177,7 +177,7 @@ def _dashboard(cols: int, rows: int) -> Screen: ], activity=lambda: (2.0,) * ACTIVITY_BUCKETS, activity_flags=lambda: (True,) * ACTIVITY_BUCKETS, - kind_counts=lambda: {"advert": 5, "ack": 2, "packet": 3}, + kind_counts=lambda: {"packet:ADVERT": 5, "packet:ACK": 2, "packet": 3}, )