Dashboard traffic: the packet bucket breaks out by payload class

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:<TYPENAME> 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 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-19 00:55:01 -04:00
1 parent 79410c4f16
commit dcc98f9cd8
4 files changed
+143 -26

No files matched your search

+13 -6
View File
@@ -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:<TYPENAME>`` (``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).
+11 -3
View File
@@ -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:<TYPENAME>``,
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
+58 -17
View File
@@ -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:<TYPENAME>``
#: 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:<TYPENAME>``
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
+61
View File
@@ -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:<TYPENAME>`` 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:<TYPENAME>`` 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 -----------------------------------------------------------