Files
MeshTerm/meshterm/ui/livefeed_screen.py
Jean-Pierre MartineauandClaude Opus 5.5 9ae0947717 A shared name goes to the newest key unless two keys used it within a week
Greying every name that two keys ever used was too strict: a reflash with an
erase makes a new key, and 34 names on this mesh have one, JP's own among
them. Smog went grey this morning because of two keys silent since September.

The newest key now gets the name, and the name is grey only when another key
used it within name_overlap_days (a new Display preference, "Shared names",
default 7) of it: two live claimants. A contact that was never heard never
wins. On this machine's real data, LOG.IC 3R goes to its active key 47368a7c,
Smog to fa331c83, and 8 of 883 names stay grey.

The stored names come with their times from the one scan node_names() already
ran (node_names_with_times), so opening a chat costs no second scan.

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

1127 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The Live feed: each packet when it arrives, full-screen, with the newest first.
This screen is the interactive face of the ``livefeed`` tool. It was the feed panel of
the dashboard, and now it is a screen of its own. One list, which MeshTerm paints again
continuously, shows the latest packets with the newest first: the time, the class (its
name, next to an icon where the platform draws icons), the subject, the SNR/RSSI, and the
conversation of a message.
A row intentionally does not show the relay path on which its packet arrived. A route is
a shape, not a lane. When a row put a route into the cells that remained at the right
edge, the result was always a stub. Thus the route belongs to the packet viewer, which
has the space to draw it as a wrapped path line over its graph. The feed tells what
arrived and how well it was heard. Enter tells how it arrived.
A **scope** lane is between the subject and the readings, at each width. It shows:
- the region into which a flood was sent (``harbour``),
- ``? 3fa1`` for a region that has no name here,
- a muted ``-`` for a plain flood, and
- nothing for a direct packet, because no repeater filters a direct packet by region.
The scope lane never collapses, and no other lane collapses. On a narrow terminal, the
user scrolls the highlighted row sideways instead (refer to :func:`_lanes_for`).
The content of the subject lane changes with the class of the packet: the lane shows
what the packet is about (refer to :meth:`LiveFeedScreen._feed_subject`). An advert or a
telemetry packet 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 is about the two nodes
between which it travels. A trace is about its tag. Only a class that says nothing about
itself leaves the lane empty.
The screen starts with rows from the stored history, so it is full when it opens. Then
it gets new rows live from the event hub. ``↑``/``↓`` move through the rows, and
PgUp/PgDn/Home/End page and jump through them. On a terminal that is too narrow for all
of a row, ``←``/``→`` scroll the highlighted row sideways.
The top of the list has **two stops, not one**. The two stops are the difference between
a user who watches the feed and a user who reads it:
- The first stop is the *pin*. It holds the topmost position, so each packet that
arrives takes the highlight with it. The pointer shows as ``^`` (it points above the
top of the list, at the traffic that is still to come), instead of the usual ``❯`` of
the app.
- One ``↓`` from the pin does not move down a row. It goes to the same newest packet,
which is now selected in the usual way, under a plain ``❯``. From there, the highlight
belongs to that packet, and it moves down when newer packets push it.
Thus the pin follows the stream, and a selection follows a packet. The mark in the
pointer lane shows which of the two the user does. The screen opens on the pin, because
the usual state of a live feed is that the user watches it. ``↑`` from the newest packet
goes back to the pin, and Home goes directly back to the pin from any position.
Enter opens the highlighted packet in the shared
:class:`~meshterm.ui.packet_viewer.PacketViewer`. The viewer then pages through the feed
itself with the same ``↑``/``↓``. If an overheard channel-text packet names a channel for
which we have the key, the viewer decrypts it. **The viewer has the two stops too.** It
holds the same pin above the newest packet, so ``↑`` from the top follows the stream
there too (each packet that arrives becomes the card that the user reads). The viewer
opens on the stop that the feed has. Enter on the pin opens the viewer on the pin, so
the card follows the stream immediately, and Enter on a selected packet opens that
packet and holds it. The viewer also moves the cursor of the feed with it, the pin
included. Thus, when the dialog closes, the feed is on the stop that the user had in the
dialog, not on the stop that the user left when the dialog opened.
The screen has no subscriptions of its own. The opener (:func:`open_livefeed`) connects
the hub subscription and the paint that occurs one time each second, and removes them
when the screen resolves. The newest packet is highlighted when the screen opens. Esc
leaves the screen 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, NamedTuple
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 ..platforms import Platform, on_platform
from .menus import fit_cells
from .packet_viewer import (
KIND_STYLES,
MessageScopeOf,
PacketEntry,
PacketViewer,
ScopeOf,
class_marks,
node_label,
unnamed_scope,
)
from .pathline import SELF_GLYPH, PathHop, cut_to
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, route_ends, scope_text
if TYPE_CHECKING:
from ..context import AppContext
from ..core.regions import Scope
#: The seconds between full paints while the feed is open. These paints keep the chrome
#: that shows ages correct, also between hub events.
_REFRESH_S = 1.0
#: The number of feed rows that the screen keeps. The feed is a list window in the
#: screen, so this number is the depth of the history, not the layout. It is the only
#: limit of the feed, so it is the one number that sets how far back the screen remembers.
#: A busy hour on a real mesh has some hundreds of packets. Thus a limit of a hundred rows
#: forgot the last ten minutes, at the time when the feed was most useful. Here, more
#: depth costs almost nothing, because the paint uses a list window
#: (:meth:`LiveFeedScreen._feed_lines` formats only the visible rows). The limit costs one
#: pointer for each entry and a few milliseconds to build the entries when the screen
#: opens, and nothing for each paint.
_FEED_CAP = 500
#: How far back the opening seed can go: **no limit**. Only the cap sets how much history
#: the feed holds, and a clock must not overrule it. With a time limit, a quiet mesh opens
#: a half-empty screen. The cause is not that nothing was ever heard, but that nothing was
#: heard recently, and the user can already see that.
#:
#: The mechanism did not change. :meth:`~meshterm.persistence.repository.Repository
#: .recent_observations` still takes a ``since``, and this value is a floor that is
#: earlier than all stored rows. The query stays fast, because ``observed_at`` has an
#: index: the query goes backwards through the index and stops after :data:`_FEED_CAP`
#: rows. Thus a wider time window reads no more rows than a narrower one did. This value
#: is intentionally not :data:`~meshterm.persistence.repository.OBSERVATION_WINDOW`, which
#: the feed used before. That value is the limit of the rolling statistics of the
#: dashboard, and it is what the RF health card of the dashboard means by "reception over
#: 2 h".
_FEED_SEED_FLOOR = datetime.min.replace(tzinfo=timezone.utc)
#: The fixed width of the subject lane of the feed. A longer subject ellipsizes, so that
#: the columns stay aligned. The width is sufficient for a node name, which is what most
#: classes put here. It is also sufficient for the two endpoints of an addressed packet to
#: show as ``a1 → Hilltop-Repe…``, instead of only bare hashes. The lanes were sized for a
#: 72-cell screen, and a wider subject lane pushes the row further past it. Thus the cells
#: that the lane cannot hold are for the viewer to use.
_FEED_SUBJECT_WIDTH = 18
#: The fixed width of the class lane. It is sufficient for the longest class of a packet
#: in the app (``telemetry``, ``chan text``, ``multipart``), so each label shows fully.
#: JP made the names short intentionally on 2026-09-25 (``dir messg``, ``anon req``). The
#: lane never collapses, so each cell that it uses pushes the readings of the row one more
#: cell off the screen.
_FEED_CLASS_WIDTH = 9
# The remaining lanes, as the row and its column header both measure them. There is one
# set of numbers, so a header label always stays over the values that it names, and a
# lane that a packet left empty pads to the same width as a full lane.
#: The cells between two lanes that are next to each other.
_LANE_GAP = 2
#: ``HH:MM:SS``, plus the gap after it.
_TIME_LANE = 8 + _LANE_GAP
#: The class icon and the space after it, or nothing where the platform draws no icons
#: (:func:`_bind_class_icon` sets this value).
_ICON_LANE = 3
@on_platform
def _bind_class_icon(platform: Platform) -> None:
"""Bind the lane of the class icon to the platform (this runs now and at each switch).
The class name is always drawn (it never collapses: JP decided this on 2026-09-25).
Thus the icon next to it is only a cue for the family, not the information itself.
That is what :attr:`~meshterm.platforms.Platform.menu_icons` controls. On the
PicoCalc, a class icon can only be a one-glyph substitute, so the lane is removed and
the row gets its three cells.
"""
global _ICON_LANE
_ICON_LANE = 3 if platform.menu_icons else 0
#: The cells in which an SNR right-aligns its number: ``+13.2``, ``-20.5`` (a sign, two
#: digits, and a tenth).
_SNR_W = 5
#: The cells in which an RSSI right-aligns its number: ``-120`` at its weakest, in whole
#: dBm. The width comes from the reading, not from the field of the SNR. When the RSSI
#: used the SNR field, the extra cell of that field made a gap between the two readings
#: that was two times as wide as each other gap on the row.
_RSSI_W = 4
#: ``+13.2 dB``: the number field, then its unit.
_SNR_LANE = _SNR_W + len(" dB")
#: `` -105 dBm``: the lane gap, the number field, then its unit.
_RSSI_LANE = _LANE_GAP + _RSSI_W + len(" dBm")
#: The width of the scope lane. It holds all of ``? 3fa1`` (an unnamed scope) and all of
#: a short region name. A longer name ellipsizes. The full name is on the ``scope`` row of
#: the viewer, one key press away, the same as all other text that a lane cuts.
_FEED_SCOPE_WIDTH = 10
#: The scope lane, with the gap that separates it from the readings after it.
_SCOPE_LANE = _FEED_SCOPE_WIDTH + _LANE_GAP
def _feed_scope(scope: Scope | None) -> Text:
"""One row's scope cell: the region, ``? 3fa1`` if unnamed, or a muted dash if unscoped.
The cell is blank for a direct packet (or for a row that is not a packet), because it
has no scope. A dash there says that the packet is a plain flood, which is not true.
"""
if scope is not None and not scope.scoped:
return Text("-", style="muted")
return scope_text(scope, bare=True)
class _Lanes(NamedTuple):
"""The lane widths in which one paint lays out its rows.
Attributes:
subject: The width of the subject lane (:data:`_FEED_SUBJECT_WIDTH`).
"""
subject: int
def _lanes_for(width: int) -> _Lanes:
"""The lanes that a row has at ``width``: all of them, at each width.
No lane collapses (JP, 2026-09-25). Before, the class name and then the scope were
removed on a narrow terminal. Each time, the removed lane was one that the user opened
the feed to see. Thus the user reads a row that is wider than the screen in the same
way as in all of the app: the highlighted row moves sideways with ``←→``. On a
72-cell terminal, this scroll gets to the readings at the end of the row. On the 53
cells of the PicoCalc, it gets to all of the row after the subject.
"""
return _Lanes(_FEED_SUBJECT_WIDTH)
#: The cells by which one press of ←/→ moves the highlighted row. This is the step of the
#: select list of the app (:attr:`~meshterm.ui.tui.select.SelectScreen._HSCROLL_STEP`),
#: so a row here scrolls at the same rate as a row in all other places.
_HSCROLL_STEP = 8
#: The moves that cancel the horizontal scroll of the highlighted row. Each row scrolls
#: independently, as the rows of an ``hscroll`` select list do. When the highlight goes
#: to a new packet, that row always starts at its own beginning.
_HSHIFT_RESET = frozenset(
{
"up",
"down",
"pageup",
"pagedown",
"space",
"home",
"ctrl_home",
"end",
"ctrl_end",
}
)
#: The pointer on a row that is selected in the usual way. It is the select-list pointer
#: of the app, and it points into the list, at the one row that it marks.
_CURSOR = "❯ "
#: The pointer on the pinned newest row (refer to the module docstring). It is in the same
#: lane, but it points above the top of the list, at the traffic that is still to arrive.
#: The reason is that the pin is attached to that traffic: not to this packet, but to the
#: packet that is the newest at each time. It is intentionally not ``↑``, because this
#: screen already uses ``↑`` for the ``↑ n more`` line of the list window. Here, ``↑``
#: means "there is more above", and the top of the feed can never mean that. It is
#: also not ``▲``, which is the repeater in the node-type set of the app. No other part of
#: the app uses a caret, a caret means "topmost" on both platforms, and it is plain ASCII.
#: Thus it is not necessary to add it to the glyph map of the console font (refer to
#: :mod:`meshterm.ui.fontset`).
_PIN_CURSOR = "^ "
def _channel_sender(text: str | None) -> str | None:
"""The sender in the ``Name: `` prefix of a channel message, or ``None`` if absent.
This function uses the shared parse of the protocol layer (refer to
:func:`~meshterm.core.channels.split_channel_sender`). Thus the subject lane of the
feed names the same sender as the chat transcript.
"""
name, _body = split_channel_sender(text or "")
return name
class LiveFeedScreen(Screen):
"""The full-screen packet stream. It renders the state, and the opener gives it data."""
floating = False
@property
def picocalc_lyra_lane(self):
"""The shared lane, which stays dim until there are packets to move through.
Here, each navigation key moves the cursor over the feed, not a scroll offset. But
the words are the same in the two cases, because the list window follows the
highlight. Thus the shared labels stay. ``Top`` and ``Bottom`` keep their literal
meaning intentionally: the top of this list is the pin on the newest packet, and
its bottom is the oldest packet, where End goes. Where there is no hint line to
name the pin, ``Top`` is how the user gets to it. Thus, with one chip, a feed that
the user read down into the history follows the stream again.
An empty feed has no cursor and no place to move to. Thus all four chips are 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,
scope_of: ScopeOf | None = None,
message_scope: MessageScopeOf | None = None,
) -> None:
"""Create the feed screen over its data sources.
Args:
session: The running TUI session (for paints and the packet viewer).
resolve: Gives the contact name for a node hash, when the name is known.
seed: The stored observations to open with (the oldest first, as the
repository returns them). The newest :data:`_FEED_CAP` observations become
the opening feed.
prefix_bytes: The width of the hash to light in the keys of the packet viewer.
self_name: The name of our node, drawn in white where it appears.
channels: The configured channels of the device, as ``(name, secret)`` pairs.
For an overheard channel packet, the subject lane names the channel whose
key gives the same MAC as the packet. Each
:class:`~meshterm.ui.packet_viewer.PacketViewer` that opens uses these keys
to decrypt.
channel_names: The channel names of the device, by slot index. Thus a channel
message that the device decoded for us shows under the same channel name
as an overheard packet on that channel, instead of under its slot number.
type_of: Gives the node type for a relay hash. The packet viewer gets it, so
that the route graph of a relayed packet marks a repeater with ``▲`` (and
the other types with their glyphs) instead of a dot.
key_of: Gives the key of the node for the display name of a sender (refer to
:func:`~meshterm.services.trace_runner.make_name_key_resolver`). Thus a
channel sender that the contacts or the recorder know gets the hue of its
key. A name that does not resolve stays muted.
scope_of: Reads the scope of a flood from its raw packet, against the regions
that have known names (``ctx.region_store.scope_of``). The scope lane uses
it, and each viewer that opens gets it for its ``scope`` row. With
``None``, the screen still tells a scoped flood from a plain flood: it
shows the code of a scoped flood in the place of the name
(:func:`~meshterm.ui.packet_viewer.unnamed_scope`).
message_scope: Reads the scope of a decoded channel message from its copies in
the packet log (:func:`message_scope_reader`). Each viewer that opens gets
it, so the card of a message has a ``scope`` row too. With ``None``, a
message has no ``scope`` row.
"""
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)
self._scope_of: ScopeOf = scope_of or unnamed_scope
self._message_scope = message_scope
#: The lanes of the last paint (refer to :func:`_lanes_for`). The subject builders
#: read them, because their fallbacks for a full lane measure against the lane
#: that was drawn.
self._lanes = _lanes_for(0)
#: The feed: the latest events of each class as data, with the newest first. The
#: screen renders it again at each paint (the rows change with the width), and
#: gives all of it 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 has no cursor stop. It 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 instead of a packet (refer to the module docstring). If it is
#: true, ``_selected == 0`` is also true. The pin is a mode of the newest row, so
#: all the code that reads the highlight (the list window fit, the viewer, the
#: ``←→`` scroll) has no special case. Only the mark that the pointer lane draws is
#: different. An empty feed has no row to pin to, and no cursor.
self._pinned: bool = bool(self._feed)
#: The list window of the feed in the fixed screen (its rows scroll under the
#: heading).
self._feed_window = ListWindow()
#: The horizontal scroll of the highlighted row (the cells that ``←/→`` moved it),
#: and how far it can move. The limit is measured against the width of the last
#: paint, so a resize can only clamp a scroll that is in progress back into the
#: range.
self._hshift = 0
self._hmax = 0
# --- live feed -----------------------------------------------------------------
def on_event(self, event: MeshEvent) -> None:
"""Add one hub event to the feed, and paint again.
When a packet arrives, the two top stops of the screen behave differently. A
selection is attached to a packet, so it moves down with that packet when the
newer row comes in above it. The pin is attached to the position, so it stays at
row 0 and the packet under it changes. That is the purpose of the pin. It is also
the reason why this method resets the horizontal scroll of the highlighted row.
The cursor is now on a different packet, the same as if the user pressed ``↓``,
and each other move to a new row starts that row at its own beginning (refer to
: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 to show, so do not do a full paint for it
self._feed.appendleft(entry)
if self._pinned:
self._selected = 0 # the pin holds the top, so this row is now the newest packet
self._hshift = 0
elif self._selected is not None:
# Keep the highlight on the same packet when 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. It gets ``←→ scroll line`` only while those keys have an effect.
A row that fits already cannot scroll, so the hint does not show the atom for it.
This is the same rule ("advertise a key only where it acts") that the ``hscroll``
hint of the select list 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:
"""Move the cursor, commit the row that it is on, scroll a row, or leave."""
if action in _HSHIFT_RESET:
self._hshift = 0 # a move from a row cancels 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 move the cursor by one list window at a time. Thus the
# highlight moves with the list window, and the list window does not leave it
# behind.
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 goes to the pin. The top of a live feed is the state that follows the
# stream. Thus "take me back to the top" follows the stream again, and does not
# stop on the packet that is the newest at 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:
"""Move the highlighted row sideways (clamped to its own end), and paint again."""
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 stops of the screen: the pin, then the feed rows.
The stops are the pin (0), then the feed rows (``1 .. len(feed)``). Thus each move
is a clamp on this one number, the pin included. The pin and row 0 draw on the same
line, but that is only a detail of the rendering. As cursor positions, they are
two. That is why ``↓`` from the pin goes to the newest packet, and not to the
second packet.
An empty feed has no row to pin to, and thus no stops.
"""
if self._pinned or self._selected is None:
return 0
return self._selected + 1
def _move_selection(self, delta: int) -> None:
"""Move the cursor by ``delta`` stops (the two 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 paint again.
Args:
index: The stop to go to, clamped into the range (refer to :attr:`_cursor`).
The two ends clamp and do not wrap, so a keyboard key that is held down
stops at an end.
"""
if not self._feed: # nothing to pin or to 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``, selected in the usual way, and never on the pin.
Other code uses this method to move the highlight (the packet viewer, when it pages
through the feed under it). A move to a specific packet is always a selection and
not a pin, also when that packet is the newest one. The user follows that packet,
so the next packet that arrives must push it down, and must not take 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:
"""Open the packet viewer as a dialog over the highlighted feed row.
When the user pages in the viewer, the feed highlight moves with it (through
``on_navigate``). Thus, when the viewer closes, the feed is on the packet that the
user saw last.
The viewer opens on the stop that the feed has, and Enter does not change it. On
the pin, the viewer opens on the pin too, and the card follows the stream. On a
selected packet, the viewer holds that packet. JP decided this on 2026-10-10.
Before, Enter removed the pin, because Enter named the packet under it. But a user
who watches the feed and opens the card continues to watch, and ``↓`` in the viewer
holds the visible packet with one key press.
"""
if self._selected is None or not self._feed:
return
def follow(entry: PacketEntry) -> None:
# The feed may have more rows after the snapshot. Thus 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,
pinned=self._pinned,
resolve=self._resolve,
prefix_bytes=self._prefix_bytes,
self_name=self._self_name,
on_navigate=follow,
# The viewer has the second stop of this screen too. Thus, when the user goes
# up to the stream in the dialog, the feed under it goes back to the pin, and
# the feed still follows the stream when the dialog closes.
on_pin=lambda: self._select_stop(0),
channels=self._channels,
type_of=self._type_of,
key_of=self._key_of,
scope_of=self._scope_of,
message_scope=self._message_scope,
# The live feed itself (the newest first). Thus the viewer shows the packets
# that arrive while it is open, and does not stay 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 list window of the feed.
The header stays at the top because of the structure of this method, not because
of the sticky-header mechanism of the base screen. The method first removes the
header line from the viewport, and the list window of the feed is in the lines that
remain (refer to :meth:`_feed_lines`). Thus the body never scrolls: the header
cannot move off its top, and the oldest packet cannot go below its 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 # no highlighted row scrolls, so the hint does not show ←→
self._pinned = False # a feed with no rows has nothing to pin to
lanes = self._lanes = _lanes_for(width)
lines: list[str] = []
if self._feed: # a header over no rows is noise, and the empty note is sufficient
lines.append(render_to_ansi(self._column_header(lanes), width, no_wrap=True))
win = max(1, self._scroll_viewport - len(lines))
lines.extend(self._feed_lines(width, win, lanes))
self._scroll_total = max(1, len(lines))
return lines
@staticmethod
def _column_header(lanes: _Lanes) -> Text:
"""The lane names, in the uppercase muted style of the column headers of the app.
The header has the same lanes as :meth:`_feed_row`, the pointer column included,
so each label is over the values that it names. Nothing here sorts. The feed is a
stream, and its one order (the newest first) is the order of the ``TIME`` lane
from top to bottom. Thus no column has the sort triangle of the contact list, or
the white style of its active column. These labels are signs, not controls. The
class lane never collapses (refer to :func:`_lanes_for`), so the header always
shows its ``CLASS`` label.
The label of the subject lane names the purpose of the lane, not the answer of one
class: a packet is about a node for an advert, a channel for a channel text, and a
pair of endpoints for a direct message. ``SUBJECT`` is the word that is correct for
all of the 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))
header.append(fit_cells("SUBJECT", lanes.subject + _LANE_GAP))
header.append(fit_cells("SCOPE", _SCOPE_LANE))
# The label of each reading starts where its lane starts, as each other label on
# the row does. When the label was right-aligned over the number, it ended in the
# middle of the value, with the unit after it. It then seemed to float between two
# columns, and did not name one of them.
header.append(fit_cells("SNR", _SNR_LANE + _LANE_GAP))
header.append(fit_cells("RSSI", _RSSI_LANE - _LANE_GAP))
return header
def _feed_lines(self, width: int, win: int, lanes: _Lanes) -> list[str]:
"""The visible feed rows: the newest first, with ``↑/↓ 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, lanes, 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, lanes: _Lanes, width: int) -> Text:
"""Lay out one feed row in fixed lanes: time, class, subject, scope, reception, detail.
The class lane tells what the packet is, directly from
:func:`~meshterm.ui.packet_viewer.class_marks`. Thus a raw packet shows as
``📻 channel text`` (the class in the headline of the card in the viewer), not as
the ``📦 packet`` event family in which it arrived. The name always shows. The
icon next to it shows only where the platform draws icons
(:func:`_bind_class_icon`). The subject lane next to the class lane then tells
what the packet is about, in the terms of its class (:meth:`_feed_subject`). The
row has no relay path. The lanes already use more than a 72-cell screen without
one, and the packet viewer draws the route correctly, one key press away.
The highlight is the usual highlight of the app: the ``❯`` pointer, and a
``cursor`` base style under all of the row, as each select list draws its
highlight. The lanes keep their own colours over it (the SNR its quality colour,
a name its palette hue), because those colours are the content. The pointer and
the base tint are what show "this row". Only the newest row can have a different
pointer: it gets ``^`` while the cursor is pinned to the top of the stream, and
not to the packet that is there now (refer to :data:`_PIN_CURSOR` and to the
module docstring). The lane, the cursor tint, and the one cell are the same. Only
the mark is different, because only the meaning is different.
If a row goes past the right edge (on a terminal that is narrower than the lanes),
the user scrolls the row to read it, and the row does not get taller. The
highlighted row moves sideways with ``←→`` (:attr:`_hshift`), as in the h-scroll
convention of the app. The ``❯`` pointer lane does not move, so the pointer never
scrolls away from the row that it marks. This method measures the limit of the
shift against the current width, so a resize can only clamp the shift back into
the 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)
if _ICON_LANE:
body.append(fit_cells(icon, _ICON_LANE))
body.append(
fit_cells(class_label, _FEED_CLASS_WIDTH),
style=KIND_STYLES.get(entry.kind, "brand"),
)
body.append(" " * _LANE_GAP) # the gutter of the lane, which the longest class fills
subject = self._feed_subject(entry)
# This lane fits as each other lane does: in cells, not characters, with an
# ellipsis on overflow. But it is a Text, so a subject made of several styled
# pieces keeps them.
subject.truncate(lanes.subject, overflow="ellipsis", pad=True)
body.append_text(subject)
body.append(" " * _LANE_GAP)
# Next to the subject, before the readings. The area into which a flood could go is
# a fact about the packet, the same as its subject. The readings are about how the
# packet got to us. A plain flood (most rows) is a muted dash instead of the word,
# so the lane stays quiet until a row has a scope.
scope = _feed_scope(self._entry_scope(entry))
scope.truncate(_FEED_SCOPE_WIDTH, overflow="ellipsis", pad=True)
body.append_text(scope)
body.append(" " * _LANE_GAP)
body.append(
f"{entry.snr:+{_SNR_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:{_RSSI_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 is under the spans of the row (the convention of the select
# list), so the lanes keep their colours and only the gaps get the tint.
row.style = "cursor"
return row
def _feed_subject(self, entry: PacketEntry) -> Text:
"""What this packet is about, in the terms of its own class.
The lane was a node lane before. That suited the two classes that name a node, but
each other class showed ``—``. On a real mesh, that is half of the traffic: the
direct messages, requests, responses, path returns, acks, and traces that are
relayed past us and name no origin. Each of those packets is about something, but
not about a node. Thus the lane asks each class its own question:
* **advert, telemetry, a direct message that we received**: about the node that
sent it, so the lane names the node
(:func:`~meshterm.ui.packet_viewer.node_label`). The name has its palette hue,
our node is white, and a bare hash is muted.
* **channel text and channel data**: about the channel. The name comes from the
key whose MAC confirms the packet
(:func:`~meshterm.core.channels.identify_channel`). A channel for which we have
no key shows the fingerprint that it advertises.
* **direct message, request, response, returned path, anonymous request**: about
the two nodes between which it travels, drawn as an origin chip and a destination
chip, or as ``sender → recipient`` where the terminal draws no chips
(:meth:`_endpoints`).
* **ack**: about the message that it acknowledges, named by the checksum of that
message.
* **trace**: about the walk that it collects, named by its tag.
* **a channel message**: about the sender who signed it, or else the channel on
which it arrived.
The lane intentionally does not show the class of the packet. The class lane, two
columns to the left, already shows it, directly from
:func:`~meshterm.ui.packet_viewer.class_marks`. When a row showed the class two
times, it read ``packet`` next to ``channel text``, as if they were two facts. A
class that says nothing about itself still gets the dash, which is the true answer.
Args:
entry: The packet that the row describes.
Returns:
The content of the lane, not sized. 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. It identifies the message that it answers.
return self._token("for", raw["ack_crc"])
if entry.where:
return Text(entry.where, style="muted") # the code of a delivery ack
return Text("—", style="muted")
def _message_subject(self, entry: PacketEntry) -> Text:
"""A chat message's subject: its signer, or else the channel on which it arrived."""
sender = _channel_sender(entry.text)
if sender:
ours = self._self_name and sender == self._self_name
# A sender that resolves gets the hue of its key. An unknown sender stays
# muted, because colour is only for identities that have a key.
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 message: it is about the channel on which it arrived
return Text(channel, style="brand") # named the same as for an overheard packet
if entry.where:
return Text(entry.where, style="muted") # …or the slot, if we cannot name it
return Text("—", style="muted")
def _channel_subject(self, raw: dict) -> Text:
"""A channel packet's subject: its channel, named by the key that its MAC confirms.
The packet names its channel only by a one-byte fingerprint, and more than one
channel can have the same fingerprint. Thus the lane shows a name only after a key
that we have gives the same MAC as the packet
(:func:`~meshterm.core.channels.identify_channel`), and never when only the
fingerprint matches. A channel for which we have no key shows the fingerprint that
the packet advertised, after the word of the app for a short derived id:
``hash a3``. This text is intentionally not ``ch a3``. The ``ch 3`` of a message
row is a slot, and two different numbers with one prefix in the same column are
worse than no text.
"""
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 packet's subject: the two nodes between which it travels.
The subject is drawn with the only hop-sequence widget (:mod:`~meshterm.ui.pathline`).
Thus the endpoints of a delivery show the same as the hops of a route: resolved to
contact names, each in the hue of its key, and our node as the ``★`` when a packet
is addressed to us (or from us). Where the terminal draws chips, the sender is an
origin chip and the recipient is a destination chip, and the relays between them
are not drawn (:func:`~meshterm.ui.widgets.route_ends`). Elsewhere the line is
``sender → recipient``. The chips use more cells than the arrow, but the two ends of
a route read as two ends at a glance (JP, 2026-10-10).
The packet names each endpoint by one byte of its key. This is the same one-byte
identity that a relay hop has, with the same probability of a collision. Thus a
name here is a good guess, not a proof, and the hashes of the packet itself are
always one key press away in the viewer. An anonymous request is the exception
that helps us: it has the full key of its sender, which resolves exactly. If that
key does not resolve, the lane shows it cut to the same one byte as all other
text in the lane, instead of 64 hex digits that nobody can read quickly.
If the two names do not fit in the lane, the recipient keeps its name and the
sender changes to its hash. The packet is addressed, so the recipient is the half
that gets the cells. A recipient that is still too wide is cut at the lane
(:func:`~meshterm.ui.pathline.cut_to`). A chip breaks off in its own colour, and an
arrow line ends on ``…``.
"""
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 = route_ends(self._hop(src), self._hop(dest))
if named.cell_len <= self._lanes.subject:
return named
# Too wide: the sender changes to its hash, so the recipient keeps all of its name
# (or as much of it as the lane holds), and is not the half that gets cut.
hashed = route_ends(self._hop(src, named=False), self._hop(dest))
return cut_to(hashed, self._lanes.subject)
def _hop(self, value: str, *, named: bool = True) -> PathHop:
"""One endpoint as a path hop: its contact name, or its one-byte hash in its hue.
Our node resolves to :data:`~meshterm.ui.pathline.SELF_GLYPH`, which all of the
app uses, instead of to its name. It is the one endpoint that the user always
knows. In an 18-cell lane, the cells that it does not use let the name at the
other end show fully, and not cut.
Args:
value: The key hash of the endpoint. For the sender of an anonymous request,
it is the full key, which is cut to the one-byte width of the lane when it
has no name.
named: Whether to resolve it. ``False`` forces the hash, for the fallback of a
full lane, which gives the cells to 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 token of the packet, after the one word that says what it is.
A trace's tag, an ack's checksum, a channel's fingerprint: this hex identifies
something, but it is not the identity of a node. Thus it gets no palette hue. The
qualifier word is ``faint``, so it is less visible, and the value itself is muted
but easy to read.
"""
text = Text(f"{word} ", style="faint")
text.append(value, style="muted")
return text
def _entry_scope(self, entry: PacketEntry) -> Scope | None:
"""The scope of the packet of a row, or ``None`` where it has no scope to show.
Only a raw ``packet`` row has the route type and the transport codes from which a
scope is read, and only a flood has a scope. For all other rows, the lane is
blank. A direct packet is never filtered by region, so a word there gives it a
meaning that it does not have. The lane is drawn ``bare``: its heading already
says ``SCOPE``, so a known region shows as its name alone, and an unknown scope
shows as ``? 3fa1`` (:func:`~meshterm.ui.widgets.scope_text`). A plain flood
shows as a muted dash instead of ``unscoped`` (:func:`_feed_scope`).
"""
if entry.kind != "packet" or not isinstance(entry.raw, dict):
return None
return self._scope_of(entry.raw)
def _feed_note(self, entry: PacketEntry) -> Text | None:
"""The detail at the end of the row: the conversation of a message, and nothing else.
Before, the route of a relayed packet was here, and it never fitted. The fixed
lanes left only a small number of cells for it, so the chain was cut to a stub
that named one hop and only suggested the others. A route is a shape, not a lane,
and it is one key press away: the packet viewer draws it as a wrapped path line
over the route graph, with the space to show all of it. Thus the feed tells what
arrived, and Enter tells how it arrived.
If the subject lane already shows the conversation, this detail does not show it
again. An unsigned channel message shows under its channel, and a row that named
the same conversation two times looked like two facts.
Args:
entry: The packet that the row describes.
Returns:
The detail to append, or ``None`` when the class has no detail.
"""
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 already shows the conversation
return Text(entry.where, style="muted")
async def open_livefeed(ctx: AppContext) -> None:
"""Open the live feed, and run it until the user leaves it.
This function connects the screen to its data sources. The recent stored observations
are its first rows, a hub subscription sends each new packet to it, and a ticker
paints it one time each second, so that the rows that show the time stay correct.
When the screen closes, the function removes all of these.
Args:
ctx: The shared application context. The interactive TUI surface must be in use.
Raises:
RuntimeError: If the call is outside the interactive menu (there is no
full-screen session).
"""
from ..services import trace_runner
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the menu-only caller guards this
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] = {}
slots_by_idx: dict[int, tuple[str, bytes]] = {}
try:
if ctx.is_connected or ctx.settings.connect_on_start:
# Through the session cache. The contacts and the channel-slot probe are the
# two slowest reads when a screen opens. Thus MeshTerm reads them one time (not
# each time that the feed opens), and navigation stays fast 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}
slots_by_idx = {s.idx: (s.name, s.secret) for s in slots}
except Exception: # noqa: BLE001 - the feed renders correctly without contact names
contacts = []
# The contacts first, then each name that the recorder overheard, as the fallback.
# This is the rule of all of the app: a node that we can name never renders as a bare
# hash.
stored_names, named_at = ctx.repo.node_names_with_times()
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, heard=named_at)
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
# The cap is the only limit: the newest _FEED_CAP packets that were ever stored, also
# when the quiet period before them started a long time ago.
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,
scope_of=ctx.region_store.scope_of,
message_scope=message_scope_reader(ctx, slots_by_idx),
)
unsubscribe = ctx.events.subscribe(screen.on_event)
async def tick() -> None:
"""Paint one time each second, so that the rows that show the time stay correct."""
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 - the teardown must never show an error of a tick
pass
def message_scope_reader(
ctx: AppContext, slots_by_idx: Mapping[int, tuple[str, bytes]]
) -> MessageScopeOf:
"""A reader for a decoded channel message's scope, from its copies in the packet log.
The device gives a received message to MeshTerm as text, not as the packet in which
it arrived. Thus a ``message`` row has no transport code. But its flooded copies are
in the packet log, and the message paths dialog already finds them: it decrypts them
and matches the text (:func:`~meshterm.services.message_paths.channel_arrivals`).
Each copy has the one code of the sender, so the scope of the copies is the scope of
the message (:func:`~meshterm.services.message_paths.message_scope`).
The reader keeps the copies that it found for each entry, because a card paints again
each second, and the search is a query over a time window plus one decrypt for each
packet. But the reader keeps them only after it found some. The feed can get a message
a short time before its packet is written to the log. If the reader cached a miss at
that time, the miss stays a miss permanently. The reader resolves the region at each
call, against the cache of the store. Thus a name that MeshTerm learns while the card
is open still names the region.
A direct message has no reader: its copies are encrypted end to end, and a routed
direct message has no scope.
Args:
ctx: The application context (its repository and region store).
slots_by_idx: The channels of the device by slot, as ``(name, secret)``.
Returns:
The reader. It returns ``None`` for each entry that it cannot place.
"""
from ..core.models import ChatMessage
from ..services.message_paths import channel_arrivals, message_scope
found: dict[int, list] = {}
def read(entry: PacketEntry) -> Scope | None:
if entry.kind != "message" or entry.channel is None:
return None
channel = slots_by_idx.get(entry.channel)
if channel is None or not entry.text:
return None
arrivals = found.get(id(entry))
if arrivals is None:
message = ChatMessage(
text=entry.text, outbound=False, is_channel=True, created_at=entry.when
)
try:
arrivals = channel_arrivals(
ctx.repo, message, channel_name=channel[0], secret=channel[1]
)
except Exception: # noqa: BLE001 - a card with no scope row, but never a crash
return None
if arrivals:
found[id(entry)] = arrivals
return message_scope(arrivals, ctx.region_store.scope_of)
return read