Files
Jean-Pierre MartineauandClaude Opus 5.5 73be9692b4 A channel guide lists the public channels heard on the mesh
MeshTerm already stores every channel packet it hears, including those
on channels the device doesn't have. A packet names its channel only by
a one-byte hash, but a public channel's key is derived from its name, so
a candidate name can be tested: derive the key and check the packet's
MAC with it, exactly as the firmware does before decoding. The guide
tries the device's channels, Public, every #name mentioned in a stored
message, and every known region. On one desktop history that names 25
public channels.

A guessed name has to match at least two messages before the guide
shows it. The check compares three bytes, and with thousands of names
against thousands of packets roughly one false match is expected. The
candidates are grouped by their hash byte, so each packet only checks
the ~1/256 that could match; the naive loop took 40 s, this takes 1 s
on 31,000 messages, in a worker thread under a busy card, and the result
is kept for the session while its inputs are unchanged.

"Channel guide…" is the first row of "Add a channel" on the Channels
page, and it stays there when every slot is full. Channels not on the
device come first, and Enter adds one to the next free slot. Channels
the device already has follow with a ✓ the highlight doesn't walk, and a
closing line counts the messages no name matched. Each row shows the
message count, the age of the last message and seven days of activity.
`channels guide` lists the same channels on the command line.

Refs: #25
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:27:11 -04:00

220 lines
9.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The channel guide screen: the public channels heard on the mesh, and an add for each.
The guide opens from the "Add a channel" section of the Channels page. It lists each public
channel that MeshTerm heard (refer to :mod:`meshterm.core.channel_guide`), in two sections.
The channels that are not on the device come first, because the user can add them: Enter
adds the highlighted channel to the next free slot at once, as the other add rows do. The
channels that the device already has come after them, each with a ``✓`` in the pointer
column. The highlight does not walk these rows, so the list is complete and Enter never does
nothing. A muted line at the end counts the messages that no name matched.
Each row shows the name, the number of different messages, the age of the last message, and
the activity of the last seven days. The chart is the last lane, and it becomes shorter to
fit the row, as on the Channels page.
The match of the stored packets takes a moment on a large history (one second on a desktop
for 31,000 messages, more on a handheld). Thus it runs in a worker thread under a busy card,
and the result stays for the session while the history and the candidate names are the same.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.channel_guide import GUIDE_BUCKETS, Guide, HeardChannel, build_guide, mention_names
from ..core.channel_probe import ChannelSlot
from .braillechart import activity_peak, activity_sparkline
from .channels import _pick_free_slot, write_channel
from .menus import Lane, column_header, fit_cells, section_heading
from .tui import CANCEL, Choice, SelectScreen, Separator
from .widgets import age_seconds, channel_glyph, format_age
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
#: The footer of the guide: navigation, the filter, the action, and Esc last.
_GUIDE_HINT = "↑↓ move · type to filter · Enter add · Esc back"
#: The widest name lane. A longer name ends in an ellipsis, as on the Channels page.
_NAME_WIDTH_MAX = 20
#: The width of the message-count lane, which is aligned to the right.
_COUNT_WIDTH = 5
#: The width of the age lane, which is aligned to the right. It fits ``never``.
_AGE_WIDTH = 5
#: The cells of the activity chart at full width: two buckets of six hours for each cell.
_CHART_CELLS = GUIDE_BUCKETS // 2
#: The lower limit of the shared chart peak, so that one message stays a small mark.
_ACTIVITY_FLOOR = 2.0
#: The last guide that was built, and what it was built from. Refer to :func:`read_guide`.
_cache: tuple[tuple, Guide] | None = None
async def channel_guide(ctx: AppContext, device: Device, capacity: int) -> int:
"""Show the guide until the user leaves it. Return the number of channels added.
Args:
ctx: The shared application context.
device: The connected device, to add a channel to.
capacity: The number of channel slots of the device.
Returns:
The number of channels that the user added from the guide.
"""
async with ctx.ui.busy_dialog("reading the channels heard…", title="Channel guide"):
slots = list(await ctx.devstate.channel_slots())
guide = await read_guide(ctx, slots)
by_identity = {channel.identity: channel for channel in guide.channels}
added = 0
async def add(choice: object) -> bool:
"""Add the selected channel. Return whether the device changed."""
nonlocal slots, added
channel = by_identity.get(choice) if isinstance(choice, str) else None
if channel is None:
return False
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return False
await write_channel(ctx, device, idx, channel.name, channel.write_secret)
slots = list(await ctx.devstate.channel_slots())
added += 1
return True
title, items = guide_items(guide, slots)
session = getattr(ctx.ui, "session", None)
if session is None: # a UI with no stack of screens: one round at a time
while True:
choice = await ctx.ui.select(title, items)
if choice is None:
return added
if await add(choice):
title, items = guide_items(guide, slots)
screen = SelectScreen(title, items, footer_hint=_GUIDE_HINT)
async with session.stay(screen) as visit:
while True:
choice = await visit.result()
if choice is CANCEL:
return added
if await add(choice):
# The rows are data, so the list changes in place: the added channel moves
# to the second section, and the filter stays.
title, items = guide_items(guide, slots)
screen.replace_items(items, title=title)
async def read_guide(ctx: AppContext, slots: list[ChannelSlot]) -> Guide:
"""Build the guide from the stored packets, or return the guide that is still correct.
The two reads of the database run here, on the thread of the database. Only the match,
which reads no database, goes to a worker thread. The guide stays for the session while
its inputs are the same: the same number of stored messages with the same latest time,
the same candidate names, and the same channels on the device.
"""
global _cache
packets = ctx.repo.channel_packets()
regions = ctx.region_store.names() if ctx.region_store is not None else []
guesses = mention_names(ctx.repo.message_texts()) | {f"#{name}" for name in regions}
known = [(slot.name, slot.secret) for slot in slots]
latest = max((row[4] or "" for row in packets), default="")
key = (len(packets), latest, frozenset(guesses), tuple(slot.identity for slot in slots))
if _cache is not None and _cache[0] == key:
return _cache[1]
now = datetime.now(timezone.utc)
guide = await asyncio.to_thread(build_guide, packets, known, guesses, now=now)
_cache = (key, guide)
return guide
def guide_items(guide: Guide, slots: list[ChannelSlot]) -> tuple[str, list]:
"""The title and the rows of the guide, for the channels that are on the device now.
Args:
guide: What the guide found.
slots: The channels of the device.
Returns:
``(title, items)`` for a :class:`SelectScreen`.
"""
on_device = {slot.identity for slot in slots}
absent = [c for c in guide.channels if c.identity not in on_device]
present = [c for c in guide.channels if c.identity in on_device]
title = f"Channel guide · {len(guide.channels)} heard"
items: list = []
if not guide.channels:
items.append(Separator(" no public channel heard yet — keep MeshTerm listening"))
else:
name_w = min(
_NAME_WIDTH_MAX, max(len("CHANNEL"), *(cell_len(c.name) for c in guide.channels))
)
peak = activity_peak(*(c.histogram for c in guide.channels), floor=_ACTIVITY_FLOOR)
items.append(Separator(lambda w: _header(name_w, w), heading=True))
items.append(section_heading("Not on this device"))
if not absent:
items.append(Separator(" each public channel heard is on this device"))
for channel in absent:
row = _row(channel, name_w, peak)
items.append(Choice(title=row, value=channel.identity, fitted=True))
if present:
items.append(section_heading("On this device"))
for channel in present:
items.append(Separator(_row(channel, name_w, peak, mark="✓")))
if guide.unnamed:
count = f"{guide.unnamed} message" if guide.unnamed == 1 else f"{guide.unnamed} messages"
items.append(Separator(" "))
items.append(Separator(f" {count} on channels with no known name"))
return title, items
def _header(name_w: int, width: int) -> str:
"""The column headers over the lanes of the guide (refer to :func:`_row_text`)."""
return column_header(
[
Lane("CHANNEL", cell_len(channel_glyph("#x", None)) + 1 + name_w + 2),
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
Lane(("ACTIVITY", "ACT", "")), # it goes with its chart
],
width,
)
def _row(
channel: HeardChannel, name_w: int, peak: float, *, mark: str = ""
) -> Callable[[int], Text]:
"""A row title that the screen renders again at each paint, at the width of the row."""
return lambda width: _row_text(channel, name_w, peak, width, mark=mark)
def _row_text(channel: HeardChannel, name_w: int, peak: float, width: int, *, mark: str) -> Text:
"""Build one row of the guide, ``width`` cells at most.
A row that the highlight walks starts at the name, after the pointer column that the
screen draws. A row of a channel that is on the device draws that column itself: two
cells, with ``mark`` (``✓``) in the first one. Thus the two kinds of row align.
"""
text = Text(no_wrap=True, overflow="ellipsis")
if mark:
text.append(mark, style="ok")
text.append(" " * (2 - cell_len(mark)))
text.append(f"{channel_glyph(channel.name, channel.key)} ")
text.append(fit_cells(channel.name, name_w), style="muted" if mark else "")
text.append(" ")
text.append(f"{min(channel.messages, 99999):>{_COUNT_WIDTH}}")
text.append(" ")
age = format_age(age_seconds(channel.last_heard)) if channel.last_heard else "never"
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
cells = min(_CHART_CELLS, width - text.cell_len - 2)
if cells > 0:
text.append(" ")
text.append_text(activity_sparkline(channel.histogram, 2 * cells, peak=peak))
return text