Files
MeshTerm/meshterm/ui/dashboard_screen.py
T
Jean-Pierre MartineauandClaude Opus 5 42743bded2 Dashboard: the idle second was re-walking the whole window
The screen repaints once a second to keep its rates, ages and clock
honest. Both of its expensive stages are pure functions of data that
mostly does not move between those frames: the window digest re-walked up
to four thousand observations to reach the numbers it already had, and
the braille activity chart re-rasterized an identical picture.

The window changes in exactly two places — an arriving observation, an
expiring one — so both bump a revision the digest compares against. The
chart memoizes on the numbers it draws from. An idle repaint: 8.4 -> 3.8 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 05:42:20 -04:00

595 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The live mesh dashboard: everything going on around this node, on one screen.
The interactive face of the ``dashboard`` tool. One full-screen, always-repainting view
stacks three reads of the mesh, coarsest first:
* **Activity** — a tall braille bar chart of *every* packet the hub hears (adverts,
telemetry, RX-logged packets, messages, acks), one dot column per minute — braille's
full horizontal resolution, two minutes per character — stretched across whatever
width the terminal offers, newest at the right (the app-wide timeline direction)
with the count scale mirrored on both edges. The header indicator's big sibling,
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.
* **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
way Device info reads them.
The per-packet stream that used to close this screen is now its own tool — the Live
feed (see :mod:`meshterm.ui.livefeed_screen`), right under the dashboard in the menu —
so this screen is pure overview: it scrolls as one body under the arrow/page keys.
The screen holds no subscriptions of its own — the opener (:func:`open_dashboard`)
wires the hub subscription, the device-stats poll, and the once-a-second repaint, and
tears them all down when the screen resolves. Esc backs out directly.
"""
from __future__ import annotations
import asyncio
from collections import Counter, deque
from statistics import median
from typing import TYPE_CHECKING, Any, Optional
from rich.console import Group, RenderableType
from rich.table import Table
from rich.text import Text
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 (
DEFAULT_ICON,
KIND_ICONS,
KIND_STYLES,
PAYLOAD_ICONS,
_PAYLOAD_GLOSS,
)
from .theme import glyph, snr_style
from .trace_screen import snr_bar
from .tui.render import render_lines
from .tui.screen import Screen
if TYPE_CHECKING:
from ..context import AppContext
#: Seconds between full repaints while the dashboard is open (ages, rates, spinner-less).
_REFRESH_S = 1.0
#: Seconds between polls of the device's own statistics (noise floor, airtime, battery).
_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:<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.
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")
#: How many character cells a traffic lane's meter spans (48 half-step levels).
_TRAFFIC_METER_CELLS = 24
#: The label column every hanging-indent section grid reserves (the ``snr `` /
#: ``radio `` lane), so wrapped values align with their own block, never column 0.
_GRID_LABEL_W = 9
def _span_label(minutes: int) -> str:
"""A compact duration — ``45 min`` under two hours, else ``2.5 h`` / ``3 h``."""
if minutes < 120:
return f"{minutes} min"
text = f"{minutes / 60:.1f}"
return (text[:-2] if text.endswith(".0") else text) + " h"
class DashboardScreen(Screen):
"""The live mesh overview. Renders state and scrolls; the opener feeds it."""
floating = False
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The footer keys — advertising scroll only when the overview actually overflows.
The dashboard is one scrolling body, but on a tall terminal it often fits whole; a
static ``↑↓ PgUp/PgDn scroll`` then names keys that do nothing. Show that atom only
while the content is taller than the viewport (see
:attr:`~meshterm.ui.tui.screen.Screen.content_overflows`); otherwise Esc is the only
key that acts.
"""
if self.content_overflows:
return "↑↓ PgUp/PgDn scroll · Esc back"
return "Esc back"
@property
def fkey_lane(self):
"""The shared lane, dimmed on the same gate the hint above uses.
The PicoCalc draws no hint line at all — the lane *is* the footer — so the rule
the hint follows has to hold there too: an overview that fits whole has nothing
for Top/End or the paging pair to move.
"""
from .tui.fkeys import default_lane
return default_lane(nav=self.content_overflows)
def __init__(
self,
*,
session: Any,
resolve: Any,
window: list[Observation],
activity: Any,
activity_flags: Any,
kind_counts: Any,
) -> None:
"""Create the dashboard over its data feeds.
Args:
session: The running TUI session (for repaints).
resolve: Maps a node hash to a friendly contact name when known.
window: The stored observations seeding the trailing window (oldest first).
activity: Zero-arg callable returning the monitor's all-packet histogram.
activity_flags: Zero-arg callable returning the histogram's per-bucket
this-session flags (seeded history draws grey, live traffic green).
kind_counts: Zero-arg callable returning the monitor's kind tallies.
"""
super().__init__()
self.title = "Dashboard — mesh overview"
self._session = session
self._resolve = resolve
self._activity = activity
self._activity_flags = activity_flags
self._kind_counts = kind_counts
#: The trailing window of observations (stored seed + live), oldest first.
self._window: deque[Observation] = deque(window, maxlen=4000)
#: The device's own numbers, refreshed by the opener's poll (Device info's
#: dynamic rows): ``stats`` from get_stats, ``battery`` from get_battery.
self.stats: dict = {}
self.battery: dict = {}
# The one-pass window aggregates (see _digest_window), refreshed whenever the
# window itself moves — which is what ``_window_rev`` counts and ``_digest_rev``
# remembers. The screen repaints once a second to keep its clocks honest, and on a
# quiet mesh every one of those frames would otherwise re-walk the same 4000
# observations to reach the identical numbers.
self._window_rev = 0
self._digest_rev = -1
self._win_nodes: set[str] = set()
self._win_repeaters: set[str] = set()
self._win_counts: Counter = Counter()
self._win_snrs: list[float] = []
self._win_rssis: list[float] = []
#: The last activity chart and the inputs it was drawn from — see
#: :meth:`_activity_section`.
self._chart_memo: Optional[tuple[tuple, list[RenderableType]]] = None
# --- live window -----------------------------------------------------------------
def on_event(self, event: MeshEvent) -> None:
"""Fold one hub observation into the trailing window, and repaint."""
obs = event.observation
if obs is not None:
self._window.append(obs)
self._window_rev += 1
self._prune()
self._session.invalidate()
def _prune(self) -> None:
"""Drop window observations that aged past the trailing window."""
cutoff = utcnow() - OBSERVATION_WINDOW
while self._window and self._window[0].observed_at < cutoff:
self._window.popleft()
self._window_rev += 1
# --- input -----------------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
"""Scroll the overview body, or dismiss."""
if action == "up":
self.scroll_lines(-1)
elif action == "down":
self.scroll_lines(1)
elif action == "pageup":
self.scroll_pages(-1)
elif action in ("pagedown", "space"):
self.scroll_pages(1)
elif action in ("home", "ctrl_home"):
self.scroll_to_top()
elif action in ("end", "ctrl_end"):
self.scroll_to_bottom()
elif action == "escape":
self.resolve(None)
# --- rendering ---------------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""Render the overview sections as one scrolling body."""
self._prune()
self._digest_window()
body: list[RenderableType] = [
*self._activity_section(width),
Text(),
*self._traffic_section(),
Text(),
*self._rf_section(),
]
lines = render_lines(Group(*body), width)
self._scroll_total = max(1, len(lines))
return lines
def _digest_window(self) -> None:
"""Fold the observation window into the per-frame aggregates, in one pass.
The pulse line, the busiest-transmitter row, and the RF section each need a
different slice of the same window; scanning the (up to 4000-entry) deque once
per consumer added up to five full passes on every repaint of a busy mesh.
One pass here fills them all; the section builders read the results.
And that pass runs only when the window has actually moved. The screen repaints
every second so its rates, ages and clock stay honest, but the aggregates are a
pure function of the deque — on a mesh quiet for a beat, every one of those frames
was re-walking four thousand observations to arrive at the numbers it already had.
The window changes in exactly two places (an arriving observation, an expiring one),
both of which bump the revision this compares against.
"""
if self._digest_rev == self._window_rev:
return
self._digest_rev = self._window_rev
nodes: set[str] = set()
repeaters: set[str] = set()
counts: Counter = Counter()
snrs: list[float] = []
rssis: list[float] = []
for o in self._window:
if o.node and o.node_type == NODE_TYPE_REPEATER:
repeaters.add(o.node)
if o.kind == "packet":
continue
if o.node:
nodes.add(o.node)
counts[o.node] += 1
if o.snr is not None:
snrs.append(o.snr)
if o.rssi is not None:
rssis.append(o.rssi)
self._win_nodes = nodes
self._win_repeaters = repeaters
self._win_counts = counts
self._win_snrs = snrs
self._win_rssis = rssis
# -- activity --
def _activity_section(self, width: int) -> list[RenderableType]:
"""The all-packet chart — newest minute at the right — plus the pulse line.
One dot column per minute, two per character cell, stretched across every
cell the terminal offers between the scale gutters (drawn as the platform
draws them — the desktop mirrors its marks on the right, the console keeps
the tick alone; see :func:`~meshterm.ui.braillechart.axis_chrome`); a wider
terminal simply shows more history. Time runs oldest→now left to right,
every MeshTerm timeline's direction.
"""
histogram = list(self._activity()) # newest first, one count per minute
# Size the label lane from the whole histogram's peak (not just the visible
# slice) so the gutters never shift as a burst scrolls out of view.
label_w = axis_label_w(max(histogram, default=0), _CHART_ROWS)
chars = max(10, width - axis_chrome(label_w))
minutes = chars * 2
shown = (histogram + [0] * minutes)[:minutes]
peak = max(shown)
heading = Text("Activity", style="accent")
heading.append(" · every packet heard · one minute per dot column",
style="muted")
# Buckets seeded from a previous session's stored history draw grey; only
# what this session heard itself pulses green.
flags = (tuple(self._activity_flags()) + (True,) * minutes)[:minutes]
styles = ["ok" if live else "muted" for live in reversed(flags)]
# The chart is a pure function of these numbers, and on the idle second-tick they
# are the same numbers: the histogram only moves when a packet lands or the minute
# rolls over. Rasterizing the braille rows and their axis chrome is the priciest
# part of this screen's paint, so it is done once per distinct picture rather than
# once per repaint. One slot — the chart that just changed is the one to keep.
key = (chars, label_w, peak, minutes, tuple(shown), tuple(styles))
if self._chart_memo is not None and self._chart_memo[0] == key:
chart = self._chart_memo[1]
else:
def caption_at(frac: float) -> str:
if frac >= 1.0:
return "now"
return "−" + _span_label(round(minutes * (1 - frac)))
chart_rows = timeline_rows(
list(reversed(shown)), rows=_CHART_ROWS, column_styles=styles
)
chart = list(axis_chart(chart_rows, peak, chars, caption_at, label_w=label_w))
self._chart_memo = (key, chart)
out: list[RenderableType] = [heading, *chart]
out.append(Text())
out.append(self._pulse_grid(shown, width))
return out
def _grid(self, rows: list[tuple[str, Text]]) -> Table:
"""Labelled rows with a hanging indent: values wrap within their own block.
The app-wide alignment rule — a wrapped item's continuation lines align with
the item, never with the line start — done as a two-column frameless grid:
the label lane is fixed at :data:`_GRID_LABEL_W` cells, the value column
soaks up the rest and folds inside itself.
"""
grid = Table(
box=None, show_header=False, show_edge=False, pad_edge=False,
padding=(0, 0), expand=False,
)
grid.add_column(width=_GRID_LABEL_W, no_wrap=True)
grid.add_column(overflow="fold")
for label, value in rows:
grid.add_row(Text(label, style="muted"), value)
return grid
def _pulse_grid(self, shown: list[int], width: int) -> Table:
"""Rates, who's been heard, and the window's busiest transmitter.
``shown`` is the chart's visible slice (newest first, one bucket per minute),
so the quoted rates describe exactly what the chart draws. The word "heard"
after the node count is a luxury: it is kept only when the line fits ``width``
without wrapping.
"""
recent = sum(shown[:15]) / max(1, min(15, len(shown)))
overall = sum(shown) / max(1, len(shown))
span = _span_label(len(shown))
nodes = self._win_nodes # the one-pass window digest (see _digest_window)
repeaters = self._win_repeaters
def compose(heard: bool) -> Text:
line = Text()
line.append(f"{recent:.1f} pkt/min", style="brand")
line.append(f" (15 m) · {overall:.1f} ({span})", style="muted")
line.append(" · ", style="muted")
line.append(str(len(nodes)))
suffix = f" node{'s' if len(nodes) != 1 else ''}"
line.append(suffix + (" heard" if heard else ""), style="muted")
if repeaters:
line.append(
f" ({len(repeaters)} repeater{'s' if len(repeaters) != 1 else ''})",
style="muted",
)
return line
line = compose(heard=True)
if len(line.plain) + _GRID_LABEL_W > width:
line = compose(heard=False)
rows = [("pulse", line)]
busiest = self._busiest()
if busiest is not None:
name, count = busiest
value = Text(name, style="brand")
value.append(
f" {count} packet{'s' if count != 1 else ''} in the window",
style="muted",
)
rows.append(("busiest", value))
return self._grid(rows)
def _busiest(self) -> Optional[tuple[str, int]]:
"""The window's most-heard attributable node, or ``None`` in silence."""
counts = self._win_counts # the one-pass window digest (see _digest_window)
if not counts:
return None
node, count = counts.most_common(1)[0]
return self._name(node), count
# -- traffic --
def _traffic_section(self) -> list[RenderableType]:
"""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(_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())
count_w = len(str(peak))
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]
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=style))
rows.append(row)
return rows
# -- rf health --
def _rf_section(self) -> list[RenderableType]:
"""The window's reception quality plus the radio's own live numbers.
Rendered as a labelled grid so an item that wraps on a narrow terminal
aligns its continuation with its own block, not the line start.
"""
heading = Text("RF health", style="accent")
heading.append(" · reception over 2 h · radio live", style="muted")
rows: list[tuple[str, Text]] = []
snrs = self._win_snrs # the one-pass window digest (see _digest_window)
rssis = self._win_rssis
if snrs:
med = median(snrs)
line = Text(f"{med:+.1f} dB median ", style=snr_style(med))
line.append_text(snr_bar(med))
lo, hi = min(snrs), max(snrs)
line.append(f" worst {lo:+.1f} · best {hi:+.1f}", style="muted")
rows.append(("snr", line))
if rssis:
line = Text(f"{median(rssis):.0f} dBm median")
line.append(f" weakest {min(rssis):.0f} · strongest {max(rssis):.0f}",
style="muted")
rows.append(("rssi", line))
stats = self.stats
if stats.get("noise_floor") is not None:
line = Text(f"{stats['noise_floor']} dBm noise floor")
if stats.get("last_rssi") is not None:
line.append(f" · last RSSI {stats['last_rssi']} dBm", style="muted")
if stats.get("last_snr") is not None:
line.append(f" · last SNR {stats['last_snr']:+.1f} dB", style="muted")
rows.append(("radio", line))
extras = Text()
label = "airtime"
if stats.get("tx_air_secs") is not None:
extras.append(f"TX {stats['tx_air_secs']} s · RX {stats.get('rx_air_secs', 0)} s")
level = self.battery.get("level")
if level:
if extras.plain:
extras.append(" · ", style="muted")
else:
label = "battery"
extras.append(f"{int(level) / 1000:.2f} V")
if extras.plain:
rows.append((label, extras))
if not rows:
return [heading, Text("no receptions in the window yet", style="muted")]
return [heading, self._grid(rows)]
def _name(self, node: Optional[str]) -> str:
"""A node's friendly name when known, else its raw hash (never ``None``)."""
if not node:
return "?"
named = self._resolve(node)
return named if named else node
async def open_dashboard(ctx: "AppContext") -> None:
"""Open the live dashboard and run it until dismissed.
Wires the screen to its feeds: the stored trailing window seeds it, a hub
subscription streams every new packet in, the device's own statistics are polled
on a slow cadence (skipped quietly while nothing is connected), and a once-a-second
ticker keeps rates and ages honest. Everything is torn down when the screen closes.
Args:
ctx: The shared application context (must be running the interactive TUI surface).
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from ..services import trace_runner
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the dashboard is only available in the menu")
session = ctx.ui.session
contacts = []
try:
if ctx.is_connected or ctx.settings.connect_on_start:
# Through the session cache: contacts are one of the slowest reads on a
# screen open, so reading them once (not per dashboard open) is what keeps
# navigation snappy over Bluetooth.
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - the dashboard renders fine without contact names
contacts = []
# Contacts first, every name the recorder ever overheard as the fallback — the
# app-wide rule that a node we can name never renders as a bare hash.
resolve = trace_runner.make_node_resolver(contacts, ctx.repo.node_names())
window = ctx.repo.recent_observations(since=utcnow() - OBSERVATION_WINDOW)
screen = DashboardScreen(
session=session,
resolve=resolve,
window=window,
activity=ctx.monitor.activity_histogram,
activity_flags=ctx.monitor.activity_session_flags,
kind_counts=ctx.monitor.kind_counts,
)
unsubscribe = ctx.events.subscribe(screen.on_event)
async def poll_stats() -> None:
"""Refresh the radio's own numbers on a slow cadence, while connected."""
while True:
if ctx.is_connected:
try:
device = await ctx.device()
screen.stats = dict(await device.get_stats() or {})
screen.battery = dict(await device.get_battery() or {})
except Exception: # noqa: BLE001 - optional reads; keep the last good ones
pass
session.invalidate()
await asyncio.sleep(_STATS_POLL_S)
async def tick() -> None:
"""Repaint once a second so rates, ages, and the chart's clock stay honest."""
while True:
await asyncio.sleep(_REFRESH_S)
session.invalidate()
poller = asyncio.ensure_future(poll_stats())
ticker = asyncio.ensure_future(tick())
try:
await session.run_screen(screen)
finally:
unsubscribe()
for task in (poller, ticker):
task.cancel()
for task in (poller, ticker):
try:
await task
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - teardown must never surface a poll hiccup
pass