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>
908 lines
45 KiB
Python
908 lines
45 KiB
Python
# 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
|