Files
MeshTerm/meshterm/ui/packet_viewer.py
T
Jean-Pierre MartineauandClaude Opus 5.5 b6e7cd5c16 The handhelds are named for their variant: picocalc-lyra and cardputer-zero
M5Stack makes three Cardputers and only the Zero runs Linux; the PicoCalc
takes several cores and only the Luckfox Lyra runs MeshTerm. So the bare
names were ambiguous, and every reference to the two devices now carries
the variant: the --platform values and MESHTERM_PLATFORM, the Platform
constants (PICOCALC_LYRA, CARDPUTER_ZERO), their lane decks and every
screen's picocalc_lyra_lane / cardputer_zero_lane, the font inventories,
the lane chips' theme styles, the scripts/ folders, the two chrome test
files, and the docs and CLAUDE.md that name them.

There is no alias: --platform picocalc is refused with the three real
choices listed, rather than falling back to the desktop layout. The
PicoCalc is auto-detected from its device tree, so it needs nothing.
What names hardware or a user's data keeps its name: the PicoCalc
keyboard's input-device name, its battery's sysfs supply, and the serial
profile the Lyra setup script writes. .gitignore follows the firmware's
folder, and the changelog keeps the words each release used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:52:41 -04:00

1206 lines
57 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The packet viewer: one floating popup that can open any packet a list shows.
Everywhere MeshTerm lists packets (the dashboard feed today; any future packet list)
the rows share this module's per-kind chrome — a colour and a two-cell icon per packet
class — and every row can open into the same viewer: a centered dialog over the list
that lays the packet out in full, flavoured by kind. An advert shows the node's
identity, type, and location; telemetry shows the node and its reported values; an
RX-logged packet shows its parsed class and route — a flood's with the region it was
scoped to, if any (:func:`~meshterm.ui.widgets.scope_text`) — what it was addressed to (the
recipient and sender read out of the frame body — see :mod:`~meshterm.core.frames` — or
the token a trace or an ack stands on), plus the relay path it rode in on —
as a ``via`` chain on THE path line (:mod:`~meshterm.ui.pathline`), wrapping at hop
boundaries under its own lane, and, when it actually crossed a relay, as THE route graph
(:mod:`~meshterm.ui.pathgraph`): origin → relays → us, the same layered picture the
Message paths dialog and the Trophy case draw — and, for an overheard channel-text frame
naming a channel we hold the key for, the decrypted text too (see
:func:`~meshterm.core.channels.decrypt_channel_text`), even though the radio itself never
decoded it for us; a message shows the sender, the
conversation, and the text; an ack shows its code. Common to all: the class headline
(icon + UPPERCASE class, first row of the card), the timestamp, the node (under its
shared node-type mark, name coloured by the app-wide palette, key in the key widget),
reception quality on the shared SNR
bar, and any leftover raw field the flavoured layout doesn't already show.
When opened over a list the viewer pages through it in place — ``↑``/``↓`` step to
the newer/older packet, Home/End jump to the newest/oldest, mirroring the keys the
opening list itself walks rows with — so a burst can be read packet by packet
without bouncing back out to the list. PgUp/PgDn scroll a tall packet inside the
dialog, matching what they do on every other screen; Esc closes it.
Over a *live* list — one that hands the viewer a ``source`` — the top is **two stops
rather than one**, exactly as the live feed's own top is. ``↑`` off the newest packet,
and Home from anywhere, lands on the **pin**: the view holds the top *position* instead
of a packet, so every arrival becomes the card being read, and the title says
``following`` where it would say ``n/total``. ``↓`` steps back off the pin onto whichever
packet is showing at that moment, which then rides down the list as newer ones arrive —
the reader who opened a packet to read it keeps it, and the reader who walked up to the
top gets the stream. Same two stops, same keys, as the feed underneath.
"""
from __future__ import annotations
from collections import OrderedDict
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import datetime
from rich.console import RenderableType
from rich.table import Table
from rich.text import Text
from ..core.channels import decrypt_channel_text, identify_channel
from ..core.models import Observation, node_type_label, utcnow
from ..core.regions import Scope, frame_scope
from ..services.trace_runner import NodeResolver
from .marks import SELF_MARK, UNKNOWN_MARK
from .menus import SEP_COMPACT, SEP_ROOMY
from .pathgraph import PathLayer, render_path_graph, revisited_hops
from .pathline import PathLine, hops_atom, path_line
from .theme import glyph, name_style, snr_style
from .trace_screen import snr_bar
from .tui.render import render_lines, render_to_ansi
from .tui.screen import Screen
from .widgets import (
DEFAULT_GLYPH,
NODE_GLYPHS,
NameKeyResolver,
TypeOf,
age_seconds,
format_ago,
highlighted_hash,
node_type_legend,
revisit_note,
route_graph_style,
scope_text,
short_frame,
)
#: Reads a frame's scope from its raw payload — ``ctx.region_store.scope_of`` in the app:
#: ``None`` for a frame with no scope to state (direct, or a route type never kept).
ScopeOf = Callable[[dict | None], Scope | None]
#: Reads a decoded message entry's scope off the flooded copies the packet log holds of it
#: (see :func:`~meshterm.ui.livefeed_screen.message_scope_reader`).
MessageScopeOf = Callable[["PacketEntry"], Scope | None]
def unnamed_scope(raw: dict | None) -> Scope | None:
"""A frame's scope read without any region names — the fallback when none are at hand.
Still says everything the frame itself says: a plain flood is ``unscoped``, and a
scoped one is ``unknown scope <code>``. Only the *naming* needs the known regions (see
:meth:`~meshterm.core.region_store.RegionStore.scope_of`), so a viewer opened without a
store never draws less than the truth, only less than the whole of it.
"""
return frame_scope(raw, ()) if isinstance(raw, dict) else None
#: Composed packet cards the viewer memoizes (a few pages either side of the view).
_BODY_CACHE_MAX = 8
#: Friendly gloss for a raw packet's parsed payload class (mirrors the meshcore
#: library's own ``PAYLOAD_TYPENAMES``), shown on a ``packet`` entry's "class" row.
_PAYLOAD_GLOSS = {
"REQ": "request",
"RESPONSE": "response",
"TEXT_MSG": "dir messg",
"ACK": "ack",
"ADVERT": "advert",
"GRP_TXT": "chan text",
"GRP_DATA": "chan data",
"ANON_REQ": "anon req",
"PATH": "path",
"TRACE": "trace",
"MULTIPART": "multipart",
"CONTROL": "control",
}
#: Raw-payload fields already folded into a flavoured row elsewhere (frame plumbing
#: parsed into "class"/"route", the channel crypto handled by
#: :meth:`PacketViewer._decrypt_rows`, or an advert field duplicating ``node``/
#: ``lat``/``lon``/etc.) — skipped so the generic dump doesn't repeat them.
# fmt: off
_RAW_ROW_SKIP = frozenset({
"node", "name", "kind", "snr", "rssi", "lat", "lon", "path", "node_type",
"observed_at", "text",
"header", "payload_ver", "transport_code", "scope_body", "path_len", "path_hash_size",
"payload_type", "payload_typename", "route_type", "route_typename",
"pkt_payload", "pkt_hash", "raw_hex", "payload", "payload_length", "recv_time",
"chan_hash", "cipher_mac", "crypted", "message", "msg_hash", "sender_timestamp",
"attempt", "txt_type",
"dest_hash", "src_hash", "src_key", "trace_tag", "ack_crc", "trace_snrs",
"adv_key", "adv_name", "adv_type", "adv_lat", "adv_lon",
})
# fmt: on
#: Display style per packet class, shared by every packet list and the viewer.
KIND_STYLES = {
"advert": "accent",
"telemetry": "brand",
"packet": "muted",
"message": "ok",
"ack": "faint",
}
#: The two-cell icon per packet class — every icon is emoji-wide, so a list can swap
#: the textual kind label for the icon alone on a narrow terminal without the lanes
#: shifting. The fallback marks a class this table has never heard of.
KIND_ICONS = {
"advert": "📢",
"telemetry": "📊",
"packet": "📦",
"message": "💬",
"ack": "✅",
}
DEFAULT_ICON = "❔"
#: The two-cell icon per parsed payload class of a raw ``packet`` frame, shared by the
#: viewer's leading class row and the dashboard's traffic meters. One glyph per concept:
#: 📻 rhymes with the Channels tool, 🎯 with Trace, and a raw ADVERT/ACK frame reuses
#: its kind's icon; :data:`DEFAULT_ICON` marks a typename this table has never heard of.
PAYLOAD_ICONS = {
"REQ": "📥",
"RESPONSE": "📮",
"TEXT_MSG": "📩",
"ACK": "✅",
"ADVERT": "📢",
"GRP_TXT": "📻",
"GRP_DATA": "💽",
"ANON_REQ": "🎭",
"PATH": "🧭",
"TRACE": "🎯",
"MULTIPART": "🧩",
"CONTROL": "🧰",
}
#: The colour a relayed packet's route draws in on THE route graph. It is the only path
#: on the canvas, so white just reads as "the route the packet rode" (the Trophy case's
#: single-walk convention).
_ROUTE_EDGE = (255, 255, 255)
@dataclass(slots=True)
class PacketEntry:
"""One listed packet, in the shape every packet list stores and the viewer reads.
A deliberately flat union of what the feed's three event families carry — the
monitor's observations, chat messages, and delivery acks — so one list type can
hold them all and the viewer can flavour by :attr:`kind`.
Attributes:
when: When the packet was heard.
kind: Packet class (``advert``/``telemetry``/``packet``/``message``/``ack``).
node: The transmitting node's stored hash, when identified.
name: The name the packet itself carried (an advert's node name, a message's
sender), if any; display falls back to resolving :attr:`node`.
snr: Reception SNR in dB, if measured (for ``packet`` rows: of the *last relay*).
rssi: Reception strength in dBm, if measured.
lat: Advertised latitude, when the packet shared a location.
lon: Advertised longitude, when the packet shared one.
node_type: The advertised node type (see ``NODE_TYPE_*``), if carried.
path: A ``packet`` row's relay path: comma-separated hop hashes in propagation
order (empty = arrived direct; ``None`` = the class carries no path).
where: A message's conversation (``ch 3`` / ``direct``) or an ack's code.
channel: A channel message's slot index — the fact behind ``where``'s prose, kept
apart from it so a list can name the channel rather than number it.
text: A message's body, when it is known.
raw: The raw event payload, for the values a class-specific layout can't name.
"""
when: datetime
kind: str
node: str | None = None
name: str | None = None
snr: float | None = None
rssi: float | None = None
lat: float | None = None
lon: float | None = None
node_type: int | None = None
path: str | None = None
where: str | None = None
channel: int | None = None
text: str | None = None
raw: dict | None = None
@classmethod
def from_observation(cls, obs: Observation) -> PacketEntry:
"""Capture an overheard observation (the monitor's event family) as an entry."""
return cls(
when=obs.observed_at,
kind=obs.kind,
node=obs.node,
name=obs.name,
snr=obs.snr,
rssi=obs.rssi,
lat=obs.lat,
lon=obs.lon,
node_type=obs.node_type,
path=obs.path,
raw=obs.raw,
)
def kind_icon(kind: str) -> str:
"""The icon for a packet class (a fallback for unknown classes).
Returns the emoji on REGULAR; on PICOCALC_LYRA, the emoji is mapped to a single-cell
glyph from the 512-glyph font via :func:`~meshterm.ui.theme.glyph`.
"""
emoji = KIND_ICONS.get(kind, DEFAULT_ICON)
return glyph(emoji)
def payload_class(raw: dict | None) -> str | None:
"""The friendly payload-class label for a raw ``packet`` frame, or ``None`` if unknown.
Maps the frame's ``payload_typename`` (``GRP_TXT``, ``TRACE``, …) through
:data:`_PAYLOAD_GLOSS` — the one identifying thing a relayed flood carries when it
names no origin node, so a packet list can read "chan text" / "trace" instead of a
bare ``?``.
"""
if not isinstance(raw, dict):
return None
typename = raw.get("payload_typename")
if not typename:
return None
return _PAYLOAD_GLOSS.get(typename, typename.lower())
def class_marks(entry: PacketEntry) -> tuple[str, str]:
"""What a packet actually *is*: its icon and its plain-case class label.
The one place the app decides which class a listed packet belongs to, so the viewer's
headline and the feed's class lane can never disagree. A raw ``packet`` frame's class
is its *parsed payload* class — ``channel text``, ``trace``, … — under its
:data:`PAYLOAD_ICONS` glyph, because ``packet`` alone names only the event family the
frame arrived in, not the thing it carries; a typename this table has never heard of
keeps the fallback mark and shows the raw typename; a class-less frame stays a plain
``packet``. Every other kind is its own class (``advert``, ``message``, …) under the
shared :data:`KIND_ICONS` glyph. The emoji are mapped to single glyphs on PICOCALC_LYRA.
"""
if entry.kind == "packet":
raw = entry.raw if isinstance(entry.raw, dict) else {}
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.
The card's first row is a headline, so it takes the UPPERCASE treatment; a list lane
reads the same class in its own plain case straight from :func:`class_marks`.
"""
icon, label = class_marks(entry)
return icon, label.upper()
def node_label(
entry: PacketEntry, resolve: NodeResolver, self_name: str | None = None
) -> tuple[str, str]:
"""The display name and style for an entry's node — the app-wide naming rule.
What ``resolve`` knows about the hash wins (the contact list is the canonical
identity), then the name the packet itself carried; a genuinely nameless node
shows its hash. Names take their stable palette hue
(:func:`~meshterm.ui.theme.name_style`) — except our own node, which is always
the pure-white ``you`` — and a bare hash stays muted, because colour is the
"this is a name" signal.
Args:
entry: The packet whose node to label.
resolve: Maps a node hash to a friendly name when known.
self_name: Our own node's name, to spot "us" (``None`` = never matches).
Returns:
``(label, style)``, ready to append to a row.
"""
name = None
if entry.node:
resolved = resolve(entry.node)
if resolved and resolved != entry.node:
name = resolved
if not name:
name = entry.name
if not name:
return (entry.node or "?", "muted")
if self_name and name == self_name:
return (name, "you")
return (name, name_style(name, entry.node))
@dataclass(frozen=True, slots=True)
class _Reception:
"""The card's reception row — SNR with its quality bar, then RSSI — sized to its lane.
Handed to the grid *unsized*, like the via path beside it: the value column's width is
only settled once the label lane is (see :meth:`PacketViewer._label_width`), so the row
lays itself out at render time. The two atoms chain on the app's roomy separator and
fall back to the compact one (:data:`~meshterm.ui.menus.SEP_ROOMY`) when the roomy form
would not fit — the header's own trade, made wherever the cells are the scarce thing.
On the 53-column console they are: a two-digit SNR beside a three-digit RSSI misses the
lane by exactly that padding, and folding put the bare word ``rssi`` on a line of its
own (JP, on-device, 2026-08-10).
Attributes:
snr: Signal-to-noise ratio in dB, if measured.
rssi: Reception strength in dBm, if measured.
"""
snr: float | None
rssi: float | None
def _line(self, separator: str) -> Text:
"""The row drawn with ``separator`` between the two atoms."""
line = Text()
if self.snr is not None:
line.append(f"{self.snr:+.1f} dB ", style=snr_style(self.snr))
line.append_text(snr_bar(self.snr))
if self.rssi is not None:
if self.snr is not None:
line.append(separator, style="muted")
line.append(f"{self.rssi:.0f} dBm rssi", style="muted")
return line
def __rich_console__(self, console, options):
roomy = self._line(SEP_ROOMY)
yield roomy if roomy.cell_len <= options.max_width else self._line(SEP_COMPACT)
class PacketViewer(Screen):
"""The floating packet dialog: one entry laid out in full, pageable over its list.
A read-only popup. The opener hands it the list *as rendered* (newest first) and
the index of the row the user opened; ``↑``/``↓`` then walk toward newer/older
packets — the same keys the opening list itself uses — Home/End jump to the
ends, PgUp/PgDn scroll a tall body, Esc closes. Opened over a single packet (a
one-entry list) the paging keys simply do nothing.
Over a live list (``source``) the top gains the feed's second stop — the pin, where
the view follows the stream instead of a packet (see the module docstring and
:meth:`_pin`). Opened over a snapshot there is nothing to follow, and ``↑`` on the
newest packet clamps as it always did.
The dialog only ever grows (see
:attr:`~meshterm.ui.tui.screen.Screen.grow_only`): paging from a short packet to a
tall one enlarges the box, but paging back keeps it at that size — blank-padded
below the shorter body — rather than re-centering smaller, so the header the reader
is looking at never hops around as they page.
"""
grow_only = True
@property
def picocalc_lyra_lane(self):
"""The shared pager over the body, with the jumps renamed for what they land on.
Home and End here don't reach the ends of a *body* — they reach the ends of the
*list*, the newest and oldest packet — so the Shift companions say ``Newest`` and
``Oldest``. The handedness is unchanged: newest is the top of a newest-first list,
so it keeps the right-hand slot behind Page ↑.
The two banks gate on different things, because they move different things. The
pager needs a body taller than the box; the jumps need somewhere to jump to — a
second packet, or, on a live list, the pin that ``Newest`` reaches even when the
list holds one packet (the feed's ``Top`` chip, one screen up). Opened over a lone
packet of a snapshot, all four chips go dim at once.
"""
from .tui.fkeys import FPair, default_lane
lane = list(default_lane(nav=self.content_overflows))
many = len(self._entries) > 1 or self._can_pin
lane[3] = FPair(
"Page ↓", "pagedown", "Oldest", "end", enabled=self.content_overflows, opp_enabled=many
)
lane[4] = FPair(
"Page ↑", "pageup", "Newest", "home", enabled=self.content_overflows, opp_enabled=many
)
return lane
def __init__(
self,
entries: list[PacketEntry],
index: int,
*,
resolve: NodeResolver,
prefix_bytes: int = 0,
self_name: str | None = None,
on_navigate: Callable[[PacketEntry], None] | None = None,
on_pin: Callable[[], None] | None = None,
channels: Sequence[tuple[str, bytes]] = (),
source: Callable[[], Sequence[PacketEntry]] | None = None,
type_of: TypeOf | None = None,
key_of: NameKeyResolver | None = None,
scope_of: ScopeOf | None = None,
message_scope: MessageScopeOf | None = None,
) -> None:
"""Open the viewer over a packet list.
Args:
entries: The list's packets, newest first — the opening snapshot.
index: Which entry to open on.
resolve: Maps a node hash to a friendly name when known.
prefix_bytes: Path-hash width to light in displayed hashes (0 = none).
self_name: Our own node's name, drawn white wherever it appears.
on_navigate: Called with the newly shown entry whenever paging moves the
view, so the opening list can walk its own highlight in step.
on_pin: Called when the view moves onto the pin, so a list that has the same
stop (the live feed) can follow the stream too and still be following it
when the dialog closes. Never called without a ``source``: the pin is
only a stop where the list is live.
channels: The device's configured channels, as ``(name, secret)`` pairs —
tried against an overheard ``packet`` entry's channel-text frame (see
:func:`~meshterm.core.channels.decrypt_channel_text`), so a raw frame
the radio never decoded for us can still be read when we hold the key.
source: Optional zero-arg callable returning the list's *current* packets
(newest first). When given, the viewer re-reads it every repaint and
keypress and re-locates the packet being viewed by identity, so packets
that arrive while the dialog is open become reachable (``↑`` walks up
into them) instead of the view being frozen at its opening snapshot.
type_of: Maps a relay hash to its node type, so a relayed packet's route
graph marks a repeater ``▲`` (etc.) instead of a generic dot; ``None``
keeps the plain named-dot / unknown-ring fallback.
key_of: Maps an origin's display name back to its node's key, so the route
graph's left endpoint takes its key-derived hue; ``None`` (or an
unresolvable name) leaves it muted.
scope_of: Reads a flood's scope off its raw frame against the regions known
by name (``ctx.region_store.scope_of``), so the ``scope`` row can say which
region a scoped flood was sent into. ``None`` falls back to
:func:`unnamed_scope`: the flood is still told apart as scoped or not,
and a scoped one shows its code where a name would go.
message_scope: Reads a decoded ``message`` entry's scope, which its own raw
payload cannot carry, off the flooded copies of it in the packet log (see
:func:`~meshterm.ui.livefeed_screen.message_scope_reader`). ``None``: a
message's card has no ``scope`` row.
"""
super().__init__()
self._entries = list(entries)
self._index = max(0, min(index, len(self._entries) - 1))
# Composed cards per (entry, width, age-minute) — see render_body.
self._body_cache: OrderedDict[tuple, list[str]] = OrderedDict()
self._resolve = resolve
self._prefix_bytes = prefix_bytes
self._self_name = self_name
self._on_navigate = on_navigate
self._on_pin = on_pin
self._channels = channels
self._source = source
self._type_of = type_of
self._key_of = key_of
self._scope_of: ScopeOf = scope_of or unnamed_scope
self._message_scope = message_scope
#: The packet currently shown, tracked by identity so a live prepend to the
#: source (which shifts every index) never slides the view onto another packet.
self._current: PacketEntry | None = self._entries[self._index] if self._entries else None
#: Whether the view is on the *pin* — the stop above the newest packet, which
#: holds the top position rather than a packet. Implies ``_index == 0``: the pin
#: is a mode on the newest entry, so everything that reads the view (the card,
#: the scroll, the cache) needs no special case; only the title and the moves
#: differ. Opening never pins — Enter named *this* packet (see
#: :meth:`~meshterm.ui.livefeed_screen.LiveFeedScreen._open_packet`) — so the
#: reader walks up to the stream deliberately or not at all.
self._pinned = False
self._update_footer()
self._set_title()
def _update_footer(self) -> None:
"""Set the footer hint to the moves that are live right now.
Three shapes, because the top of a live list is two stops. On the pin only ``↓``
moves, and the atom says what stepping off it buys — the packet on screen, held
against the next arrival. One stop below, ``↑`` and ``↓`` do different things (one
follows the stream, the other walks older), so they take an atom each rather than
sharing ``newer/older``, which would name neither. Everywhere else the pair walks
the list and reads as one atom, and a lone packet with nothing to follow keeps the
bare ``Esc close`` it always had.
"""
if self._pinned:
atoms = ["↓ hold packet"]
elif self._index == 0 and self._can_pin:
atoms = ["↑ follow"] + (["↓ older"] if len(self._entries) > 1 else [])
elif len(self._entries) > 1:
atoms = ["↑↓ newer/older"]
else:
atoms = []
if len(self._entries) > 1 or self._can_pin:
atoms += ["PgUp/PgDn scroll", "Home/End ends"]
atoms.append("Esc close")
self.footer_hint = " · ".join(atoms)
@property
def _can_pin(self) -> bool:
"""Whether this viewer has a stream to follow — which is what a ``source`` is.
Over a snapshot there is no second stop above the newest packet: nothing will ever
arrive there, so the pin would be an empty mode and ``↑`` clamps instead.
"""
return self._source is not None
def _sync(self) -> None:
"""Refresh the entries from the live source, re-finding the viewed packet by identity.
A no-op without a ``source``. Otherwise the list is re-read (picking up any
packets that arrived since the last paint) and the view stays on the *same*
packet object — its index simply moves as newer packets prepend ahead of it —
so paging keys measure against the current list and newly arrived packets sit
reachable above the one being read. A packet that has since aged out of the
list drops the view to the nearest surviving index.
The pin is where that parts company: it is attached to the *position*, so a
re-read lands it on whatever is newest now and the card under it changes. This
runs on every paint as well as before every move, so following costs nothing
more than the repaint the feed was already buying.
"""
if self._source is None:
return
entries = list(self._source())
if not entries:
return
self._entries = entries
if self._pinned:
self._show(0)
elif self._current is not None:
for i, candidate in enumerate(entries):
if candidate is self._current:
self._index = i
break
else:
self._index = min(self._index, len(entries) - 1)
self._current = entries[self._index]
else:
self._index = min(self._index, len(entries) - 1)
self._current = entries[self._index]
self._update_footer()
self._set_title()
def _set_title(self) -> None:
# No emoji in a dialog title (the standards' rule); the class row carries the icon.
# The position atom says which of the two things the view is doing: a count while
# it holds a packet, and ``following`` while it holds the top of the stream — the
# word rather than a perpetual ``1/n``, because what changes under the pin is the
# card, not the number.
entry = self._entries[self._index]
title = entry.kind
if self._pinned:
title += " · following"
elif len(self._entries) > 1:
title += f" · {self._index + 1}/{len(self._entries)}"
self.title = title
# --- input ---------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
"""Page through the list, follow the stream, scroll a tall entry, or dismiss."""
if action in ("up", "down", "home", "ctrl_home", "end", "ctrl_end"):
# Re-read the live list first so newly arrived packets are in range before we
# step — otherwise the first ``↑`` off the newest could never reach them.
self._sync()
if action == "up":
# Off the newest packet ``↑`` has one more place to go on a live list: the
# pin. On a snapshot the same press clamps, there being no stream to follow.
if self._index == 0:
self._pin()
else:
self._jump(self._index - 1)
elif action == "down":
# Off the pin ``↓`` does not move down a row — it lands on the same packet
# showing under it, now held. The feed's own ``↓`` off its pin, exactly.
self._jump(self._index if self._pinned else self._index + 1)
elif action in ("home", "ctrl_home"):
# "Take me back to the top" resumes the stream where there is one, rather than
# parking on whichever packet happens to be newest this instant — the feed's
# Home again, and what the ``Newest`` chip reaches on the console.
if self._can_pin:
self._pin()
else:
self._jump(0)
elif action in ("end", "ctrl_end"):
self._jump(len(self._entries) - 1)
elif action == "pageup":
self.scroll_pages(-1)
elif action in ("pagedown", "space"):
self.scroll_pages(1)
elif action in ("escape", "enter"):
self.resolve(None)
def _jump(self, index: int) -> None:
"""Hold the entry at ``index`` (clamped), leaving the pin behind.
Landing on an entry is by definition holding a packet rather than following the
stream, so this is also the way *off* the pin — and the one move that does
something while the index stays put, which is exactly what ``↓`` off the pin is.
The scroll resets only where the packet itself changes (see :meth:`_show`), so
that press keeps the reader where they were in the card they were already reading.
"""
index = max(0, min(index, len(self._entries) - 1))
if index == self._index and not self._pinned:
return
self._pinned = False
self._show(index)
self._set_title()
self._update_footer()
if self._on_navigate is not None and self._current is not None:
self._on_navigate(self._current)
def _pin(self) -> None:
"""Follow the stream: hold the top of the list, whichever packet is newest.
A no-op over a snapshot (nothing to follow), and on the pin already. Otherwise the
list is re-read so the pin lands on what is newest *now*, and the opener is told —
so a list carrying the same stop follows along, and is still following when the
dialog closes back onto it.
"""
if self._pinned or not self._can_pin:
return
self._pinned = True
self._sync()
self._set_title()
self._update_footer()
if self._on_pin is not None:
self._on_pin()
def _show(self, index: int) -> None:
"""Put the view on entry ``index``, starting a *different* packet from its top.
The scroll belongs to the packet, not to the position: paging onto another card
starts it at its beginning, while re-landing on the card already showing (``↓``
off the pin) leaves the reader exactly where they had scrolled to.
"""
self._index = index
entry = self._entries[index]
if entry is not self._current:
self._current = entry
self.scroll = 0
# --- rendering -------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""Lay the current entry out as labelled rows, flavoured by its kind.
A relayed ``packet`` slots THE route graph (:mod:`~meshterm.ui.pathgraph`) between
its parsed detail (heard/from/class/via) and its extras (location/text/raw): the
via row names each hop precisely, the graph shows the same relay chain as a shape —
origin at the left, us at the right. The two label/value grids around it share one
label-lane width so their rows still line up across the picture.
"""
# Fold in any packets that arrived since the last paint, keeping the view on the
# same packet, so the title's ``n/total`` and the reachable range stay current.
self._sync()
entry = self._entries[self._index]
# An entry is immutable once captured, so its whole card — rows, decrypted
# payload, route-graph layout — is a pure function of the entry, the width,
# and the minute its "heard" age reads (the one row that rolls with time).
# Memoize on exactly that: the repaint tick re-lays the graph only when a
# minute boundary actually moves the visible text.
age_minute = int(max(0.0, (utcnow() - entry.when).total_seconds()) // 60)
# …and of the flood's scope, which is *not* fixed at capture: a region learned
# while the card is open (a repeater's list arriving, a name typed) names a code
# that read as unknown a moment ago. The store memoizes the lookup, so asking it
# every paint costs a dict hit.
# …and of whether the frame is short, which sets the node-type key aside.
short = short_frame(self._scroll_viewport)
key = (id(entry), width, age_minute, self._entry_scope(entry), short)
cached = self._body_cache.get(key)
if cached is not None:
self._body_cache.move_to_end(key)
self._scroll_total = max(1, len(cached))
return cached
head, tail = self._head_rows(entry), self._tail_rows(entry)
label_w = self._label_width(head + tail, width)
lines = self._grid_lines(head, width, label_w)
graph = self._graph_lines(entry, width)
if graph:
lines.append("")
lines.extend(graph)
caption = Text("origin → you · labels = hash byte", style="faint")
lines.append(render_to_ansi(caption, width, no_wrap=True))
if not short: # the key steps aside on a short frame (see widgets.short_frame)
lines.extend(render_lines(node_type_legend(width=width), width, no_wrap=True))
if tail:
lines.append("")
lines.extend(self._grid_lines(tail, width, label_w))
self._scroll_total = max(1, len(lines))
self._body_cache[key] = lines
if len(self._body_cache) > _BODY_CACHE_MAX:
self._body_cache.popitem(last=False)
return lines
@staticmethod
def _label_width(rows: list[tuple[str, RenderableType]], width: int) -> int:
"""The shared label-lane width so wrapped values hang under their own block.
One fixed width for every grid on the card (the app-wide hanging-indent rule), never
column zero — sized to the widest label present (a raw payload field's full name
included, so none is clipped) plus a one-cell gutter, but never so wide it leaves the
value column under 20 cells (a pathological key then ellipsises instead of crushing it).
"""
widest = max((len(label) for label, _ in rows), default=0)
return max(10, min(widest + 1, width - 20))
@staticmethod
def _grid_lines(rows: list[tuple[str, RenderableType]], width: int, label_w: int) -> list[str]:
"""Render one label/value grid to ANSI lines (empty rows → no lines)."""
if not rows:
return []
grid = Table(
box=None,
show_header=False,
show_edge=False,
pad_edge=False,
padding=(0, 0),
expand=False,
)
grid.add_column(width=label_w, no_wrap=True)
grid.add_column(overflow="fold", max_width=max(20, width - label_w))
for label, value in rows:
grid.add_row(Text(label, style="muted"), value)
return render_lines(grid, width)
def _head_rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]:
"""The rows above the route graph.
The class headline, the common core, then a packet's parsed frame.
"""
rows: list[tuple[str, RenderableType]] = []
icon, class_label = class_chrome(entry)
rows.append(("class", Text(f"{icon} {class_label}")))
secs = age_seconds(entry.when)
heard = Text(entry.when.astimezone().strftime("%b %d %H:%M:%S"))
heard.append(f" ({format_ago(secs)})", style="muted")
rows.append(("heard", heard))
if entry.snr is not None or entry.rssi is not None:
rows.append(("snr", self._reception(entry)))
if entry.node or entry.name:
who = Text()
label, style = node_label(entry, self._resolve, self._self_name)
glyph, glyph_style = self._node_marker(entry, style)
who.append(glyph, style=glyph_style)
who.append(" ")
who.append(label, style=style)
if entry.node and label != entry.node:
who.append(" ")
who.append_text(highlighted_hash(entry.node, self._prefix_bytes))
rows.append(("from", who))
if entry.node_type is not None:
rows.append(("type", Text(node_type_label(entry.node_type))))
if entry.kind == "packet":
rows.extend(self._packet_rows(entry))
elif entry.kind == "message":
# A decoded message carries no routing of its own; its scope is read off the
# flooded copies of it the packet log holds (see :meth:`_entry_scope`).
scope = self._entry_scope(entry)
if scope is not None:
rows.append(("scope", self._scope_text(scope)))
return rows
def _node_marker(self, entry: PacketEntry, style: str) -> tuple[str, str]:
"""The node-type mark leading the ``from`` row — the app's shared node glyphs.
The same marks the map, the contact list and the route graph below plant on a
node (``★`` us, ``▲`` repeater, ``■`` room, ``◉`` sensor, ``●`` node), so a
packet's sender reads as the same *kind* of thing here as everywhere else — and
the ``type`` row underneath spells out in words what the glyph says at a glance.
The type comes from the packet itself when it carried one (an advert's), else
from what the contacts know about its hash; a node neither can type keeps the
``○`` unknown ring rather than being passed off as a plain client.
Args:
entry: The packet whose sender is being marked.
style: The style :func:`node_label` gave the name — ``"you"`` marks us.
Returns:
The ``(glyph, style)`` pair, ready to append to the row.
"""
if style == "you":
return SELF_MARK
node_type = entry.node_type
if node_type is None and entry.node and self._type_of is not None:
node_type = self._type_of(entry.node)
if node_type is None:
return UNKNOWN_MARK
return NODE_GLYPHS.get(node_type, DEFAULT_GLYPH)
def _tail_rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]:
"""The rows below the route graph: location, the message/ack fields, the raw dump."""
rows: list[tuple[str, RenderableType]] = []
if entry.lat is not None and entry.lon is not None:
rows.append(("location", Text(f"{entry.lat:.5f}, {entry.lon:.5f}")))
if entry.where:
label = "code" if entry.kind == "ack" else "where"
rows.append((label, Text(entry.where)))
if entry.text:
rows.append(("text", Text(entry.text)))
rows.extend(self._raw_rows(entry))
return rows
def _graph_lines(self, entry: PacketEntry, width: int) -> list[str]:
"""Draw a relayed packet's origin → relays → us route on THE route graph.
Only a ``packet`` frame that actually crossed a relay draws one — a direct
arrival's ``via`` row (``direct — no relays``) already says all there is to say, so
a two-node graph would waste the height. The relay chain is the packet's path
(originator-nearest first), the left endpoint the origin when the frame named one
(an advert's ``adv_key``; other classes arrive origin-less, drawn ``?``), the right
endpoint always us — the same layered route-graph widget the Message paths dialog
and the Trophy case draw, in a single white path.
Drawn with ``allow_duplicate_nodes``, because this chain is *observed*: we did not
compose it, its hops are named by a one-byte hash, and a repeat among them is as likely
two colliding nodes as a genuine loop. Folding such a repeat into one marker would make
a cycle the left-to-right flow cannot seat — the walk collapses into a pile of markers
one column wide — so each visit takes its own marker and the chain draws in the order it
was heard. The ``via`` row's :func:`~meshterm.ui.widgets.revisit_note` says why a name
appears twice.
"""
if entry.kind != "packet":
return []
hops = tuple(hop for hop in (entry.path or "").split(",") if hop)
if not hops:
return []
glyph_of, label_of, label_rgb_of = route_graph_style(
resolve=self._resolve,
self_name=self._self_name,
source=self._graph_source(entry),
type_of=self._type_of,
key_of=self._key_of,
)
return render_path_graph(
[PathLayer(hops=hops, color=_ROUTE_EDGE, priority=3)],
width,
glyph_of=glyph_of,
label_of=label_of,
label_rgb_of=label_rgb_of,
allow_duplicate_nodes=True,
)
def _graph_source(self, entry: PacketEntry) -> str | None:
"""The origin's display name for the graph's left endpoint, or ``None`` if unknown.
The naming rule the ``from`` row follows (a resolved contact wins, then the name the
frame carried), but a nameless origin returns ``None`` so the graph draws a plain
``?`` endpoint rather than passing a bare hash off as a named node.
"""
if entry.node:
resolved = self._resolve(entry.node)
if resolved and resolved != entry.node:
return resolved
return entry.name or None
@staticmethod
def _reception(entry: PacketEntry) -> _Reception:
"""SNR (with the shared quality bar) and RSSI on one line — see :class:`_Reception`."""
return _Reception(entry.snr, entry.rssi)
def _via_path(self, entry: PacketEntry) -> PathLine:
"""A ``packet`` entry's relay chain as THE path line (:mod:`~meshterm.ui.pathline`).
Handed to the grid *unsized*: the line renders itself into whatever cell width
the value lane works out, folding at hop boundaries — never mid-name, never
mid-chip — and hanging its continuations under the value block. The dialog has
the rows to spend, so a long chain reads whole here, unlike the feed row behind
it, which has to elide its middle to stay on one line.
A trace with no hops is the one case where an empty chain does not mean a direct
shot. A trace names its relays nowhere — it records a *reading* per hop instead
(see :func:`~meshterm.core.frames.trace_link_snrs`) — so a walked trace arrives
with an empty path and a full set of readings, and "direct — no relays" would be
a flat contradiction of the ``links`` row two lines above. It says how far the
packet got instead, which is the one thing those readings do establish.
The chain is the route's *middle* — the ``path`` field names the relays that
forwarded the frame and neither the node that sent it nor the one that received
it — so both ends are drawn open (``from_origin``/``to_destination``): the
chevron says the route carries on past the chip, where a flat end would claim
the first relay was the origin.
"""
return path_line(
(entry.path or "").split(","),
self._resolve,
prefix_bytes=self._prefix_bytes,
self_name=self._self_name,
empty=self._empty_via(entry),
from_origin=False,
to_destination=False,
)
@staticmethod
def _empty_via(entry: PacketEntry) -> str:
"""What an empty relay chain means for this frame's class."""
readings = (entry.raw or {}).get("trace_snrs") if isinstance(entry.raw, dict) else None
if readings:
hops = len(readings)
return (
f"{hops} hop{'' if hops == 1 else 's'} walked "
"— a trace logs each leg, not its relays"
)
return "direct — no relays"
def _packet_rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]:
"""What a raw ``packet`` entry has to say for itself.
Its addressing, parsed class and route, and relay path — plus, for a channel
frame naming a channel we hold the key for, its channel and plaintext.
"""
raw = entry.raw if isinstance(entry.raw, dict) else {}
rows: list[tuple[str, RenderableType]] = []
typename = raw.get("payload_typename") # class itself leads the card (class_chrome)
rows.extend(self._addressing_rows(raw))
route = raw.get("route_typename")
if route:
scope = self._entry_scope(entry)
rows.append(("route", self._route_text(route, scope)))
if scope is not None:
rows.append(("scope", self._scope_text(scope)))
rows.append(("via", self._via_path(entry)))
# The figure the chain above encodes but never states, hung under it as the app's
# own hop atom — the same one the node page's routes and the trace scenarios carry
# (:func:`~meshterm.ui.pathline.hops_atom`), so a count reads the same wherever a
# path has one. Only where there is a chain to count: an empty ``path`` has already
# been spelt out in words on the row above (:meth:`_empty_via`), and a second line
# reading ``direct`` under ``direct — no relays`` would be the same answer twice.
relays = [hop for hop in (entry.path or "").split(",") if hop]
if relays:
rows.append(("", hops_atom(len(relays))))
# A chain that names one hop twice reads as a mistake until it is explained; the graph
# below draws that hop twice too (see ``_graph_lines``), so the note covers both.
revisits = revisit_note(
revisited_hops([hop for hop in (entry.path or "").split(",") if hop]),
self._resolve,
prefix_bytes=self._prefix_bytes,
self_name=self._self_name,
)
if revisits is not None:
rows.append(("", revisits))
if typename == "GRP_TXT":
rows.extend(self._decrypt_rows(raw))
elif typename == "GRP_DATA":
# A datagram's body is not text, so there is nothing to decode for a reader —
# but the same MAC that would authorise decrypting it names its channel.
named = identify_channel(
raw.get("chan_hash") or "",
raw.get("cipher_mac") or "",
raw.get("crypted") or "",
self._channels,
)
if named is not None:
rows.append(("channel", Text(named[0], style="brand")))
elif raw.get("chan_hash"):
rows.append(
(
"channel",
Text(f"unknown (hash {raw['chan_hash']})", style="muted"),
)
)
return rows
def _entry_scope(self, entry: PacketEntry) -> Scope | None:
"""The scope of an entry's frame, or ``None`` where it has none to state.
A raw ``packet`` frame carries the route type and transport codes a scope is read
from. A decoded ``message`` carries neither — the radio hands over the text, not
the frame — so its scope is read off the flooded copies of it in the packet log,
where a ``message_scope`` reader was given (the live feed gives one for channel
messages). Every other kind answers ``None`` and draws nothing.
"""
if entry.kind == "message" and self._message_scope is not None:
return self._message_scope(entry)
if entry.kind != "packet" or not isinstance(entry.raw, dict):
return None
return self._scope_of(entry.raw)
@staticmethod
def _route_text(route: str, scope: Scope | None) -> Text:
"""The ``route`` row: how the frame was routed — ``flood`` or ``direct``.
A scoped flood reads ``flood``, not the library's ``tc flood``: that named the wire
mechanism (transport codes) and left the reader to know it meant *scoped*. A scoped
and an unscoped flood are one routing; where they differ is the region, and that
has a row of its own underneath (:meth:`_scope_text`).
A direct frame keeps its route word alone. A ``TC_DIRECT`` frame's codes are the
"to nowhere" pair a contact share uses, not a region, so it has no scope row.
"""
if scope is None:
return Text(route.replace("_", " ").lower())
return Text("flood")
@staticmethod
def _scope_text(scope: Scope) -> Text:
"""The ``scope`` row: the region a flood was sent into, stated on its own line.
Its own row, labelled, rather than an atom trailing the route: it is what a reader
opened the card from the feed's SCOPE lane to see, and a card has the room to say
it in full where the lane had ten cells. The label says *scope*, so the value is
the region alone (``harbour``, in the ``scope`` style); a region no name known here
reproduces reads ``unknown region · code 3fa1``, the code kept so two packets can be
seen to share it; a plain flood reads ``unscoped``.
"""
if scope.state == "scoped" and scope.region:
return scope_text(scope, bare=True)
if scope.scoped:
text = Text("unknown region", style="muted")
if scope.code:
text.append(f" · code {scope.code}", style="muted")
return text
return Text("unscoped", style="muted")
def _addressing_rows(self, raw: dict) -> list[tuple[str, RenderableType]]:
"""Who a frame was for, who it says it was from, and the token it carries.
The fields :mod:`~meshterm.core.frames` reads out of the frame body, laid out as
the card's own rows rather than left to the raw dump at the bottom — a recipient is
a node, and a node belongs under a resolved name and THE hash widget, not as a bare
hex value in a debug list. Each endpoint is named by one byte of its key, so the
name is the same good guess a relay hop's is (and the lit hash beside it says
exactly which byte was matched); an anonymous request's sender carries its whole
key, which resolves outright.
Args:
raw: The frame's raw payload.
Returns:
The rows to slot ahead of the route/via block (empty for a class that
addresses nothing).
"""
rows: list[tuple[str, RenderableType]] = []
dest = raw.get("dest_hash")
if dest:
rows.append(("to", self._endpoint(dest)))
src = raw.get("src_key") or raw.get("src_hash")
if src:
rows.append(("from", self._endpoint(src)))
if raw.get("trace_tag"):
rows.append(("tag", Text(raw["trace_tag"], style="muted")))
readings = raw.get("trace_snrs")
if readings:
rows.append(("links", self._link_readings(readings)))
if raw.get("ack_crc"):
# An ack identifies the message it answers, by that message's own checksum.
rows.append(("acks", Text(raw["ack_crc"], style="muted")))
return rows
def _link_readings(self, readings: list[float]) -> Text:
"""How well each leg of a trace's walk was heard, in the order it walked them.
A trace is the one class that reports on the mesh as it crosses it: every node
that forwards it records the SNR it heard its predecessor at, so an overheard
trace carries a reading per hop travelled — and how many there are says how far
along its route the frame had got when we caught it. Read oldest hop first, the
same direction a path line runs, each coloured by :func:`~meshterm.ui.theme.
snr_style` like every other reception figure in the app.
Args:
readings: The per-hop SNRs in dB, in walk order.
Returns:
The readings as one separated line.
"""
line = Text()
for at, value in enumerate(readings):
if at:
line.append(SEP_COMPACT, style="muted")
line.append(f"{value:+.2f} dB", style=snr_style(value))
return line
def _endpoint(self, value: str) -> Text:
"""One end of an addressed frame: its resolved name, then the key it was named by.
The ``from`` row's own presentation, minus the node-type glyph — a one-byte hash
is too thin an identity to plant a type mark on. A node we can't name shows the
hash alone, grey whole (``known=False``) like every unknown-node hash in the app.
"""
text = Text()
named = self._resolve(value)
known = bool(named and named != value)
if known:
style = (
"you" if self._self_name and named == self._self_name else name_style(named, value)
)
text.append(named, style=style)
text.append(" ")
text.append_text(highlighted_hash(value, self._prefix_bytes or 1, known=known))
return text
def _decrypt_rows(self, raw: dict) -> list[tuple[str, RenderableType]]:
"""Try every known channel's key against an overheard channel-text frame.
The frame names its channel only by a one-byte hash fingerprint (several
channels can collide on it), so :func:`~meshterm.core.channels.
decrypt_channel_text` confirms the match by MAC before trusting a key to
decrypt anything — a channel we don't hold the key for, or a fingerprint we
can't confirm, is reported as such rather than left silently missing.
"""
chan_hash = raw.get("chan_hash")
cipher_mac = raw.get("cipher_mac")
crypted = raw.get("crypted")
if not (chan_hash and cipher_mac and crypted):
return []
decrypted = decrypt_channel_text(chan_hash, cipher_mac, crypted, self._channels)
if decrypted is None:
return [
(
"channel",
Text(f"unknown (hash {chan_hash}) — can't decrypt", style="muted"),
)
]
rows: list[tuple[str, RenderableType]] = [
("channel", Text(decrypted.channel_name, style="brand")),
]
body = Text(decrypted.text)
if decrypted.sent_at is not None:
body.append(
f" · sent {decrypted.sent_at.astimezone().strftime('%H:%M:%S')}",
style="muted",
)
if decrypted.attempt:
body.append(f" (resend #{decrypted.attempt})", style="muted")
rows.append(("text", body))
return rows
def _raw_rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]:
"""The raw payload's leftover fields, one labelled row each (telemetry's meat).
Fields the flavoured layout already presents are skipped; the rest render as
flat ``key value`` rows so a telemetry frame's channels (or a new firmware
field the layout doesn't know yet) are still visible without a debugger.
"""
raw = entry.raw if isinstance(entry.raw, dict) else None
if not raw:
return []
shown = {k: v for k, v in raw.items() if k not in _RAW_ROW_SKIP and v is not None}
if not shown:
return []
rows: list[tuple[str, RenderableType]] = [("", Text())]
rows.append(("payload", Text(f"{len(shown)} raw fields", style="faint")))
for key, value in list(shown.items())[:12]:
body = str(value)
if len(body) > 200:
body = body[:199] + "…"
# The label lane widens to fit the full key (see :meth:`render_body`), so the
# field name is shown whole rather than clipped; the two-space indent nests it
# visually under the "payload" heading above.
rows.append((f" {key}", Text(body, style="muted")))
return rows