Files
MeshTerm/meshterm/ui/livefeed_screen.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

908 lines
45 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.
# SPDX-License-Identifier: Apache-2.0
"""The Live feed: every packet as it arrives, full-screen and newest first.
The interactive face of the ``livefeed`` tool — the dashboard's old feed panel,
promoted to a first-class screen. One always-repainting list streams the latest
packets, newest first: time, class (icon + label, icon alone on a narrow terminal),
subject, SNR/RSSI, and a message's conversation. What a row deliberately does *not* carry
is the relay path a frame rode in on: a route is a shape, not a lane, and squeezing one
into the cells left at the right edge only ever produced a stub — so the route belongs
to the packet viewer, which has the room to draw it as a wrapped path line over its
graph. The feed's job is to say what arrived and how well it was heard; Enter says how
it got here.
The subject lane is contextual: it holds whatever the *class* of packet is about (see
:meth:`LiveFeedScreen._feed_subject`). An advert or a telemetry frame is about the node
that sent it, so the lane names the node — but a channel text is about its channel, a
direct message or a request about the two nodes it travels between, a trace about its
tag. Only a class that genuinely says nothing about itself leaves the lane empty.
Seeded from stored history so the screen opens full, then streamed
live off the event hub. ``↑``/``↓`` walk the rows, PgUp/PgDn/Home/End page and jump
them, and ``←``/``→`` scroll the highlighted row sideways on a terminal too narrow to
hold it whole.
The top of the list is **two stops, not one** — the difference between watching the feed
and reading it. The first is the *pin*: it holds the very topmost position, so every
packet that arrives takes the highlight with it, and the cursor draws as ``^`` (pointing
past the list's top, at the traffic still to come) rather than the app's ``❯``. One
``↓`` off it does not move down a row — it lands on that same newest packet, now
*normally* selected under a plain ``❯``, and from there the highlight belongs to that
packet and rides down as newer ones push it along. So the pin follows the stream and a
selection follows a packet, and the mark in the cursor lane says which you are doing.
The screen opens pinned (a live feed's resting state is *watching*), ``↑`` from the newest
packet re-pins, and Home jumps straight back to the pin from anywhere.
Enter opens the highlighted packet in the shared
:class:`~meshterm.ui.packet_viewer.PacketViewer` — which then pages through the feed
itself with the same ``↑``/``↓``, and, for an overheard channel-text packet naming a
channel we hold the key for, decrypts it. **Both stops go up with it**: the viewer holds
the same pin above the newest packet, so ``↑`` off the top follows the stream there too
(each arrival becoming the card being read), and it walks the feed's cursor in step, pin
included — closing the dialog lands on whatever the reader was doing inside it rather
than on a state they left behind at the door.
The screen holds no subscriptions of its own — the opener (:func:`open_livefeed`)
wires the hub subscription and the once-a-second repaint, and tears them down when
the screen resolves. The newest packet is highlighted from the moment the screen
opens; Esc backs out directly.
"""
from __future__ import annotations
import asyncio
from collections import deque
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from rich.text import Text
from ..core.channels import identify_channel, split_channel_sender
from ..core.events import EventKind, MeshEvent
from ..core.frames import CHANNEL_CLASSES, ENDPOINT_HASH_BYTES
from ..core.models import Observation, utcnow
from .menus import fit_cells
from .packet_viewer import (
KIND_STYLES,
PacketEntry,
PacketViewer,
class_marks,
node_label,
)
from .pathline import SELF_GLYPH, PathHop, PathLine
from .theme import name_style, snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import NameKeyResolver, TypeOf
if TYPE_CHECKING:
from ..context import AppContext
#: Seconds between full repaints while the feed is open (keeps age-sensitive chrome
#: honest even between hub events).
_REFRESH_S = 1.0
#: How many feed rows are kept (the feed windows within the screen, so this is
#: history depth, not layout) — and, being the feed's *only* bound, the one number that
#: decides how far back the screen remembers. A busy hour on a real mesh runs to some
#: hundreds of packets, so a hundred rows forgot the last ten minutes exactly when the
#: feed was worth watching. Depth is nearly free here: the paint is windowed
#: (:meth:`LiveFeedScreen._feed_lines` formats only the rows on screen), so the cap costs
#: a pointer per entry and a few milliseconds of hydration at open, never anything per
#: frame.
_FEED_CAP = 500
#: How far back the opening seed may reach: **no limit** — the cap alone says how much
#: history the feed holds, and a clock has no business overruling it. A quiet mesh would
#: otherwise open a half-empty screen not because nothing was ever heard but because
#: nothing was heard *lately*, which is the one thing the reader can already see.
#:
#: The mechanics are unchanged — :meth:`~meshterm.persistence.repository.Repository
#: .recent_observations` still takes a ``since``, and this is simply a floor no stored
#: row predates. It stays cheap because ``observed_at`` is indexed: the query walks the
#: index backwards and stops after :data:`_FEED_CAP` rows, so widening the window reads
#: no more rows than narrowing it did. Deliberately *not*
#: :data:`~meshterm.persistence.repository.OBSERVATION_WINDOW`, which the feed used to
#: borrow: that one bounds the dashboard's rolling stats and is what its RF health card
#: means by "reception over 2 h".
_FEED_SEED_FLOOR = datetime.min.replace(tzinfo=timezone.utc)
#: The feed's fixed subject-lane width; anything longer ellipsizes so the columns hold.
#: Sized for a node name, which is what most classes put here — and wide enough that the
#: two endpoints of an addressed frame still read as ``a1 → Hilltop-Repe…`` rather than
#: collapsing to bare hashes. Widening it would push the row past the 72-column screen
#: the lanes are budgeted for, so the cells the lane can't hold are the viewer's to spend.
_FEED_SUBJECT_WIDTH = 18
#: The class lane's fixed width — wide enough for the longest class the app files a
#: packet under (``direct message``), so every label reads whole and the lanes hold.
_FEED_CLASS_WIDTH = 14
# The remaining lanes, as the row and its column header both measure them — one set of
# numbers so a header label can never drift off the values it names, and so a lane a
# packet left empty pads to exactly the width it would have filled.
#: Cells between adjacent lanes.
_LANE_GAP = 2
#: ``HH:MM:SS`` plus the gap that follows it.
_TIME_LANE = 8 + _LANE_GAP
#: The class icon and its trailing space (all the class lane keeps on a narrow terminal).
_ICON_LANE = 3
#: Cells a reception reading right-aligns its number in — the ``%+5.1f``/``%5.0f`` field
#: both lanes are built on, and the slot their column headers sit over.
_READING_W = 5
#: ``+13.2 dB`` — the number field, then its unit.
_SNR_LANE = _READING_W + len(" dB")
#: `` -61 dBm`` — the lane gap, the number field, then its unit.
_RSSI_LANE = _LANE_GAP + _READING_W + len(" dBm")
#: Terminal width below which the feed drops the textual class label and keeps only the
#: two-cell icon. It is exactly the width the full row occupies — with no route to make
#: room for, every lane fits a 72-column screen, so the label (the row's most informative
#: field now that it names the real class) is only ever dropped when it truly can't fit.
_FEED_LABEL_MIN_WIDTH = (
2
+ _TIME_LANE
+ _ICON_LANE
+ _FEED_CLASS_WIDTH
+ _LANE_GAP
+ _FEED_SUBJECT_WIDTH
+ _LANE_GAP
+ _SNR_LANE
+ _RSSI_LANE
)
#: Cells one ←/→ press shifts the highlighted row by — the app-wide select list's own
#: step (:attr:`~meshterm.ui.tui.select.SelectScreen._HSCROLL_STEP`), so a row here
#: scrolls at the rate a row anywhere else does.
_HSCROLL_STEP = 8
#: Moves that abandon the highlighted row's horizontal scroll: each row scrolls on its
#: own, exactly as an ``hscroll`` select list's rows do — landing on a new packet always
#: starts it at its own beginning.
_HSHIFT_RESET = frozenset(
{
"up",
"down",
"pageup",
"pagedown",
"space",
"home",
"ctrl_home",
"end",
"ctrl_end",
}
)
#: The cursor over a normally selected row — the app-wide select-list pointer, pointing
#: *into* the list at the one row it marks.
_CURSOR = "❯ "
#: The cursor over the pinned newest row (see the module docstring). The same lane, turned
#: to point past the top of the list — at the traffic still to arrive — because that is
#: what the pin is attached to: not this packet, but whichever one is newest. It is
#: deliberately not ``↑``, which this screen already spends on the window's ``↑ n more``
#: marker and would read here as "there is more above" — the one thing the top of the feed
#: can never mean; nor ``▲``, which is the repeater in the app's node-type set. A caret is
#: unclaimed, reads as "topmost" on both platforms, and is plain ASCII, so it needs no
#: entry in the console font's glyph map (see :mod:`meshterm.ui.fontset`).
_PIN_CURSOR = "^ "
def _channel_sender(text: str | None) -> str | None:
"""The sender named by a channel message's ``Name: `` prefix, or ``None`` if absent.
The shared protocol-layer parse (see
:func:`~meshterm.core.channels.split_channel_sender`), so the feed's node lane names
exactly the sender the chat transcript would.
"""
name, _body = split_channel_sender(text or "")
return name
class LiveFeedScreen(Screen):
"""The full-screen packet stream. Renders state; the opener feeds it."""
floating = False
@property
def fkey_lane(self):
"""The shared lane, gated on there being packets to walk.
Every nav key here moves the *cursor* over the feed rather than a scroll offset,
but the vocabulary is the same either way — the window follows the highlight — so
the shared labels stand. ``Top`` and ``Bottom`` stay literal on purpose: the top of
this list is the pin riding the newest packet and its bottom is the oldest one,
which is where End actually lands. ``Top`` is how the pin is reached where there is
no hint line to name it — a feed read down into history resumes following in one
chip.
A feed with nothing in it has no cursor and nowhere to move to, so all four chips
go dim until the first packet arrives.
"""
from .tui.fkeys import default_lane
return default_lane(nav=bool(self._feed))
def __init__(
self,
*,
session: Any,
resolve: Any,
seed: list[Observation],
prefix_bytes: int = 0,
self_name: str | None = None,
channels: Sequence[tuple[str, bytes]] = (),
channel_names: Mapping[int, str] | None = None,
type_of: TypeOf | None = None,
key_of: NameKeyResolver | None = None,
) -> None:
"""Create the feed over its data feeds.
Args:
session: The running TUI session (for repaints and the packet viewer).
resolve: Maps a node hash to a friendly contact name when known.
seed: Stored observations to open with (oldest first, as the repository
returns them); the newest :data:`_FEED_CAP` become the opening feed.
prefix_bytes: The hash width to light in the packet viewer's keys.
self_name: Our own node's name, drawn white wherever it appears.
channels: The device's configured channels, as ``(name, secret)`` pairs. The
subject lane names an overheard channel frame's channel by whichever of
these keys reproduces its MAC, and each opened
:class:`~meshterm.ui.packet_viewer.PacketViewer` decrypts with them.
channel_names: The device's channel names by slot index, so a channel message
the radio decoded for us can be filed under the same channel name an
overheard frame on it is — rather than under its slot number.
type_of: Maps a relay hash to its node type, handed to the packet viewer so a
relayed packet's route graph marks a repeater ``▲`` (etc.) over a dot.
key_of: Maps a sender's display name back to its node's key (see
:func:`~meshterm.services.trace_runner.make_name_key_resolver`), so a
channel sender the contacts or the recorder know takes its key-derived
hue; an unresolvable name stays muted.
"""
super().__init__()
self.title = "Live feed"
self._session = session
self._resolve = resolve
self._prefix_bytes = prefix_bytes
self._self_name = self_name
self._channels = channels
self._channel_names: Mapping[int, str] = channel_names or {}
self._type_of = type_of
self._key_of: NameKeyResolver = key_of or (lambda name: None)
#: The feed: latest events of every class as data, newest first — rendered
#: fresh each paint (rows adapt to width) and handed whole to the viewer.
self._feed: deque[PacketEntry] = deque(maxlen=_FEED_CAP)
for obs in list(seed)[-_FEED_CAP:][::-1]:
self._feed.append(PacketEntry.from_observation(obs))
#: The highlighted feed row, or ``None`` when there is no row to highlight —
#: an empty feed, which offers no cursor stop at all. Starts on the newest packet.
self._selected: int | None = 0 if self._feed else None
#: Whether the cursor is on the *pin* — the stop above row 0, which holds the
#: topmost position rather than a packet (see the module docstring). Implies
#: ``_selected == 0``: the pin is a mode on the newest row, so everything that
#: reads the highlight (the window fit, the viewer, the ``←→`` scroll) needs no
#: special case; only the cursor mark drawn in the pointer lane differs. A feed
#: with nothing in it has no row to pin to, and no cursor at all.
self._pinned: bool = bool(self._feed)
#: The feed's window within the fixed screen (its rows scroll under the heading).
self._feed_window = ListWindow()
#: The highlighted row's horizontal scroll (cells shifted in, ``←/→``) and how
#: far it can shift — measured against the width of the last paint, so a resize
#: can only ever clamp an in-progress scroll back into range.
self._hshift = 0
self._hmax = 0
# --- live feed -----------------------------------------------------------------
def on_event(self, event: MeshEvent) -> None:
"""Fold one hub event into the feed and repaint.
The arrival is where the screen's two top stops part company. A *selection* is
attached to a packet, so it rides down with it as the newer row pushes in above.
The *pin* is attached to the position, so it stays at row 0 and the packet under
it changes — which is the whole point of it, and also why the highlighted row's
horizontal scroll is dropped here: the cursor has landed on a different packet
just as surely as if the reader had pressed ``↓``, and every other move onto a new
row starts that row at its own beginning (see :data:`_HSHIFT_RESET`).
"""
entry: PacketEntry | None = None
obs = event.observation
if obs is not None:
entry = PacketEntry.from_observation(obs)
elif event.kind == EventKind.MESSAGE and event.message is not None:
msg = event.message
entry = PacketEntry(
when=utcnow(),
kind="message",
node=msg.sender,
snr=msg.snr,
where=f"ch {msg.channel}" if msg.is_channel else "direct",
channel=msg.channel if msg.is_channel else None,
text=msg.text,
raw=msg.raw,
)
elif event.kind == EventKind.ACK and event.ack is not None:
entry = PacketEntry(
when=utcnow(),
kind="ack",
where=event.ack.code or "delivery confirmed",
)
if entry is None:
return # nothing new on screen — don't buy a full repaint for it
self._feed.appendleft(entry)
if self._pinned:
self._selected = 0 # the pin holds the top: the newest packet is now this one
self._hshift = 0
elif self._selected is not None:
# Keep the highlight on the same packet as new rows push it down.
self._selected = min(self._selected + 1, len(self._feed) - 1)
self._session.invalidate()
# --- input -----------------------------------------------------------------------
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The key hint, gaining ``←→ scroll line`` exactly while it would do something.
A row that already fits scrolls nowhere, so the atom stays hidden on it — the
same "advertise a key only where it acts" rule the select list's own ``hscroll``
hint follows.
"""
base = "↑↓ PgUp/PgDn Home/End move · Enter open · Esc back"
if self._selected is not None and self._hmax > 0:
return "↑↓ PgUp/PgDn Home/End move · ←→ scroll line · Enter open · Esc back"
return base
def handle(self, action: str, data: str = "") -> None:
"""Walk the cursor, commit the row it rests on, scroll a row, or dismiss."""
if action in _HSHIFT_RESET:
self._hshift = 0 # moving off a row abandons its scroll
if action == "up":
self._move_selection(-1)
elif action == "down":
self._move_selection(1)
elif action == "enter":
self._commit()
elif action == "pageup":
# The page keys walk the cursor a windowful at a time, so the highlight
# travels with the window rather than being left behind by it.
self._select_stop(self._cursor - self._feed_window.page)
elif action in ("pagedown", "space"):
self._select_stop(self._cursor + self._feed_window.page)
elif action in ("home", "ctrl_home"):
# Home jumps to the pin — the top of a live feed is the *following* state, so
# "take me back to the top" resumes the stream rather than parking on whichever
# packet happens to be newest this instant. End goes to the oldest packet.
self._select_stop(0)
elif action in ("end", "ctrl_end"):
self._select_stop(len(self._feed))
elif action == "left":
self._scroll_line(-_HSCROLL_STEP)
elif action == "right":
self._scroll_line(_HSCROLL_STEP)
elif action == "escape":
self.resolve(None)
def _scroll_line(self, delta: int) -> None:
"""Shift the highlighted row sideways (clamped to its own tail) and repaint."""
shift = max(0, min(self._hshift + delta, self._hmax))
if shift != self._hshift:
self._hshift = shift
self._session.invalidate()
@property
def _cursor(self) -> int:
"""The cursor as one index over the screen's stops: the pin, then the feed's rows.
The stops are the pin (0), then the feed's rows (``1 .. len(feed)``) — so every
move stays a clamp on this one number, the pin included. That the pin and row 0
draw on the *same line* is a rendering detail; as cursor positions they are two,
which is exactly what makes ``↓`` off the pin land on the newest packet rather
than the second one.
An empty feed has no row to pin to, and so no stops at all.
"""
if self._pinned or self._selected is None:
return 0
return self._selected + 1
def _move_selection(self, delta: int) -> None:
"""Step the cursor by ``delta`` stops (both ends clamp on a packet)."""
self._select_stop(self._cursor + delta)
def _select_stop(self, index: int) -> None:
"""Move the cursor to one stop — the pin or a feed row — and repaint.
Args:
index: The stop to land on, clamped into range (see :attr:`_cursor`). Both ends
clamp rather than wrap, so a key held down settles at an end.
"""
if not self._feed: # nothing to pin or select: the screen has no cursor
self._pinned = False
self._selected = None
self._session.invalidate()
return
index = max(0, min(index, len(self._feed)))
self._pinned = index == 0
self._selected = max(0, index - 1)
self._session.invalidate()
def _select_row(self, row: int) -> None:
"""Put the cursor on feed row ``row``, normally selected — never on the pin.
The way *something else* moves the highlight (the packet viewer paging through the
feed under it). Landing on a specific packet is by definition a selection and not a
pin, even when that packet happens to be the newest one: the reader is following
that packet, so the next arrival must push it down rather than steal the cursor.
"""
self._select_stop(row + 1)
def _commit(self) -> None:
"""Enter: open the highlighted packet (an empty feed has none to open)."""
self._open_packet()
def _open_packet(self) -> None:
"""Float the packet viewer over the highlighted feed row.
Paging inside the viewer walks the feed highlight in step (via ``on_navigate``), so
closing it lands back on the packet last viewed — and opening one drops the pin for
the same reason. Enter names *this* packet; if the pin outlived it, the arrivals
during a long read would walk the cursor off it, and closing the viewer would land
somewhere the reader never chose.
"""
if self._selected is None or not self._feed:
return
self._select_row(self._selected)
def follow(entry: PacketEntry) -> None:
# The feed may have grown since the snapshot; find the entry itself.
for i, candidate in enumerate(self._feed):
if candidate is entry:
self._select_row(i)
return
viewer = PacketViewer(
list(self._feed),
self._selected,
resolve=self._resolve,
prefix_bytes=self._prefix_bytes,
self_name=self._self_name,
on_navigate=follow,
# The viewer carries this screen's own second stop, so a reader who walks up
# to the stream inside the dialog re-pins the feed under it and is still
# following when the dialog closes.
on_pin=lambda: self._select_stop(0),
channels=self._channels,
type_of=self._type_of,
key_of=self._key_of,
# The live feed itself (newest first), so the viewer keeps up with packets
# that arrive while it is open instead of freezing at this snapshot.
source=lambda: list(self._feed),
)
self._session.run_detached(self._session.run_screen(viewer))
# --- rendering ---------------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""The column header, then the feed's window.
The header is pinned by construction rather than by the base screen's sticky-header
machinery: it is struck from the viewport first and the feed windows *inside* what
is left (see :meth:`_feed_lines`), so the body never scrolls — the header can't
travel off the top of it, and the oldest packet can't sink past the bottom.
"""
if self._selected is not None:
self._selected = min(self._selected, len(self._feed) - 1) if self._feed else None
if self._selected is None:
self._hmax = 0 # nothing highlighted scrolls, so nothing advertises ←→
self._pinned = False # a feed with no rows has nothing to pin to
show_label = width >= _FEED_LABEL_MIN_WIDTH
lines: list[str] = []
if self._feed: # a header over nothing is noise; the empty note speaks for itself
lines.append(render_to_ansi(self._column_header(show_label), width, no_wrap=True))
win = max(1, self._scroll_viewport - len(lines))
lines.extend(self._feed_lines(width, win, show_label))
self._scroll_total = max(1, len(lines))
return lines
@staticmethod
def _column_header(show_label: bool) -> Text:
"""The lane names, in the app's uppercase muted column-header voice.
Laid out lane for lane against :meth:`_feed_row`, the pointer column included, so
every label sits over the values it names. Nothing here sorts — the feed is a
stream, and its one order (newest first) is what the ``TIME`` lane reads down — so
no column carries the contact list's sort triangle or its lit active-column style;
these are signposts, not controls. On a terminal too narrow for the class label
the header drops its ``CLASS`` too, leaving the icon lane unlabelled rather than
clipping a word into three cells.
The subject lane's label names the lane's *job*, not one class's answer to it:
what a packet is about is a node for an advert, a channel for a channel text, a
pair of endpoints for a direct message. ``SUBJECT`` is the word that stays true
down the whole column.
"""
header = Text(" ", style="muted") # the pointer lane
header.append(fit_cells("TIME", _TIME_LANE))
header.append(
fit_cells("CLASS", _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP)
if show_label
else " " * _ICON_LANE
)
header.append(fit_cells("SUBJECT", _FEED_SUBJECT_WIDTH + _LANE_GAP))
# The two readings right-align their number, so their labels do too — each sits
# over the digits it names rather than over the sign column ahead of them.
header.append(fit_cells("SNR", _READING_W, align="right"))
header.append(" " * (_SNR_LANE - _READING_W))
header.append(" " * _LANE_GAP)
header.append(fit_cells("RSSI", _READING_W, align="right"))
header.append(" " * (_RSSI_LANE - _LANE_GAP - _READING_W))
return header
def _feed_lines(self, width: int, win: int, show_label: bool) -> list[str]:
"""The feed's windowed rows: newest first, ``↑/↓ n more`` at the edges."""
if not self._feed:
return [render_to_ansi(Text("nothing heard yet", style="muted"), width)]
entries = list(self._feed)
top, count = self._feed_window.fit(len(entries), win, self._selected)
out: list[str] = []
if top > 0:
out.append(render_to_ansi(ListWindow.marker(top, "above"), width))
for i in range(top, top + count):
row = self._feed_row(entries[i], i == self._selected, show_label, width)
out.append(render_to_ansi(row, width, no_wrap=True))
below = len(entries) - top - count
if below > 0:
out.append(render_to_ansi(ListWindow.marker(below, "below"), width))
return out
def _feed_row(self, entry: PacketEntry, selected: bool, show_label: bool, width: int) -> Text:
"""Lay one feed row out in fixed lanes: time, class, subject, reception, detail.
The class lane says what the packet *is*, straight from
:func:`~meshterm.ui.packet_viewer.class_marks` — so a raw frame reads
``📻 channel text``, the class the viewer's card headlines, rather than the
``📦 packet`` event family it merely arrived in. Its icon always shows; the
textual label beside it is dropped wholesale on a narrow terminal
(``show_label``), keeping the lanes aligned either way. The subject lane beside it
then answers what the packet is *about*, in whatever terms its class deals in
(:meth:`_feed_subject`). No relay path rides here — the whole row fits a
72-column screen precisely because it doesn't try to, and the route is drawn
properly one keypress away, in the packet viewer.
The highlight is the app's own: the ``❯`` pointer and a ``cursor`` base under the
whole row, exactly as every select list draws its cursor. The lanes keep their own
colours over it — the SNR its quality hue, a name its palette hue — because those
colours are the content; the pointer and the base tint are what say "this row".
The one row that can wear a *different* pointer is the newest, which takes ``^``
while the cursor is pinned to the top of the stream rather than to the packet
currently sitting there (:data:`_PIN_CURSOR`, and the module docstring). Same lane,
same cursor tint, same one cell — only the mark differs, because only the meaning
does.
A row that runs past the right edge — a terminal narrower than the lanes need —
is read by scrolling it, not by growing it: the *highlighted* row slides under
``←→`` (:attr:`_hshift`), the app-wide h-scroll convention, with the ``❯``
pointer lane pinned so the cursor never scrolls away from the row it marks. The
shift bound is measured here, against the current width, so a resize can only
clamp it back into range.
"""
row = Text(no_wrap=True, overflow="ellipsis")
cursor = _PIN_CURSOR if self._pinned else _CURSOR
row.append(cursor if selected else " ", style="cursor" if selected else "")
avail = max(1, width - 2)
body = Text(no_wrap=True, overflow="ellipsis")
body.append(
fit_cells(entry.when.astimezone().strftime("%H:%M:%S"), _TIME_LANE),
style="muted",
)
icon, class_label = class_marks(entry)
body.append(fit_cells(icon, _ICON_LANE))
if show_label:
body.append(
fit_cells(class_label, _FEED_CLASS_WIDTH),
style=KIND_STYLES.get(entry.kind, "brand"),
)
body.append(" " * _LANE_GAP) # the lane's gutter — the longest class fills it
subject = self._feed_subject(entry)
# The lane fits like every other: cells, not characters, ellipsis on overflow —
# but as a Text, so a subject built of several styled pieces keeps them.
subject.truncate(_FEED_SUBJECT_WIDTH, overflow="ellipsis", pad=True)
body.append_text(subject)
body.append(" " * _LANE_GAP)
body.append(
f"{entry.snr:+{_READING_W}.1f} dB" if entry.snr is not None else " " * _SNR_LANE,
style=snr_style(entry.snr) if entry.snr is not None else "muted",
)
body.append(
f"{'':{_LANE_GAP}}{entry.rssi:{_READING_W}.0f} dBm"
if entry.rssi is not None
else " " * _RSSI_LANE,
style="muted",
)
note = self._feed_note(entry)
if note is not None:
body.append(" " * _LANE_GAP)
body.append_text(note)
if selected:
self._hmax = max(0, body.cell_len - avail)
self._hshift = min(self._hshift, self._hmax)
if self._hshift:
body = crop_cells(body, self._hshift, avail)
row.append_text(body)
if selected:
# The cursor tint rides *under* the row's spans (the select list's own
# convention), so the lanes keep their colours and only the gaps take it.
row.style = "cursor"
return row
def _feed_subject(self, entry: PacketEntry) -> Text:
"""What this packet is *about*, in the terms its own class deals in.
The lane used to be a node lane, which suited the two classes that name a node and
left every other one reading ``—``: on a real mesh that is half the traffic —
the direct messages, requests, responses, path returns, acks and traces that
relay past us naming no origin. Each of those is about *something*, just not
about a node, so the lane asks each class its own question:
* **advert, telemetry, a direct message we received** — about the node that sent
it, so the lane names the node (:func:`~meshterm.ui.packet_viewer.node_label`):
its palette hue, our own node white, a bare hash muted.
* **channel text and channel data** — about the *channel*, named by the key whose
MAC confirms the frame (:func:`~meshterm.core.channels.identify_channel`); a
channel we hold no key for falls back to the fingerprint it advertises.
* **direct message, request, response, returned path, anonymous request** —
about the pair of nodes it travels between, drawn as ``sender → recipient``
(:meth:`_endpoints`).
* **ack** — about the message it acknowledges, named by that message's checksum.
* **trace** — about the walk it is collecting, named by its tag.
* **a channel message** — about whoever signed it, else the channel it arrived on.
What the lane deliberately does *not* do is stand in the packet's class: the class
lane two columns left already says that, straight from
:func:`~meshterm.ui.packet_viewer.class_marks`, and a row that spelled it twice
read ``packet`` beside ``channel text`` as though they were two facts. A class that
genuinely says nothing about itself still gets the honest dash.
Args:
entry: The packet the row describes.
Returns:
The lane's content, unsized — the caller fits it to the lane.
"""
label, style = node_label(entry, self._resolve, self._self_name)
if label != "?": # the packet named a node: that is what it is about
return Text(label, style=style)
if entry.kind == "message":
return self._message_subject(entry)
raw = entry.raw if isinstance(entry.raw, dict) else {}
if raw.get("payload_typename") in CHANNEL_CLASSES:
return self._channel_subject(raw)
if raw.get("dest_hash"):
return self._endpoints(raw)
if raw.get("trace_tag"):
return self._token("tag", raw["trace_tag"])
if raw.get("ack_crc"):
# An ack names no node at all — what it identifies is the message it answers.
return self._token("for", raw["ack_crc"])
if entry.where:
return Text(entry.where, style="muted") # a delivery ack's code
return Text("—", style="muted")
def _message_subject(self, entry: PacketEntry) -> Text:
"""A chat message's subject: whoever signed it, else the channel it arrived on."""
sender = _channel_sender(entry.text)
if sender:
ours = self._self_name and sender == self._self_name
# A resolvable sender takes its key-derived hue; a stranger stays muted —
# colour is reserved for keyed identities.
style = "you" if ours else name_style(sender, self._key_of(sender))
return Text(sender, style=style)
channel = self._channel_names.get(entry.channel) if entry.channel is not None else None
if channel: # an unsigned post: the channel it landed on is what it is about
return Text(channel, style="brand") # named exactly as an overheard frame's is
if entry.where:
return Text(entry.where, style="muted") # …or the slot, when we can't name it
return Text("—", style="muted")
def _channel_subject(self, raw: dict) -> Text:
"""A channel frame's subject: its channel, named by the key its MAC confirms.
The frame names its channel only by a one-byte fingerprint, which several channels
can share — so a name is only shown once a key we hold has reproduced the frame's
own MAC (:func:`~meshterm.core.channels.identify_channel`), never on a fingerprint
match alone. A channel we hold no key for reads as the fingerprint it advertised,
under the app's word for a short derived id: ``hash a3``. That is deliberately not
spelled ``ch a3`` — a message row's ``ch 3`` is a *slot*, and two different numbers
wearing one prefix in the same column would be worse than saying nothing.
"""
named = identify_channel(
raw.get("chan_hash") or "",
raw.get("cipher_mac") or "",
raw.get("crypted") or "",
self._channels,
)
if named is not None:
return Text(named[0], style="brand")
if raw.get("chan_hash"):
return self._token("hash", raw["chan_hash"])
return Text("—", style="muted")
def _endpoints(self, raw: dict) -> Text:
"""An addressed frame's subject: the two nodes it travels between.
Drawn as ``sender → recipient`` on THE hop-sequence widget
(:mod:`~meshterm.ui.pathline`), so the endpoints of a delivery read exactly as the
hops of a route do — resolved to contact names, each in its key-derived hue, our
own node white the moment a frame is addressed to (or from) us. Plain arrows, not
chips: this is a lane inside a table row, and a chip's padding would cost the very
cells the names need.
Both endpoints are named by a single byte of their key — the same one-byte identity
a relay hop carries, with the same collision odds — so a name here is a good guess,
not a proof, and the frame's own hashes are always one keypress away in the viewer.
An anonymous request is the exception in our favour: it carries its sender's *whole*
key, which resolves exactly, and (unresolved) is shown cut to the same one byte
as everything else in the lane rather than 64 hex digits nobody can read at a
glance.
When both names don't fit the lane the recipient keeps its name and the sender
drops to its hash: the frame is *addressed*, so the addressee is the half worth
the cells.
"""
dest = raw.get("dest_hash") or ""
src = raw.get("src_key") or raw.get("src_hash") or ""
if not src:
return self._token("to", dest)
named = PathLine([self._hop(src), self._hop(dest)], mode="plain").text()
if named.cell_len <= _FEED_SUBJECT_WIDTH:
return named
# Too wide: the sender drops to its hash, so the addressee keeps its name whole (or
# as much of it as the lane holds) instead of being the half that gets amputated.
return PathLine([self._hop(src, named=False), self._hop(dest)], mode="plain").text()
def _hop(self, value: str, *, named: bool = True) -> PathHop:
"""One endpoint as a path hop: its contact name, or its one-byte hash in that hue.
Our own node resolves to the app-wide :data:`~meshterm.ui.pathline.SELF_GLYPH`
rather than its name — the one endpoint the reader never has to be told, and in an
18-cell lane the cells it gives back are the *other* end's name reading whole
instead of being cut.
Args:
value: The endpoint's key hash (or, for an anonymous request's sender, its
whole key — shown cut to the lane's one-byte width when it can't be named).
named: Resolve it at all. ``False`` forces the hash, for the crowded-lane
fallback that spends the cells on the other end.
"""
short = value[: 2 * ENDPOINT_HASH_BYTES]
name = self._resolve(value) if named else None
if name and name == self._self_name:
return PathHop(SELF_GLYPH, you=True)
if name and name != value:
return PathHop(name, key=value)
return PathHop(short, key=value, lit_bytes=ENDPOINT_HASH_BYTES)
@staticmethod
def _token(word: str, value: str) -> Text:
"""A frame's own token under the one word that says what it is.
A trace's tag, an ack's checksum, a channel's fingerprint: hex that identifies
something without being a node's identity, so it takes no palette hue — the
qualifier recedes into ``faint`` and the value itself stays muted-legible.
"""
text = Text(f"{word} ", style="faint")
text.append(value, style="muted")
return text
def _feed_note(self, entry: PacketEntry) -> Text | None:
"""The row's trailing detail — a message's conversation, and nothing else.
A relayed frame's route used to sit here, and it never fitted: whatever the fixed
lanes left it was a handful of cells, so the chain arrived elided to a stub that
named one hop and hinted at the rest. A route is a shape rather than a lane, and
it is one keypress away — the packet viewer draws it as a wrapped path line over
the route graph, with the room to say the whole thing. So the feed says what
arrived; Enter says how it got here.
A conversation the subject lane already stood in for is not repeated here: an
unsigned channel post files itself *under* its channel, and a row that named the
same conversation twice read as two facts.
Args:
entry: The packet the row describes.
Returns:
The detail to append, or ``None`` when the class carries none.
"""
if entry.kind != "message" or not entry.where:
return None
if entry.channel is not None and not _channel_sender(entry.text):
return None # the subject lane is already the conversation
return Text(entry.where, style="muted")
async def open_livefeed(ctx: AppContext) -> None:
"""Open the live feed and run it until dismissed.
Wires the screen to its feeds: stored recent observations seed it, a hub
subscription streams every new packet in, and a once-a-second ticker keeps the
clock-sensitive rows 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 live feed is only available in the menu")
session = ctx.ui.session
contacts = []
self_name: str | None = None
channels: list[tuple[str, bytes]] = []
channel_names: dict[int, str] = {}
try:
if ctx.is_connected or ctx.settings.connect_on_start:
# Through the session cache: contacts and the channel-slot probe are the two
# slowest reads on a screen open, so reading them once (not per feed open)
# is what keeps navigation snappy over Bluetooth.
contacts = await ctx.devstate.contacts()
self_name = (await ctx.devstate.self_info()).get("name") or None
slots = await ctx.devstate.channel_slots()
channels = [(s.name, s.secret) for s in slots]
channel_names = {s.idx: s.name for s in slots}
except Exception: # noqa: BLE001 - the feed 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.
stored_names = ctx.repo.node_names()
resolve = trace_runner.make_node_resolver(contacts, stored_names)
type_of = trace_runner.make_node_type_resolver(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names)
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
# The cap is the only bound: the newest _FEED_CAP packets ever recorded, however
# long ago the quiet stretch before them began.
seed = ctx.repo.recent_observations(since=_FEED_SEED_FLOOR, limit=_FEED_CAP)
screen = LiveFeedScreen(
session=session,
resolve=resolve,
seed=seed,
prefix_bytes=prefix_bytes,
self_name=self_name,
channels=channels,
channel_names=channel_names,
type_of=type_of,
key_of=key_of,
)
unsubscribe = ctx.events.subscribe(screen.on_event)
async def tick() -> None:
"""Repaint once a second so the clock-sensitive rows stay honest."""
while True:
await asyncio.sleep(_REFRESH_S)
session.invalidate()
ticker = asyncio.ensure_future(tick())
try:
await session.run_screen(screen)
finally:
unsubscribe()
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - teardown must never surface a tick hiccup
pass