Files
MeshTerm/meshterm/ui/dashboard_screen.py
Jean-Pierre MartineauandClaude Opus 5.5 abfd32c491 Docstrings and comments of meshterm/ui are in ASD-STE100
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).

The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 20:37:26 -04:00

777 lines
34 KiB
Python
Raw Permalink 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.
# SPDX-License-Identifier: Apache-2.0
"""The live mesh dashboard: all the activity around this node, on one screen.
This is the interactive face of the ``dashboard`` tool. One full-screen page, which
paints again continuously, shows three summaries of the mesh, the most general first:
* **Activity**: a tall braille bar chart of *each* packet that the hub hears (adverts,
telemetry, RX-logged packets, messages, acks). It has one dot column for each minute,
which is the full horizontal resolution of braille: two minutes for each character.
The chart fills the full width of the terminal, with the newest minute at the right
(the timeline direction of the whole app), and with the count scale mirrored on both
edges. It is a larger form of the indicator in the header, drawn from the same
:meth:`~meshterm.services.monitor_service.MonitorService` buckets. A pulse line under
it shows the rate, the nodes that MeshTerm heard, and the busiest node of the time
window.
* **Traffic**: the packets heard on the air, counted by class (chan text, trace, path,
…), with the names and icons that the live feed gives them, and each with a
proportional bar. Each packet counts one time. What the device *reported* about a
packet (its advert event, a decoded message) does not count a second time next to it.
* **RF health**: the reception quality of the trailing time window. It shows the median
SNR (on the quality bar of the trace tool) and the median RSSI from stored
observations. It also shows the live numbers of the radio itself (noise floor, last
RSSI/SNR, airtime, battery), which MeshTerm polls from the device in the same way as
Device info.
**Scope** limits the full screen to the floods of one region. ``s`` (the F3 chip on the
PicoCalc) goes through each scope view in turn: all, unscoped, each region heard, and
unknown scopes. The ``w`` key of the Time Machine goes through its time windows in the
same way. A narrowed view reads the trailing time window of the screen itself (stored +
live, 2 h), because a cryptographic check names the region of each packet, and the
database cannot do that check. The numbers of the radio itself stay complete, because
they are the numbers of the device.
Before, a stream of each packet was at the end of this screen. Now it is a tool of its
own: the Live feed (refer to :mod:`meshterm.ui.livefeed_screen`), directly under the
dashboard in the menu. Thus this screen is only an overview. It scrolls as one body with
the arrow keys and the page keys.
The screen has no subscriptions of its own. The opener (:func:`open_dashboard`) connects
the hub subscription, the poll of the device statistics, and the paint each second. It
removes all of them when the screen resolves. The Esc key leaves the screen directly.
"""
from __future__ import annotations
import asyncio
import time
from collections import Counter, deque
from collections.abc import Callable
from statistics import median
from typing import TYPE_CHECKING, Any, NamedTuple
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 ..core.regions import Scope
from ..persistence.repository import OBSERVATION_WINDOW
from ..platforms import Platform, on_platform
from ..services.monitor_service import ACTIVITY_BUCKET_S, ACTIVITY_BUCKETS
from .braillechart import axis_chart, axis_chrome, axis_label_w, meter, timeline_rows
from .packet_viewer import KIND_STYLES, payload_marks
from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step
from .theme import 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
#: The seconds between full paints while the dashboard is open, for the ages and the rates
#: (the screen has no spinner).
_REFRESH_S = 1.0
#: The seconds between polls of the statistics of the device (noise floor, airtime, battery).
_STATS_POLL_S = 10.0
#: The height of the activity chart in braille rows (each row is four dot rows).
_CHART_ROWS = 3
#: The order of the classes in the traffic section. Heard classes that are not in this list
#: sort last, in alphabetical order. Chat packets come before protocol packets, and the
#: ``packet`` bucket without a class ends the list.
_TRAFFIC_ORDER = (
"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",
)
def _is_frame_bucket(bucket: str) -> bool:
"""Whether a count bucket counts packets heard on the air (``packet[:<TYPENAME>]``).
The monitor also counts what the *device* reported (``advert``, ``telemetry``,
``message``, ``ack``). But each of those is a packet that is already counted under its
own class (the advert event *and* its RX-logged ``ADVERT`` packet). Thus the traffic
section counts only the packets on the air: each packet one time, under the name that
the live feed gives it.
"""
return bucket == "packet" or bucket.startswith("packet:")
def _traffic_chrome(bucket: str) -> tuple[str, str]:
"""The ``(icon, label)`` of one traffic bucket, from the same function as the live feed.
On PICOCALC_LYRA, :func:`~meshterm.ui.theme.glyph` maps the icons to single glyphs.
"""
typename = bucket.split(":", 1)[1] if ":" in bucket else None
return payload_marks(typename)
#: The number of cells that the meter of a traffic lane uses (48 half-step levels).
_TRAFFIC_METER_CELLS = 24
#: The label column that each section grid with a hanging indent keeps free (the
#: ``snr `` / ``radio `` lane). Thus wrapped values align with their own block,
#: never with column 0.
_GRID_LABEL_W = 9
#: The title of the screen, before a scope atom.
_TITLE = "Dashboard — mesh overview"
#: What the activity chart of the whole mesh counts, as the aside of the heading says it.
#: The desktop adds the unit of the chart, ``one minute per dot column``. A font that draws
#: braille solid (the font of the PicoCalc or of the Cardputer,
#: :attr:`~meshterm.platforms.Platform.solid_braille`) has no dots to count. Also, on 53
#: columns, these words make the heading too long for its line (JP, 2026-10-04). The axis
#: under the chart still marks the minutes.
_ACTIVITY_ASIDE = "every packet heard · one minute per dot column"
@on_platform
def _bind_activity_aside(platform: Platform) -> None:
"""Bind the activity aside to the braille of the platform (runs now and at each switch)."""
global _ACTIVITY_ASIDE
_ACTIVITY_ASIDE = (
"every packet heard"
if platform.solid_braille
else "every packet heard · one minute per dot column"
)
class _ScopeDigest(NamedTuple):
"""The numbers of one scope view in the time window, in the shapes of the sections.
Attributes:
histogram: Packets for each minute, the newest first, in the bucket shape of the
monitor.
counts: Packets by traffic bucket (``packet:<TYPENAME>``).
snrs: All the SNR values of those packets.
rssis: All the RSSI values of those packets.
"""
histogram: tuple[int, ...]
counts: dict[str, int]
snrs: list[float]
rssis: list[float]
def _span_label(minutes: int) -> str:
"""A compact duration: ``45 min`` under two hours, or 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. It renders the state and scrolls. The opener gives it data."""
floating = False
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The footer keys. The hint shows the scroll keys only when the overview overflows.
The dashboard is one body that scrolls, but on a tall terminal the full body often
fits. Then a static ``↑↓ PgUp/PgDn scroll`` names keys that do nothing. Thus show
that atom only while the content is taller than the viewport (refer to
:attr:`~meshterm.ui.tui.screen.Screen.content_overflows`). In other cases, Esc is
the only key that acts, with ``s`` when there is more than one scope view.
"""
atoms = ["↑↓ PgUp/PgDn scroll"] if self.content_overflows else []
if len(self._ring()) > 1:
atoms.append("s scope")
return " · ".join([*atoms, "Esc back"])
@property
def picocalc_lyra_lane(self):
"""The shared F-key lane, dimmed like the hint above, plus the scope cycle.
The PicoCalc draws no hint line at all, because the lane *is* the footer. Thus the
rule of the hint must apply there too: when the full overview fits, Top/End and the
paging pair have nothing to move. The scope cycle uses F3, as the time-window cycle
of the Time Machine does. Its chip names the view that a key press goes *to* (the
title already says where you are). When no flood was heard that the screen can
narrow to, the slot stays empty.
"""
from .tui.fkeys import FPair, default_lane
lane = list(default_lane(nav=self.content_overflows))
views = self._ring()
if len(views) > 1:
lane[2] = FPair(scope_chip(step(views, self._scope)), "scope")
return lane
def __init__(
self,
*,
session: Any,
resolve: Any,
window: list[Observation],
activity: Any,
activity_flags: Any,
kind_counts: Any,
scope_of: Callable[[dict | None], Scope | None] | None = None,
) -> None:
"""Create the dashboard over its data feeds.
Args:
session: The active TUI session (for paints).
resolve: Changes a node hash into a friendly contact name, when the name is
known.
window: The stored observations that start the trailing time window (oldest
first).
activity: A callable with no arguments that returns the histogram of all
packets from the monitor.
activity_flags: A callable with no arguments that returns a flag for each
bucket of the histogram: whether the bucket is from this session (stored
history draws grey, live traffic draws green).
kind_counts: A callable with no arguments that returns the counts by kind
from the monitor.
scope_of: Names the scope of a packet from its raw payload
(:meth:`~meshterm.core.region_store.RegionStore.scope_of`). ``None`` gives
no scope views.
"""
super().__init__()
self._scope_of = scope_of
#: The scope view on the screen. ``None`` is all the packets heard.
self._scope: ScopeKey | None = None
self._set_title()
self._session = session
self._resolve = resolve
self._activity = activity
self._activity_flags = activity_flags
self._kind_counts = kind_counts
#: The trailing time window of observations (stored seed + live), oldest first.
self._window: deque[Observation] = deque(window, maxlen=4000)
#: The numbers of the device itself, which the poll of the opener refreshes (the
#: dynamic rows of Device info): ``stats`` from get_stats, ``battery`` from
#: get_battery.
self.stats: dict = {}
self.battery: dict = {}
# The aggregates of the time window, made in one pass (refer to _digest_window).
# They are refreshed each time that the window itself changes. ``_window_rev``
# counts these changes, and ``_digest_rev`` remembers the last one. The screen
# paints once a second to keep its clocks correct. Without this cache, on a quiet
# mesh each of those paints goes through the same 4000 observations again and gets
# the same 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 packets of the time window that have a scope, each with the view that it
#: counts in.
self._win_scoped: list[tuple[ScopeKey, Observation]] = []
#: The numbers of the narrowed view, and the inputs for which they were calculated
#: (refer to :meth:`_scoped`).
self._scoped_memo: tuple[tuple, _ScopeDigest] | None = None
#: The last activity chart, and the inputs from which it was drawn (refer to
#: :meth:`_activity_section`).
self._chart_memo: tuple[tuple, list[RenderableType]] | None = None
# --- live time window ------------------------------------------------------------
def on_event(self, event: MeshEvent) -> None:
"""Add one hub observation to the trailing time window, and paint again."""
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:
"""Remove the observations that are older than the trailing time 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, change the scope view, or close the screen."""
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 == "scope" or (action == "text" and data.lower() == "s"):
self._cycle_scope()
elif action == "escape":
self.resolve(None)
# --- scope -----------------------------------------------------------------------
def _ring(self) -> list[ScopeKey | None]:
"""All the scope views on offer: all packets, then each scope heard in the window."""
self._digest_window()
return ring((key for key, _ in self._win_scoped), self._scope)
def _cycle_scope(self) -> None:
"""Go to the next scope view (refer to :func:`~meshterm.ui.scopering.step`)."""
views = self._ring()
if len(views) < 2:
return
self._scope = step(views, self._scope)
self._set_title()
self.scroll_to_top()
self._session.invalidate()
def _set_title(self) -> None:
"""Set the title for the scope view: a narrowed view names itself, shorter if narrow."""
if self._scope is None:
self.title, self.short_title = _TITLE, ""
return
atom = scope_atom(self._scope)
self.title = f"{_TITLE} · {atom}"
self.short_title = f"Dashboard · {atom}"
def _scoped(self) -> _ScopeDigest:
"""The narrowed view's numbers, recalculated only for a new window, view, or minute."""
minute = int(time.time() // ACTIVITY_BUCKET_S)
memo_key = (self._window_rev, self._scope, minute)
if self._scoped_memo is not None and self._scoped_memo[0] == memo_key:
return self._scoped_memo[1]
histogram = [0] * ACTIVITY_BUCKETS
counts: Counter = Counter()
snrs: list[float] = []
rssis: list[float] = []
for key, o in self._win_scoped:
if key != self._scope:
continue
age = minute - int(o.observed_at.timestamp() // ACTIVITY_BUCKET_S)
if 0 <= age < ACTIVITY_BUCKETS:
histogram[age] += 1
typename = (o.raw or {}).get("payload_typename")
counts[f"packet:{typename}" if typename else "packet"] += 1
if o.snr is not None:
snrs.append(o.snr)
if o.rssi is not None:
rssis.append(o.rssi)
digest = _ScopeDigest(tuple(histogram), dict(counts), snrs, rssis)
self._scoped_memo = (memo_key, digest)
return digest
# --- rendering ---------------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""Render the overview sections as one body that scrolls."""
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 aggregates that each paint uses, in one pass.
The pulse line, the row of the busiest transmitter, and the RF section each use a
different slice of the same time window. Before, each consumer went through the
deque (up to 4000 entries) one time. That made up to five full passes on each paint
of a busy mesh. Now one pass here fills all of them, and the section builders read
the results.
Also, that pass runs only when the time window has changed. The screen paints each
second, so that its rates, ages, and clock stay correct. But the aggregates depend
only on the deque. Before, on a mesh that was quiet for a moment, each of those
paints went through four thousand observations again to get the numbers that it
already had. The time window changes in exactly two places (an observation that
arrives, an observation that expires), and both increase the revision that this
method 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] = []
scoped: list[tuple[ScopeKey, Observation]] = []
for o in self._window:
if o.node and o.node_type == NODE_TYPE_REPEATER:
repeaters.add(o.node)
if o.kind == "packet":
# The region store caches by packet, so each packet is checked one time in
# each visit.
key = scope_key(self._scope_of(o.raw)) if self._scope_of else None
if key is not None:
scoped.append((key, o))
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
self._win_scoped = scoped
# -- activity --
def _activity_section(self, width: int) -> list[RenderableType]:
"""The chart of all packets (the newest minute at the right), and the pulse line.
It has one dot column for each minute, two for each cell. The chart fills all the
cells of the terminal between the scale gutters. The gutters are drawn as the
platform draws them: the desktop mirrors its marks on the right, and the console
keeps only the tick (refer to :func:`~meshterm.ui.braillechart.axis_chrome`). A
wider terminal shows more history. Time goes from oldest to now, left to right,
which is the direction of each MeshTerm timeline.
"""
# The newest first, one count for each minute.
histogram = list(self._scoped().histogram if self._scope else self._activity())
# Size the label lane from the peak of the full histogram (not only of the visible
# slice), so that the gutters never move when a burst scrolls out of the chart.
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")
# A narrowed view uses the aside to tell what it counts, because the user must know
# it. The minute for each column is on the axis below, in both cases.
what = "floods in this scope · last 2 h" if self._scope else _ACTIVITY_ASIDE
heading.append(f" · {what}", style="muted")
# Buckets that come from the stored history of a previous session are grey. Only
# what this session heard itself shows green.
flags = (tuple(self._activity_flags()) + (True,) * minutes)[:minutes]
styles = ["ok" if live else "muted" for live in reversed(flags)]
# The chart depends only on these numbers, and on the idle tick each second they
# are the same numbers. The histogram changes only when a packet arrives or a new
# minute starts. To rasterize the braille rows and their axis chrome is the most
# expensive part of the paint of this screen. Thus it occurs one time for each
# different picture, not one time for each paint. The cache has one slot, because
# the chart that changed last 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: the values wrap in their own block.
This is the alignment rule of the whole app: the continuation lines of a wrapped
item align with the item, never with the start of the line. The method does it as
a grid of two columns without borders. The label lane has a fixed width of
:data:`_GRID_LABEL_W` cells. The value column uses the width that remains, and folds
its text in 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:
"""The rates, the heard nodes, and the busiest transmitter of the time window.
``shown`` is the visible slice of the chart (the newest first, one bucket for each
minute). Thus the rates describe exactly what the chart draws. The word "heard"
after the node count is not necessary. The method keeps it only when the line fits
in ``width`` and does not wrap.
"""
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 window digest of one pass (refer to _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")
if self._scope:
return line # the heard nodes come from adverts, which have no scope
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 = None if self._scope else 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) -> tuple[str, int] | None:
"""The most-heard node of the time window, or ``None`` when it is quiet.
Only the observations that name a node count.
"""
counts = self._win_counts # the window digest of one pass (refer to _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]:
"""The packets heard on the air, by class, each with an icon and a proportional meter.
Each class shows after MeshTerm hears it, with the name that the live feed gives
it. Classes that were never heard stay hidden. Each bar has the one colour in which
the feed draws the class of a packet (the colour of ``packet``). The icon and the
label tell the class of a row, so the colour has nothing more to say here.
"""
heading = Text("Traffic", style="accent")
if self._scope:
heading.append(" · by packet class · floods in the last 2 h", style="muted")
counts = self._scoped().counts
empty = "nothing heard in this scope in the last 2 h"
else:
heading.append(" · by packet class · stored history + live", style="muted")
counts = {k: n for k, n in self._kind_counts().items() if _is_frame_bucket(k)}
empty = "nothing heard yet"
if not counts:
return [heading, Text(empty, 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))
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 = 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, at full height and with no track. The lane shows
# 48 levels in its 24 cells (two half-steps for each 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 reception quality of the time window, and the live numbers of the radio itself.
It is rendered as a labelled grid. Thus an item that wraps on a narrow terminal
aligns its continuation with its own block, not with the start of the line.
"""
heading = Text("RF health", style="accent")
heading.append(" · reception over 2 h · radio live", style="muted")
rows: list[tuple[str, Text]] = []
# The window digest of one pass (refer to _digest_window), or the packets of the
# narrowed view.
if self._scope:
snrs, rssis = self._scoped().snrs, self._scoped().rssis
else:
snrs, rssis = self._win_snrs, 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: str | None) -> str:
"""The friendly name of a node if known, or 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 the user closes it.
The function connects the screen to its data feeds. The stored trailing time window
gives the first data. A hub subscription sends each new packet in. The function polls
the statistics of the device itself at a slow rate (and skips the poll without a
message while nothing is connected). A ticker paints each second, so that the rates
and ages stay correct. When the screen closes, the function stops and removes all of
these.
Args:
ctx: The shared application context (it must run the interactive TUI surface).
Raises:
RuntimeError: If the caller is not in the interactive menu (there is no
full-screen session).
"""
from ..services import trace_runner
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the only caller is the menu
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. The contacts are one of the slowest reads when a
# screen opens. Thus MeshTerm reads them one time (not at each open of the
# dashboard), and this keeps navigation fast over Bluetooth.
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - the dashboard renders correctly without contact names
contacts = []
# The contacts first, and all the names that the recorder ever heard as the fallback.
# This is the rule of the whole app: a node that 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,
scope_of=ctx.region_store.scope_of if ctx.region_store is not None else None,
)
unsubscribe = ctx.events.subscribe(screen.on_event)
async def poll_stats() -> None:
"""Refresh the numbers of the radio itself at a slow rate, while it is 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 values.
pass
session.invalidate()
await asyncio.sleep(_STATS_POLL_S)
async def tick() -> None:
"""Paint each second, so that the rates, the ages, and the chart clock stay correct."""
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 - the teardown must never show a poll failure
pass