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>
1654 lines
77 KiB
Python
1654 lines
77 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""The interactive channel manager, rendered in the full-screen session.
|
||
|
||
This is the channel management that the user sees for the ``channels`` tool. It is a live
|
||
list of the channel slots of the device. For each slot, a detail screen shows the QR code
|
||
and the key that the user can share. On the detail screen the user can rename the channel,
|
||
change its key, open it in chat, mute its notifications, or clear it. There are four ways
|
||
to make a new channel: a new private channel (random key), a public ``#`` channel (the key
|
||
comes from the name), joining with a key that the user pastes, or importing a scanned
|
||
``meshcore://`` link. Each change is written to the device at once, as in a phone app.
|
||
Thus the list always shows the state of the radio.
|
||
|
||
The channel guide (:mod:`meshterm.ui.channel_guide`) is the first row of "Add a channel". It
|
||
lists the public channels that MeshTerm heard, and it adds one with Enter.
|
||
|
||
The section "Export and import" moves all the channels to another device. The export writes
|
||
each channel, with its key, its send scope, and its mute, to one TOML file. The import adds
|
||
the channels of such a file to the device, in the order of the file, and it never removes a
|
||
channel (refer to :mod:`meshterm.core.channel_file`).
|
||
|
||
Muting is the one setting of a channel that is a local *preference* and not a configuration
|
||
of the device. The new messages of a muted channel do not make the unread badge higher.
|
||
Its inbound messages do not add to the unread count, and muting sets the count to zero.
|
||
But MeshTerm still records the messages in the history. The mute is in
|
||
:class:`~meshterm.core.mute_store.MuteStore`. Its key is the intrinsic identity of the
|
||
channel, so the mute stays with the channel when the channel moves to another slot. The
|
||
list row shows a ``🔕`` in its unread lane (which is then always empty) to mark the mute.
|
||
|
||
The **send scope** of a channel is the other such setting. It is the region into which the
|
||
messages of the channel are flooded, so that only the repeaters that carry that region
|
||
relay them. The firmware has no send scope for each channel. The chat sets the session
|
||
scope of the companion around each send (refer to
|
||
:meth:`~meshterm.core.connection.Device.send_channel_in_scope`). Thus MeshTerm keeps the
|
||
send scope, in :class:`~meshterm.core.region_store.RegionStore`, with the same key type as
|
||
a mute. The row *Send scope…* of the detail page lets the user select the scope from the
|
||
regions that MeshTerm already knows, or type a name.
|
||
|
||
The list has the same layout as the config editor: fixed lanes, aligned in columns, under
|
||
one header line. The lanes are the openness glyph and name, the send scope, the unread
|
||
badge, the total messages, the age of the last message, and a braille sparkline of the
|
||
traffic of the last two hours. Thus a glance shows which channels exist and also which
|
||
channels are active. The openness (other than the glyph) and the hash are in the detail
|
||
screens (the title line and Show key). This keeps the list small enough that the activity
|
||
lane stays visible on a terminal of 72 columns. The message statistics come from
|
||
:meth:`~meshterm.persistence.repository.Repository.channel_stats` (read through a small
|
||
TTL cache). The unread counts come from the live chat service. Each row is a callable
|
||
title that the code resolves again at each paint. Thus, if a message arrives while the
|
||
list is open, its row updates in place. The conversation picker uses the same method. The
|
||
menus also follow the pattern of the config editor with a backdrop that stays: the list
|
||
stays pushed, and each sub-prompt floats over it as a modal dialog, and does not replace
|
||
the screen.
|
||
|
||
This module is in the UI layer. But, as the config editor and the chat screen do, it can
|
||
depend on the context and the services. It has no persistence of its own.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from collections.abc import Awaitable, Callable
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
|
||
from rich.cells import cell_len
|
||
from rich.console import Group
|
||
from rich.text import Text
|
||
|
||
from ..core.channel_file import (
|
||
ChannelFileError,
|
||
ImportPlan,
|
||
apply_preferences,
|
||
entry_from_slot,
|
||
plan_import,
|
||
read_channel_file,
|
||
write_channel_file,
|
||
)
|
||
from ..core.channel_probe import ChannelSlot, probe_channel_slots
|
||
from ..core.channels import (
|
||
DEFAULT_PUBLIC_SECRET,
|
||
MAX_CHANNELS,
|
||
derive_secret,
|
||
normalize_secret,
|
||
parse_share_url,
|
||
random_secret,
|
||
share_url,
|
||
)
|
||
from ..core.connection import Device
|
||
from ..core.regions import RegionNameError
|
||
from ..core.regions import validate as validate_region
|
||
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
|
||
from ..platforms import get_platform
|
||
from .braillechart import activity_peak, activity_sparkline
|
||
from .menus import (
|
||
Lane,
|
||
column_header,
|
||
fit_cells,
|
||
icon_lane,
|
||
marked_label,
|
||
menu_rows,
|
||
run_steps,
|
||
section_heading,
|
||
)
|
||
from .qr import share_screen
|
||
from .theme import glyph
|
||
from .tui import CANCEL, Choice, SelectScreen, Separator
|
||
from .widgets import age_seconds, channel_glyph, format_age, format_ago
|
||
|
||
if TYPE_CHECKING:
|
||
from ..context import AppContext
|
||
from ..persistence.repository import ChannelStats
|
||
|
||
#: The footer sentence of the manager: navigation, then the filter, then the action, and Esc
|
||
#: last.
|
||
_MANAGER_HINT = "↑↓ move · type to filter · Enter open · Esc back"
|
||
|
||
# The sentinels of the actions of the top menu. They are different from a plain slot index,
|
||
# which selects that channel.
|
||
_CREATE = "__create__"
|
||
_PUBLIC = "__public__"
|
||
_DEFAULT_PUBLIC = "__default_public__"
|
||
_JOIN = "__join__"
|
||
_IMPORT = "__import__"
|
||
_REORDER = "__reorder__"
|
||
_EXPORT_FILE = "__export_file__"
|
||
_IMPORT_FILE = "__import_file__"
|
||
_GUIDE = "__guide__"
|
||
|
||
# The sentinels of the actions of the channel detail screen.
|
||
_QR = "qr"
|
||
_KEY = "key"
|
||
_CHAT = "chat"
|
||
_MUTE = "mute"
|
||
_SCOPE = "scope"
|
||
_EDIT = "edit"
|
||
|
||
# The sentinels of the send-scope picker: clear the scope of the channel, or type the name of
|
||
# a region.
|
||
_NO_SCOPE = "__no_scope__"
|
||
_TYPE_REGION = "__type_region__"
|
||
|
||
#: The footer of the scope picker. It is a value picker over a list that can grow, so it
|
||
#: filters.
|
||
_SCOPE_HINT = "↑↓ move · type to filter · Enter set · Esc keep"
|
||
_CLEAR = "clear"
|
||
|
||
|
||
async def manage_channels(ctx: AppContext) -> int:
|
||
"""Run the interactive channel manager until the user leaves it.
|
||
|
||
The visit says **nothing** when the user leaves. It once stored a note for each action,
|
||
and then added a line "✓ applied 3 channel changes" after them. The line was shown when
|
||
the manager was already closed. It was an acknowledgement for changes that the user had
|
||
just seen in the list in front of them. It came as a short dialog or as a full-frame
|
||
result screen, and that depended on the number of lines that had collected. An error is the
|
||
exception. The manager shows an error when it occurs (refer to :func:`_import_link`).
|
||
|
||
The user gets the wait itself instead. Each device action reports *while it runs*, under
|
||
the modal busy card (:meth:`~meshterm.ui.surface.Ui.busy_dialog`). Refer to
|
||
:func:`write_channel` and ``settle`` below for the places where the cards are drawn, and
|
||
for the reason why the card must be modal.
|
||
|
||
Args:
|
||
ctx: The shared application context (it gives the connected device and the UI).
|
||
|
||
Returns:
|
||
The number of channels that were created, changed, or cleared. This is the record
|
||
of the run log of what the visit did (``ToolResult.summary``). The user does not
|
||
see it.
|
||
"""
|
||
device = await ctx.device()
|
||
# The number of slots of the firmware is fixed for the session, so find it one time
|
||
# (a probe that only reads) and do not assume a fixed 8. The number controls the check
|
||
# for a free slot and the used/total display below. Read it through the session cache.
|
||
# On firmware that never rejects an index that is out of range, the probe walks each
|
||
# slot (slow). The count does not change for a connection, so the code warms it one time
|
||
# (prewarm or the first open) and uses it again at each later open. If a device cannot
|
||
# report any slots, the code uses the standard count. Thus the manager stays usable and
|
||
# does not show zero capacity.
|
||
capacity = await ctx.devstate.channel_capacity() or MAX_CHANNELS
|
||
stats = _LiveStats(ctx)
|
||
changes = 0
|
||
highlight: object | None = None
|
||
slots: list = []
|
||
|
||
async def reload() -> tuple[str, list]:
|
||
"""Probe the slot layout again and build the title and the rows of the list again.
|
||
|
||
This goes through the session cache. The slot probe walks each index, and it is one
|
||
of the slowest reads when a screen opens. Thus a screen that opens after this one
|
||
(or this one after another screen) uses the same probe again. The function keeps a
|
||
local copy, so that the helpers below that change the list can work on a working
|
||
list. Each change makes the cache invalid (refer to ``handle``). Thus this function
|
||
probes the new layout again and does not trust the old copy.
|
||
"""
|
||
nonlocal slots
|
||
slots = list(await ctx.devstate.channel_slots())
|
||
return _menu_items(ctx, slots, capacity, stats)
|
||
|
||
async def settle(*, moved: bool = False) -> tuple[str, list]:
|
||
"""Probe the layout again. If a slot moved, file again what points to it.
|
||
|
||
When something changed, this is two round trips to the device, one after the other.
|
||
They are the slot map of the chat service, and the slot probe that ``reload`` calls,
|
||
which is one of the slowest reads that the app does. They report as one wait, and
|
||
not as boxes that flash in a sequence. Also, while the card is up, the user cannot
|
||
type into a list whose rows are replaced under the user.
|
||
|
||
Args:
|
||
moved: Whether the occupant of a slot changed during the round that just ended.
|
||
The caller finds this by a comparison of ``devstate.channels_epoch``. It
|
||
does not trust a count (refer to :func:`write_channel`, which drops the
|
||
cache when it writes).
|
||
"""
|
||
async with ctx.ui.busy_dialog("reading channels…", title="Channels"):
|
||
title_and_items = await reload()
|
||
if moved:
|
||
# An inbound message has only a slot index. The chat service maps it to the
|
||
# identity of a channel through a cache with the slot as the key. Refresh the
|
||
# cache now. Then a message on a slot that is used again or has a new key is
|
||
# filed under the channel that is there now, and not under the channel that
|
||
# was there before. If not, its transcript is in the wrong chat. This is
|
||
# after ``reload`` on purpose. It reads the same cached probe, so in this
|
||
# order the pair needs one walk of the device and not two.
|
||
await _refresh_chat_channels(ctx)
|
||
return title_and_items
|
||
|
||
async def handle(choice: object) -> bool:
|
||
"""Do one menu choice (over the list that is still pushed). ``False`` leaves the manager."""
|
||
nonlocal changes, highlight
|
||
if choice is None: # Esc
|
||
return False
|
||
highlight = choice
|
||
if choice == _CREATE:
|
||
changes += await _create_private(ctx, device, slots, capacity)
|
||
elif choice == _DEFAULT_PUBLIC:
|
||
changes += await _add_default_public(ctx, device, slots, capacity)
|
||
elif choice == _PUBLIC:
|
||
changes += await _add_public(ctx, device, slots, capacity)
|
||
elif choice == _JOIN:
|
||
changes += await _join_with_key(ctx, device, slots, capacity)
|
||
elif choice == _IMPORT:
|
||
changes += await _import_link(ctx, device, slots, capacity)
|
||
elif choice == _REORDER:
|
||
changes += await _reorder_channels(ctx, device, slots)
|
||
elif choice == _EXPORT_FILE:
|
||
await _export_channels(ctx, slots) # it writes a file, and the device stays the same
|
||
elif choice == _IMPORT_FILE:
|
||
changes += await _import_channels(ctx, device, capacity)
|
||
elif choice == _GUIDE:
|
||
from .channel_guide import channel_guide # it imports this module
|
||
|
||
changes += await channel_guide(ctx, device, capacity)
|
||
else: # an existing slot index
|
||
slot = next((s for s in slots if s.idx == choice), None)
|
||
if slot is not None:
|
||
changes += await _channel_detail(ctx, device, slot, stats)
|
||
return True
|
||
|
||
# The list stays pushed for the whole visit. Each sub-flow floats over it: to create a
|
||
# channel, to import a link, or the detail page of a slot. After each sub-flow, the code
|
||
# probes the rows again and replaces them in place. Thus the highlight (and a typed
|
||
# filter) stays when the layout changed under it. A UI that has no session (the plain
|
||
# CLI, or scripted tests) has no stack to stay on. It runs the same dispatcher, one
|
||
# round at a time.
|
||
title, items = await settle()
|
||
session = getattr(ctx.ui, "session", None)
|
||
if session is None:
|
||
while True:
|
||
epoch = ctx.devstate.channels_epoch
|
||
try:
|
||
keep = await _menu_round(ctx, title, items, default=highlight, handle=handle)
|
||
except BaseException:
|
||
# A write that reached the device and then raised an error changed the
|
||
# device as much as a write that returned with no error. A ^W that unwinds
|
||
# through the QR screen, which opened after the write, does the same. The
|
||
# epoch shows this when the return value of the action did not have the
|
||
# chance to show it. Thus the code files the chat map again before it
|
||
# raises the error again.
|
||
if ctx.devstate.channels_epoch != epoch:
|
||
await _refresh_chat_channels(ctx)
|
||
raise
|
||
if not keep:
|
||
return changes
|
||
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
|
||
# The hint is ``Enter open`` and not the default of the list. Each slot row pushes the
|
||
# detail screen of the channel, and Reorder pushes the reorder screen. The chat picker
|
||
# shows the same channels, one screen over, and it has always said the same.
|
||
menu = SelectScreen(title, items, footer_hint=_MANAGER_HINT)
|
||
async with session.stay(menu) as visit:
|
||
while True:
|
||
choice = await visit.result()
|
||
epoch = ctx.devstate.channels_epoch
|
||
try:
|
||
keep = await handle(None if choice is CANCEL else choice)
|
||
except BaseException:
|
||
if ctx.devstate.channels_epoch != epoch: # refer to the note on the other loop
|
||
await _refresh_chat_channels(ctx)
|
||
raise
|
||
if not keep:
|
||
return changes
|
||
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
|
||
menu.replace_items(items, title=title)
|
||
|
||
|
||
async def _menu_round(
|
||
ctx: AppContext,
|
||
title: str,
|
||
items: list,
|
||
*,
|
||
handle: Callable[[object], Awaitable],
|
||
default: object = None,
|
||
) -> object:
|
||
"""Select one time and do the choice. This is what a UI with no stack of screens does.
|
||
|
||
In the full-screen session, both channel menus keep one screen for the whole visit
|
||
(``session.stay``, with the rows replaced in place). This is the only way that the
|
||
highlight, the sort, and a typed filter stay after an action. A UI that has no stack
|
||
(the plain CLI, or a scripted test) has nothing to keep. It selects, does the choice,
|
||
and is called again. ``default`` is the only value that it can carry from one round to
|
||
the next. The summary line has no place to be drawn.
|
||
|
||
Args:
|
||
ctx: The shared application context.
|
||
title: The heading in the border of the menu.
|
||
items: The :class:`Choice` and :class:`Separator` rows of the menu.
|
||
handle: The async function that is awaited with the selected value (``None`` for
|
||
Esc).
|
||
default: The value of a choice to highlight again, so that the menu opens again
|
||
where the user left it.
|
||
|
||
Returns:
|
||
The value that ``handle`` returns.
|
||
"""
|
||
return await handle(await ctx.ui.select(title, items, default=default))
|
||
|
||
|
||
async def _refresh_chat_channels(ctx: AppContext) -> None:
|
||
"""Build the slot-to-identity cache of the chat service again after a channel change.
|
||
|
||
This is a best effort. A short fault in a device read must never stop the channel
|
||
manager. The cache also repairs itself at the next miss, so the code only logs a
|
||
failure.
|
||
"""
|
||
try:
|
||
await ctx.chat.refresh_channels()
|
||
except Exception as exc: # noqa: BLE001 - a best effort. It is never a fatal error here
|
||
ctx.log.debug("channels: chat cache refresh failed: %s", exc)
|
||
|
||
|
||
def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> int | None:
|
||
"""Return the lowest unused slot index, or ``None`` if each slot is full."""
|
||
used = {s.idx for s in slots}
|
||
return next((i for i in range(capacity) if i not in used), None)
|
||
|
||
|
||
# --- writing -----------------------------------------------------------------
|
||
|
||
|
||
async def write_channel(
|
||
ctx: AppContext, device: Device, idx: int, name: str, secret: bytes | None
|
||
) -> None:
|
||
"""Write a channel to a device slot, and remember it for a restore.
|
||
|
||
The memory lets MeshTerm restore a device that forgets its channels.
|
||
This is the only boundary that each channel change goes through. Create, join, import,
|
||
edit, reorder, and clear all come here. Thus the channel store is a true record of what
|
||
MeshTerm wrote (refer to :mod:`meshterm.core.channel_store`). If the user clears a slot
|
||
(an empty ``name``), the store forgets it. Any other write makes the store remember the
|
||
channel under the device's own public key. For a channel whose key comes from its
|
||
name, the store keeps that derived key, so that it can be sent again as it is. The
|
||
remembering is a best effort. It never blocks the real write to the device, and it never
|
||
makes the write fail.
|
||
|
||
Because this is the only boundary, it is also the place where the *wait* is reported.
|
||
The write and the identity probe after it are one round trip to the device each. Before
|
||
the card existed, the list looked idle and fully interactive while they ran. A batch (a
|
||
reorder) is nested in the card of its caller. It does not flash one box for each slot.
|
||
|
||
Args:
|
||
ctx: The shared application context (for the channel store and the cached
|
||
self-info).
|
||
device: The connected device to write to.
|
||
idx: The slot to write.
|
||
name: The channel name (an empty name clears the slot).
|
||
secret: The secret of 16 bytes, or ``None`` to let the firmware derive it from the
|
||
name.
|
||
"""
|
||
caption = f"clearing slot {idx}…" if not name.strip() else f"saving {name.strip()}…"
|
||
async with ctx.ui.busy_dialog(caption, title="Channels"):
|
||
await _write_and_remember(ctx, device, idx, name, secret)
|
||
# The layout on the device is not what any cache has now. Say so here, and do not leave
|
||
# each caller to remember it. The manager of the menu did remember it, but it lost the
|
||
# call when an action raised an error on its way back. The four CLI subcommands never
|
||
# did it. The bump is also the signal that a caller compares across a round. Refer to
|
||
# :attr:`~meshterm.services.device_state.DeviceState.channels_epoch`.
|
||
ctx.devstate.invalidate_channels()
|
||
|
||
|
||
async def _write_and_remember(
|
||
ctx: AppContext, device: Device, idx: int, name: str, secret: bytes | None
|
||
) -> None:
|
||
"""Do the write itself and the bookkeeping of the channel store.
|
||
|
||
Refer to :func:`write_channel`.
|
||
"""
|
||
await device.set_channel(idx, name, secret)
|
||
store = ctx.channel_store
|
||
if store is None:
|
||
return
|
||
try:
|
||
pubkey = (await ctx.devstate.self_info()).get("public_key", "")
|
||
except Exception as exc: # noqa: BLE001 - the identity probe is a best effort, so skip the store
|
||
ctx.log.debug("channels: could not read self key to remember channel: %s", exc)
|
||
return
|
||
if not pubkey:
|
||
return
|
||
if name.strip():
|
||
store.remember(pubkey, idx, name, secret if secret is not None else derive_secret(name))
|
||
else:
|
||
store.forget(pubkey, idx)
|
||
|
||
|
||
def _slot_label(slot: ChannelSlot) -> str:
|
||
"""Format a channel for a compact row (the reorder screen): only its glyph and name.
|
||
|
||
The reorder screen is about *position* and not about the vital data. The openness,
|
||
hash, and message lanes of the manager list would only make the dialog wider. Thus
|
||
each row is the channel exactly as the first lane of the manager shows it: glyph, gap,
|
||
name.
|
||
"""
|
||
return f"{channel_glyph(slot.name, slot.secret)} {slot.name}"
|
||
|
||
|
||
# --- notifications -------------------------------------------------------------
|
||
|
||
|
||
def _is_muted(ctx: AppContext, slot: ChannelSlot) -> bool:
|
||
"""Whether the notifications of new messages of this channel are muted.
|
||
|
||
Refer to :class:`MuteStore`.
|
||
"""
|
||
return ctx.mute_store.is_muted(slot.identity)
|
||
|
||
|
||
def _toggle_mute(ctx: AppContext, slot: ChannelSlot) -> None:
|
||
"""Change the notification mute of a channel. When it is muted, set its unread count to zero.
|
||
|
||
Muting is a preference that MeshTerm remembers for each channel. Its key is the
|
||
intrinsic identity of the channel, so it stays with the channel when the channel moves
|
||
to another slot. Muting stops the new messages of the channel from making the unread
|
||
badge higher. Muting also clears the unread count that the channel had in this session.
|
||
Thus the badge goes down at once and does not stay until the next open. If the user
|
||
unmutes the channel, future messages start to count again.
|
||
"""
|
||
now_muted = not _is_muted(ctx, slot)
|
||
ctx.mute_store.set_muted(slot.identity, now_muted)
|
||
if now_muted:
|
||
ctx.chat.clear_unread(slot.conversation.key)
|
||
|
||
|
||
# --- message statistics --------------------------------------------------------
|
||
|
||
|
||
#: The lower limit for the shared channel-activity peak. When each channel is quiet, a
|
||
#: single message is drawn against at least this number for each bucket. Thus a single
|
||
#: message stays a small nub and does not fill its column. The number is low, because
|
||
#: channel traffic is sparse from the start.
|
||
_ACTIVITY_FLOOR = 2.0
|
||
|
||
|
||
class _LiveStats:
|
||
"""A view of the statistics of the stored messages of each channel. It refreshes itself.
|
||
|
||
This is the ``_LiveLasts`` pattern of the conversation picker, used for
|
||
:meth:`~meshterm.persistence.repository.Repository.channel_stats`. The list rows read
|
||
through this object at each paint (their titles are callables). Thus, if a message
|
||
arrives while the manager is open, the counts, the age, and the sparkline of that
|
||
channel update in place. But the code queries the repository at most one time for each
|
||
``ttl`` seconds, and not one time for each row at each paint. Thus a full slot table
|
||
stays cheap at the paint rate of the session, which is approximately 1 Hz. The TTL is
|
||
*higher* than that paint period on purpose. If the TTL was exactly the tick rate,
|
||
each idle frame would still cause one repository query, and the throttle would limit
|
||
nothing.
|
||
"""
|
||
|
||
def __init__(self, ctx: AppContext, *, ttl: float = 3.0) -> None:
|
||
"""Connect to a context. The first read fills the cache."""
|
||
self._ctx = ctx
|
||
self._ttl = ttl
|
||
self._cache: dict[str, ChannelStats] | None = None
|
||
self._peak: float | None = None
|
||
self._at = 0.0
|
||
|
||
def _snapshot(self) -> dict[str, ChannelStats]:
|
||
"""The statistics of each channel.
|
||
|
||
It reads the repository again at most one time for each ``ttl``.
|
||
"""
|
||
now = time.monotonic()
|
||
if self._cache is None or now - self._at >= self._ttl:
|
||
try:
|
||
self._cache = self._ctx.repo.channel_stats()
|
||
except Exception: # noqa: BLE001 - if a read fails, keep the last good snapshot
|
||
self._cache = self._cache or {}
|
||
self._peak = None # it comes from the snapshot, so calculate it again at the next read
|
||
self._at = now
|
||
return self._cache
|
||
|
||
def get(self, channel_id: str) -> ChannelStats | None:
|
||
"""Return the statistics for one channel identity. Refresh them when the TTL ends."""
|
||
return self._snapshot().get(channel_id)
|
||
|
||
def peak(self) -> float:
|
||
"""The sparkline scale that is shared across *each* channel. It is cached for each snapshot.
|
||
|
||
This is a steady upper limit over the histograms of all the channels together (refer
|
||
to :func:`~meshterm.ui.braillechart.activity_peak`). Outliers have little effect on
|
||
it, so one busy burst does not flatten the column. It has a lower limit, so a single
|
||
message in a quiet period stays a nub. It is steadier because the pool uses a time window
|
||
that is deeper than the rows draw. The code gives this one value to the
|
||
:func:`~meshterm.ui.braillechart.activity_sparkline` of each row. Thus the whole
|
||
activity column is scaled against the busiest channel on the screen. The bar heights
|
||
of the rows are comparable at a glance, and each row does not scale to its own
|
||
upper limit.
|
||
|
||
The method reads live through the same cache and TTL as :meth:`get`. It caches the
|
||
value together with that snapshot, so a full slot table does not calculate the pooled
|
||
peak again for each row.
|
||
"""
|
||
snapshot = self._snapshot()
|
||
if self._peak is None:
|
||
self._peak = activity_peak(
|
||
*(st.histogram for st in snapshot.values()),
|
||
floor=_ACTIVITY_FLOOR,
|
||
)
|
||
return self._peak
|
||
|
||
|
||
#: The maximum width of the name lane. Names that are longer are cut with an ellipsis, so
|
||
#: that the lanes stay in their places.
|
||
_NAME_WIDTH_MAX = 18
|
||
#: The number of cells that the name lane and the send-scope lane share. The code draws the
|
||
#: scope lane only if at least one channel in the list has a scope, because a column of
|
||
#: blanks gives no information. The lane is then only as wide as the longest scope (but not
|
||
#: narrower than its ``SCOPE`` label). It takes what the names leave of this budget. If a
|
||
#: name has the width :data:`_NAME_WIDTH_MAX`, the scope lane gets eight cells. This is the
|
||
#: space that the row of 72 columns had before the lane existed, less the last cell that a
|
||
#: row with an ellipsis keeps free. Thus the activity sparkline keeps its full width on the
|
||
#: desktop. Shorter names give more cells to a long region name. A region name of 30 bytes
|
||
#: gets an ellipsis, and the row does not become wider. On the PicoCalc, with 53 columns,
|
||
#: the code already draws the sparkline shorter to fit (refer to :func:`_slot_text`). Thus a
|
||
#: scope lane there makes it shorter again. It takes cells from the MSGS count behind it
|
||
#: only when the chart has no cells left. The detail page states the scope. The leading
|
||
#: SLOT lane also uses cells from this budget (refer to :func:`_menu_items`), for the same
|
||
#: reason: the sparkline keeps its cells.
|
||
_NAME_SCOPE_BUDGET = _NAME_WIDTH_MAX + 8
|
||
#: The minimum width of the slot-index lane, which is aligned to the right. It has two
|
||
#: digits, because the default table of MeshCore has more than ten slots. A wider table
|
||
#: makes the lane as wide as its highest index.
|
||
_SLOT_WIDTH_MIN = 2
|
||
#: The width of the unread-badge lane (it fits ``● 999``). It is the same as the width in the
|
||
#: conversation picker.
|
||
_BADGE_WIDTH = 5
|
||
#: The width of the total-messages lane, which is aligned to the right.
|
||
_COUNT_WIDTH = 5
|
||
#: The width of the last-message-age lane, which is aligned to the right. It fits ages that
|
||
#: are as long as ``never``.
|
||
_AGE_WIDTH = 5
|
||
|
||
|
||
#: The number of cells that the activity sparkline uses when the row has room for all of it.
|
||
#: A braille cell has two buckets of five minutes, so the sparkline shows the last two hours.
|
||
_ACTIVITY_CELLS = ACTIVITY_DRAWN_BUCKETS // 2
|
||
|
||
|
||
@lru_cache(maxsize=64)
|
||
def _activity_sparkline(
|
||
histogram: tuple[int, ...], peak: float, cells: int = _ACTIVITY_CELLS
|
||
) -> Text:
|
||
"""The braille activity sparkline of the channel, ``cells`` wide, with now on the right.
|
||
|
||
This is the shared :func:`~meshterm.ui.braillechart.activity_sparkline` over the newest
|
||
``2 × cells`` buckets of five minutes of the histogram of the repository. At the full
|
||
:data:`_ACTIVITY_CELLS` it shows the last two hours. It shows less where the row has
|
||
less room (refer to :func:`_slot_text`). Then it removes the *oldest* buckets, so that
|
||
now stays on the right edge. It is scaled to ``peak``, which is the shared upper limit
|
||
across each channel (refer to :meth:`_LiveStats.peak`). Thus the whole activity column
|
||
has one scale, and the bars of the rows are comparable at a glance. The histogram is
|
||
deeper than the drawn part. The tail after the drawn buckets has an effect on ``peak``,
|
||
but the chart does not show it.
|
||
|
||
The function is cached on its inputs, which are hashable. The row callables are built
|
||
again at each paint, but the histogram snapshot changes only one time for each
|
||
:class:`_LiveStats` TTL. Thus between refreshes the sparkline of each slot is a cache
|
||
hit. Callers must treat the returned Text as read-only.
|
||
"""
|
||
return activity_sparkline(histogram, 2 * cells, peak=peak)
|
||
|
||
|
||
# --- menus -------------------------------------------------------------------
|
||
|
||
|
||
def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str:
|
||
"""The column headers over the fixed lanes of the channel list (refer to :func:`_slot_text`).
|
||
|
||
The indent covers the pointer column of the select screen (2 cells, drawn on choice
|
||
rows but not on separators). ``SLOT`` is first. It is the index that each ``channels``
|
||
command and the slot references of the chat use. Its lane also covers the glyph lane
|
||
after it, which has no label. The code *measures* the width and does not assume it.
|
||
The glyph of a channel is two cells on the desktop and one on the console. A fixed width
|
||
put each later label one column off there. ``NEW`` is over the lane of the unread
|
||
badge. There is no TYPE lane and no HASH lane. The glyph already shows the openness, and
|
||
the hash is in Show key. This gives the activity sparkline its room on a terminal of 72
|
||
columns. ``SCOPE`` is between the name and the badge. It says where the messages of the
|
||
channel go, which is a fact of the channel itself, and it comes before the lanes that
|
||
count its traffic. The code shows it only while some channel has a scope (otherwise
|
||
``scope_w`` is 0). ``LAST`` is before ``MSGS``. A glance down the list asks how recently
|
||
a channel spoke, and the total is the detail behind that.
|
||
|
||
The code resolves the header against the render width, because the header row is pinned
|
||
and must stay one row. At 53 columns the full line was 54 cells and it wrapped. That
|
||
used one content row of the twenty-six, and the landmark was drawn two times.
|
||
``ACTIVITY`` gives its cells back first (refer to
|
||
:func:`~meshterm.ui.menus.column_header`), as the chart under it does.
|
||
"""
|
||
return column_header(
|
||
[
|
||
Lane("SLOT", slot_w + 2 + cell_len(channel_glyph("Public", None)) + 1),
|
||
Lane("CHANNEL", name_w + 2),
|
||
*([Lane("SCOPE", scope_w + 2)] if scope_w else []),
|
||
Lane("NEW", _BADGE_WIDTH + 2),
|
||
# Aligned to the right, because the values under them are. An age and a count
|
||
# are padded to the right edge of their lane, so a label aligned to the left
|
||
# would be away from the digits that it names.
|
||
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
|
||
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
|
||
Lane(("ACTIVITY", "ACT", "")), # it is removed with its chart (refer to _slot_text)
|
||
],
|
||
width,
|
||
)
|
||
|
||
|
||
def _slot_row(
|
||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int
|
||
) -> Callable[[int], Text]:
|
||
"""Return a title callable for a list row. The select screen renders it again at each paint.
|
||
|
||
The code reads the unread badge, the counts, the age, and the activity sparkline live
|
||
(refer to :class:`_LiveStats`). Thus, if a message arrives while the list is open, the
|
||
row updates at the next paint. This is exactly the behaviour of the conversation
|
||
picker. The callable takes the render width, so the sparkline can fit itself to the row
|
||
(refer to :func:`_slot_text`).
|
||
"""
|
||
return lambda width: _slot_text(ctx, slot, stats, slot_w, name_w, scope_w, width)
|
||
|
||
|
||
def _slot_text(
|
||
ctx: AppContext,
|
||
slot: ChannelSlot,
|
||
stats: _LiveStats,
|
||
slot_w: int,
|
||
name_w: int,
|
||
scope_w: int,
|
||
width: int,
|
||
) -> Text:
|
||
"""Build the list row of one channel, ``width`` cells at most.
|
||
|
||
The row has lanes of fixed width and colours. The alignment makes the row easy to read.
|
||
The slot index (aligned right, muted), the glyph, the name, the send scope, the unread
|
||
badge, the age of the last message, the total messages, and the activity sparkline are
|
||
each in their own lane under the :func:`_lanes_header` line. The colour is light and has
|
||
a purpose. The name is the focus of the row, in the base colour. The descriptive lanes
|
||
are muted. The unread ``●`` badge is red, with its count in warn (the language of the
|
||
conversation picker). The sparkline is drawn in the ok green over a faint flat line.
|
||
A muted channel shows a muted ``🔕`` in the unread lane and not a count. Muting sets
|
||
the unread count to zero and stops it from growing, so that lane is always free to
|
||
show the state. The scope is the region name in the ``scope`` style. It is blank for a
|
||
channel that sends with the default of the device. That is the ordinary case, and a
|
||
word in each row would make it harder to see the other rows. If ``scope_w`` is 0 (no
|
||
channel has a scope), the code does not draw the lane. The row is always a Rich
|
||
:class:`~rich.text.Text`, so those spans stay under the row highlight of the select
|
||
screen.
|
||
|
||
**The sparkline is the last lane, and it becomes shorter to fit** (JP, 2026-10-03).
|
||
Where the row is narrower than all the lanes at their full width (the 53 columns of the
|
||
PicoCalc and the Cardputer, a long name, or a scope lane), the code draws the chart in
|
||
the cells that the lanes before it leave. It removes the oldest buckets, so that now
|
||
stays on the right. The ellipsis of the row does not cut the chart in the middle. If no
|
||
cell is left for it, the code does not draw it.
|
||
"""
|
||
st = stats.get(slot.identity)
|
||
muted = _is_muted(ctx, slot)
|
||
unread = ctx.chat.unread(slot.conversation.key)
|
||
text = Text(no_wrap=True, overflow="ellipsis")
|
||
text.append(f"{slot.idx:>{slot_w}}", style="muted")
|
||
text.append(" ")
|
||
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
|
||
text.append(fit_cells(slot.name, name_w))
|
||
text.append(" ")
|
||
if scope_w:
|
||
text.append(fit_cells(_channel_scope(ctx, slot) or "", scope_w), style="scope")
|
||
text.append(" ")
|
||
if muted:
|
||
mark = glyph("🔕") # two cells on the desktop, one on the console
|
||
text.append(mark, style="muted")
|
||
text.append(" " * (_BADGE_WIDTH - cell_len(mark)))
|
||
elif unread:
|
||
text.append("●", style="err")
|
||
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
|
||
else:
|
||
text.append(" " * _BADGE_WIDTH)
|
||
text.append(" ")
|
||
age = format_age(age_seconds(st.last_at)) if st is not None and st.last_at else ""
|
||
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
|
||
text.append(" ")
|
||
total = st.total if st is not None else 0
|
||
if total:
|
||
# A limit, so that an extreme backlog cannot push the row out of its lanes.
|
||
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
|
||
else:
|
||
# ``○`` is the empty mark of the app. ``·`` had three meanings here: the separator
|
||
# that chains status atoms, the stand-in of the picker for an unknown sender, and,
|
||
# in this same row, what the console folds ``🔕`` to. Thus a muted channel with no
|
||
# messages drew the same glyph two times with different meanings.
|
||
text.append(f"{'○':>{_COUNT_WIDTH}}", style="muted")
|
||
# The cells that the lanes leave after the gap, up to the full width of the chart.
|
||
cells = min(_ACTIVITY_CELLS, width - text.cell_len - 2)
|
||
if cells > 0:
|
||
text.append(" ")
|
||
# The shared peak across all channels, so the sparkline of each row has one scale.
|
||
histogram = st.histogram if st is not None else ()
|
||
text.append_text(_activity_sparkline(histogram, stats.peak(), cells))
|
||
return text
|
||
|
||
|
||
def _menu_items(
|
||
ctx: AppContext, slots: list[ChannelSlot], capacity: int, stats: _LiveStats
|
||
) -> tuple[str, list]:
|
||
"""Build the title and the rows of the channel manager for the current slot table.
|
||
|
||
The function returns the ``(title, items)`` for one :func:`_menu_round`. The items are
|
||
the channel rows in their aligned lanes under a column-header line (the presentation of
|
||
the config editor), and then the sections of actions, Organize and Add a channel. The
|
||
slot usage is in the title, so the header line has only column labels.
|
||
"""
|
||
items: list = []
|
||
if slots:
|
||
slot_w = max(_SLOT_WIDTH_MIN, *(len(str(s.idx)) for s in slots))
|
||
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
|
||
scopes = [cell_len(_channel_scope(ctx, s) or "") for s in slots]
|
||
# There is no scope lane while no channel has a scope (refer to
|
||
# :data:`_NAME_SCOPE_BUDGET`). The slot lane and its gap come out of the same
|
||
# budget, so the sparkline keeps its room. A scope lane that is drawn is never
|
||
# narrower than its label. A long name gives up cells before the lane would put
|
||
# ``SCOPE`` too close to ``NEW``.
|
||
budget = _NAME_SCOPE_BUDGET - (slot_w + 2)
|
||
scope_w = max(len("SCOPE"), min(budget - name_w, max(*scopes))) if any(scopes) else 0
|
||
name_w = min(name_w, budget - scope_w)
|
||
# The lane names are the only landmark of this block (its section has no
|
||
# ── heading ──). Thus they stay pinned at the top while the slots scroll, and they
|
||
# give way to Organize and Add a channel.
|
||
items.append(Separator(lambda w: _lanes_header(slot_w, name_w, scope_w, w), heading=True))
|
||
for slot in slots:
|
||
# Fitted: the row is complete at any width (its chart gets shorter). Thus the
|
||
# highlight also draws it fitted, and not as a line that ←→ slides
|
||
# (Choice.fitted).
|
||
row = _slot_row(ctx, slot, stats, slot_w, name_w, scope_w)
|
||
items.append(Choice(title=row, value=slot.idx, fitted=True))
|
||
else:
|
||
items.append(Separator(" no channels yet — add one below"))
|
||
|
||
# One icon column, which the code measures, for each command row on this screen. Thus
|
||
# the marks of one cell (↕) start their labels in the same column as the marks of two
|
||
# cells (+ # 🌐 🔑 🔗), and not one column early. The Organize row once corrected this
|
||
# by hand, with two spaces and a comment of four lines. The column is empty where the
|
||
# platform draws no icons, and the labels use the cells.
|
||
lane = icon_lane(("↕", "💾", "📂", "⚡", "🌐", "+", "#", "🔑", "🔗"))
|
||
if len(slots) > 1:
|
||
items.append(section_heading("Organize"))
|
||
reorder = marked_label("↕", "Reorder channels", "", lane=lane)
|
||
items.append(Choice(title=reorder, value=_REORDER))
|
||
|
||
# Before "Add a channel", because that section ends early when each slot is full, and an
|
||
# import still has work then: it puts the channels in the order of its file.
|
||
transfer = []
|
||
if slots:
|
||
export = marked_label("💾", "Export channels…", "", lane=lane)
|
||
transfer.append((export, "Save them all to a file", _EXPORT_FILE))
|
||
import_file = marked_label("📂", "Import channels…", "", lane=lane)
|
||
transfer.append((import_file, "Add them from a file", _IMPORT_FILE))
|
||
|
||
# The guide is first, and it stays when each slot is full: its list of the channels heard
|
||
# is worth a read then too, and an add from it says why it cannot add.
|
||
guide = marked_label("⚡", "Channel guide…", "", lane=lane)
|
||
adds = [(guide, "Public channels heard on the mesh", _GUIDE)]
|
||
free = _next_free_slot(slots, capacity) is not None
|
||
if free:
|
||
# The Public channel of the firmware has a fixed key. It has one well-known secret, so
|
||
# it is the same channel on each slot. Offer to restore it only if no slot has it
|
||
# already.
|
||
if not any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
|
||
adds.append(
|
||
(
|
||
marked_label("🌐", "Standard Public channel", "", lane=lane),
|
||
"MeshCore's built-in meshwide channel",
|
||
_DEFAULT_PUBLIC,
|
||
)
|
||
)
|
||
adds.extend(
|
||
[
|
||
(
|
||
marked_label("+", "New private channel…", "", lane=lane),
|
||
"A fresh random key",
|
||
_CREATE,
|
||
),
|
||
(
|
||
marked_label("#", "Public channel…", "", lane=lane),
|
||
"Key derived from its name",
|
||
_PUBLIC,
|
||
),
|
||
(
|
||
marked_label("🔑", "Join with a key…", "", lane=lane),
|
||
"Paste a channel's 32-hex key",
|
||
_JOIN,
|
||
),
|
||
(
|
||
marked_label("🔗", "Import a link…", "", lane=lane),
|
||
"Paste a meshcore:// share link",
|
||
_IMPORT,
|
||
),
|
||
]
|
||
)
|
||
# One call for the rows of the two sections, so that their descriptions start in the same
|
||
# column. Two calls aligned each section to its own widest label, four cells apart.
|
||
aligned = menu_rows(transfer + adds)
|
||
items.append(section_heading("Export and import"))
|
||
items.extend(aligned[: len(transfer)])
|
||
items.append(section_heading("Add a channel"))
|
||
items.extend(aligned[len(transfer) :])
|
||
if not free:
|
||
# The list already knows that there is no free slot. It says so here, and it does not
|
||
# offer four rows that all end in the same refusal.
|
||
items.append(Separator(" every slot is full — clear one first"))
|
||
|
||
# ``·`` chains a status atom. ``—`` introduces a subject, and the slot count is not the
|
||
# subject of this screen (refer to Contacts, which reads "Contacts · 12 known").
|
||
return f"Channels · {len(slots)}/{capacity} slots", items
|
||
|
||
|
||
def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> str:
|
||
"""One line of vital data for the detail screen: what the channel is, then how it was used.
|
||
|
||
The openness and the hash are first. They moved here from the title of the screen. The
|
||
title had them in a blob in parentheses, and it was 42 cells wide. That is the whole
|
||
borderless title bar of the PicoCalc, and it left no room for the one place that says
|
||
that Esc leaves.
|
||
|
||
The line is one line. The code removes atoms from the right until it fits the readable
|
||
width of the platform. The atoms on the left identify the channel. The atoms on the
|
||
right describe traffic, which the user can also see in the row that the user came from.
|
||
"""
|
||
st = stats.get(slot.identity)
|
||
unread = ctx.chat.unread(slot.conversation.key)
|
||
kind = "public" if slot.is_public else "private"
|
||
parts = [kind, f"hash {slot.hash}", f"slot {slot.idx}"]
|
||
# The scope goes with the identity of the channel and not with its traffic. It decides
|
||
# who can hear the next message, so the line keeps it before the counts when it removes
|
||
# atoms.
|
||
scope = _channel_scope(ctx, slot)
|
||
if scope:
|
||
parts.append(f"scope {scope}")
|
||
if st is None or not st.total:
|
||
parts.append("no messages yet")
|
||
else:
|
||
parts.append(f"{st.total} msg{'' if st.total == 1 else 's'}")
|
||
if unread:
|
||
parts.append(f"{unread} unread")
|
||
if _is_muted(ctx, slot):
|
||
parts.append("muted")
|
||
if st is not None and st.last_at is not None:
|
||
parts.append(f"last {format_ago(age_seconds(st.last_at))}")
|
||
width = get_platform().readable_cols
|
||
while len(parts) > 1 and cell_len(" · ".join(parts)) > width:
|
||
parts.pop()
|
||
return " · ".join(parts)
|
||
|
||
|
||
def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
|
||
"""Build the rows of the channel detail screen: label and description in two aligned lanes.
|
||
|
||
This is the Actions presentation (lanes in the style of a menu, with no header line,
|
||
because these are commands and not tabular data). The code pads in display cells, so
|
||
that the emoji of double width do not move the description column. The Open in chat row
|
||
has the live unread badge of the channel. The notifications row is a toggle, and its
|
||
glyph and verb show the current mute state (it is an immediate action, so it has no
|
||
``…`` at the end). The one destructive row keeps a label with the err tint, so that it
|
||
looks destructive.
|
||
"""
|
||
unread = ctx.chat.unread(slot.conversation.key)
|
||
# ✎ and 🗑 are one cell, and 📱 🔑 💬 🔔 🔕 are two cells. Thus the code measures the
|
||
# column one time and pads each mark to it. If not, the edit row and the clear row start
|
||
# their labels one column to the left of the rows above them.
|
||
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "🔖", "✎", "🗑"))
|
||
chat_label = marked_label("💬", "Open in chat", "", lane=lane)
|
||
if unread:
|
||
chat_label.append(" ●", style="err")
|
||
chat_label.append(f" {unread}", style="warn")
|
||
if _is_muted(ctx, slot):
|
||
mute_row = (
|
||
marked_label("🔔", "Unmute notifications", "", lane=lane),
|
||
"Show new messages here in the unread badge",
|
||
_MUTE,
|
||
)
|
||
else:
|
||
mute_row = (
|
||
marked_label("🔕", "Mute notifications", "", lane=lane),
|
||
"Hide new messages here from the unread badge",
|
||
_MUTE,
|
||
)
|
||
items = menu_rows(
|
||
[
|
||
(
|
||
marked_label("📱", "Show QR code", "", lane=lane),
|
||
"Share this channel as a scannable code",
|
||
_QR,
|
||
),
|
||
(
|
||
marked_label("🔑", "Show key", "", lane=lane),
|
||
"The name, key, hash, and share link",
|
||
_KEY,
|
||
),
|
||
(chat_label, "Read and send messages on this channel", _CHAT),
|
||
mute_row,
|
||
_scope_row(ctx, slot, lane),
|
||
(
|
||
marked_label("✎", "Rename / change key…", "", lane=lane),
|
||
"Edit the name or paste a different key",
|
||
_EDIT,
|
||
),
|
||
(
|
||
# The tint is on the mark and not on the words. It is on the words only where
|
||
# the platform draws no mark (refer to menus.marked_label).
|
||
marked_label("🗑", "Clear this slot…", "err", lane=lane),
|
||
"Remove the channel from this device",
|
||
_CLEAR,
|
||
),
|
||
]
|
||
)
|
||
return items
|
||
|
||
|
||
async def _channel_detail(
|
||
ctx: AppContext, device: Device, slot: ChannelSlot, stats: _LiveStats
|
||
) -> int:
|
||
"""Show the actions of one channel (QR, key, chat, rename, clear). Return the changes made.
|
||
|
||
The screen starts with the vital data of the channel (refer to :func:`_detail_summary`)
|
||
above the action rows. As the main list does, it is one screen for the whole visit. It
|
||
stays pushed while the prompts of each action float over it. After the action, the code
|
||
replaces the rows and the vital-data line in place, and does not draw the screen again.
|
||
Both are live (the mute row is a toggle, and the summary counts the unread messages and
|
||
gives the age of the last message). Thus the code reads both again at each round, and
|
||
the highlight stays on the row that the user just used.
|
||
|
||
A **clear** closes the page, because the channel that the page was about is gone. A
|
||
**rename or a new key does not close it**. The channel is still there, and this is still
|
||
its page. Thus the code reads the new name and key again into the title, the summary,
|
||
and the rows, and the user stays where the user was. The page once closed in both cases.
|
||
The only reason was that ``slot`` was a snapshot that the rename made old.
|
||
"""
|
||
changes = 0
|
||
|
||
def title_for() -> str:
|
||
"""``Feature — subject``. The openness and the hash are in the summary line instead."""
|
||
return f"Channel — {slot.name}"
|
||
|
||
async def reread() -> bool:
|
||
"""Point ``slot`` at what occupies its index now. ``False`` if nothing does."""
|
||
nonlocal slot
|
||
fresh = next((s for s in await ctx.devstate.channel_slots() if s.idx == slot.idx), None)
|
||
if fresh is None:
|
||
return False
|
||
slot = fresh
|
||
return True
|
||
|
||
async def handle(choice: object) -> int | None:
|
||
"""Run one action.
|
||
|
||
An int closes the detail screen with that number of changes. ``None`` stays.
|
||
"""
|
||
nonlocal changes
|
||
if choice is None: # Esc
|
||
return changes
|
||
if choice == _QR:
|
||
await _show_share(ctx, slot.name, slot.secret)
|
||
elif choice == _KEY:
|
||
await _show_key(ctx, slot)
|
||
elif choice == _CHAT:
|
||
await _open_chat(ctx, slot)
|
||
elif choice == _MUTE:
|
||
# This is a preference and not a change of the slot config. Change the toggle in
|
||
# place and go back to the detail screen (its rows render again to the new
|
||
# state). Do not count it as a channel change.
|
||
_toggle_mute(ctx, slot)
|
||
elif choice == _SCOPE:
|
||
# MeshTerm keeps it, as it keeps the mute. Nothing is written to the radio until
|
||
# a message is sent, so it is not a channel change either.
|
||
await _pick_scope(ctx, slot)
|
||
elif choice == _EDIT and await _edit(ctx, device, slot):
|
||
changes += 1
|
||
if not await reread(): # pragma: no cover - the slot that we just wrote is there
|
||
return changes
|
||
elif choice == _CLEAR and await _clear(ctx, device, slot):
|
||
return changes + 1 # the channel of this page is gone, and the page is gone too
|
||
return None
|
||
|
||
# A UI that has no session (the plain CLI, or scripted tests) has no stack to stay on.
|
||
# It runs the same dispatcher one round at a time, as the manager above does.
|
||
session = getattr(ctx.ui, "session", None)
|
||
if session is None:
|
||
while True:
|
||
result = await _menu_round(ctx, title_for(), _detail_items(ctx, slot), handle=handle)
|
||
if result is not None:
|
||
return result
|
||
|
||
menu = SelectScreen(
|
||
title_for(),
|
||
_detail_items(ctx, slot),
|
||
prompt=_detail_summary(ctx, slot, stats),
|
||
)
|
||
async with session.stay(menu) as visit:
|
||
while True:
|
||
choice = await visit.result()
|
||
result = await handle(None if choice is CANCEL else choice)
|
||
if result is not None:
|
||
return result
|
||
menu.replace_items(
|
||
_detail_items(ctx, slot),
|
||
title=title_for(),
|
||
prompt=_detail_summary(ctx, slot, stats),
|
||
)
|
||
|
||
|
||
# --- send scope ----------------------------------------------------------------
|
||
|
||
|
||
def _channel_scope(ctx: AppContext, slot: ChannelSlot) -> str | None:
|
||
"""The region for the messages of this channel, or ``None`` for the device default."""
|
||
store = getattr(ctx, "region_store", None)
|
||
return store.channel_scope(slot.identity) if store is not None else None
|
||
|
||
|
||
def _scope_row(ctx: AppContext, slot: ChannelSlot, lane: int) -> tuple:
|
||
"""The *Send scope…* row of the detail page. Its description says what is set now."""
|
||
scope = _channel_scope(ctx, slot)
|
||
description = f"Messages flood in {scope} only" if scope else "Keep messages in one region"
|
||
return (marked_label("🔖", "Send scope…", "", lane=lane), description, _SCOPE)
|
||
|
||
|
||
def _scope_items(ctx: AppContext, current: str | None) -> list:
|
||
"""The rows of the scope picker: no scope, each known region, then a row to type one.
|
||
|
||
A region is shown in the ``scope`` style (a region is not a node, so it has no node
|
||
hue). The row also shows the number of repeaters that were heard to carry the region.
|
||
This number decides if a message that has that scope goes anywhere. The scope that is
|
||
now in use is marked ``current``.
|
||
"""
|
||
store = getattr(ctx, "region_store", None)
|
||
none_row = Text("No scope — the device default")
|
||
if not current:
|
||
none_row.append(" · current", style="muted")
|
||
items: list = [Choice(title=none_row, value=_NO_SCOPE)]
|
||
for name in store.names() if store is not None else []:
|
||
row = Text(name, style="scope")
|
||
carriers = len(store.carriers(name))
|
||
if carriers:
|
||
row.append(f" · {carriers} repeater{'s' if carriers != 1 else ''}", style="muted")
|
||
if name == current:
|
||
row.append(" · current", style="muted")
|
||
items.append(Choice(title=row, value=name))
|
||
items.append(Choice(title=Text("Type a region name…"), value=_TYPE_REGION))
|
||
return items
|
||
|
||
|
||
async def _pick_scope(ctx: AppContext, slot: ChannelSlot) -> bool:
|
||
"""Select the region in which the messages of a channel are sent. Return whether it changed.
|
||
|
||
This is a value picker that floats over the detail page. It opens on the scope that is
|
||
now in use. To type a new name is a second step on top of the first (the ``run_steps``
|
||
shape). Esc on the name field goes back to the list and does not abandon the selection.
|
||
The code sets a typed name and also learns it (source ``typed``), so that the next
|
||
channel can select it from the list.
|
||
|
||
Args:
|
||
ctx: The shared application context.
|
||
slot: The channel for which the user sets the scope.
|
||
|
||
Returns:
|
||
``True`` if the scope of the channel changed.
|
||
"""
|
||
store = getattr(ctx, "region_store", None)
|
||
if store is None: # pragma: no cover - every real context builds one
|
||
return False
|
||
current = store.channel_scope(slot.identity)
|
||
session = getattr(ctx.ui, "session", None)
|
||
while True:
|
||
items = _scope_items(ctx, current)
|
||
title = f"Send scope — {slot.name}"
|
||
prompt = "The region this channel's messages flood into:"
|
||
default = current or _NO_SCOPE
|
||
if session is not None:
|
||
picked = await session.select(
|
||
title, items, prompt=prompt, default=default, footer_hint=_SCOPE_HINT
|
||
)
|
||
else:
|
||
picked = await ctx.ui.select(title, items, prompt=prompt, default=default)
|
||
if picked is None:
|
||
return False
|
||
if picked == _TYPE_REGION:
|
||
typed = await ctx.ui.text(
|
||
"Send scope",
|
||
prompt="Region name, as its repeaters list it:",
|
||
default=current or "",
|
||
validate=_valid_region,
|
||
)
|
||
if typed is None:
|
||
continue # Esc on the name goes back to the list
|
||
picked = store.learn(typed, "typed") or typed
|
||
wanted = None if picked == _NO_SCOPE else str(picked)
|
||
if wanted == current:
|
||
return False
|
||
store.set_channel_scope(slot.identity, wanted)
|
||
return True
|
||
|
||
|
||
def _valid_region(text: str) -> bool | str:
|
||
"""Make sure that the firmware can hold the region name (refer to :func:`regions.validate`)."""
|
||
try:
|
||
validate_region(text)
|
||
except RegionNameError as exc:
|
||
return str(exc)
|
||
return True
|
||
|
||
|
||
# --- create / join flows -----------------------------------------------------
|
||
|
||
|
||
async def _create_private(
|
||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||
) -> int:
|
||
"""Create a private channel with a new random key on the next free slot."""
|
||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||
if idx is None:
|
||
return 0
|
||
name = await ctx.ui.text("Channel name:", validate=_nonblank)
|
||
if not name:
|
||
return 0
|
||
secret = random_secret()
|
||
await write_channel(ctx, device, idx, name.strip(), secret)
|
||
await _show_share(ctx, name.strip(), secret)
|
||
return 1
|
||
|
||
|
||
async def _add_default_public(
|
||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||
) -> int:
|
||
"""Add the ``Public`` channel of MeshCore, which has a fixed key, on the next free slot.
|
||
|
||
It has one well-known secret. Thus it is the same channel on each slot, and a second
|
||
copy is not a channel but a duplicate row. The menu already removes this action when a
|
||
slot has the channel. This is the same check at the place where the action *runs*. It is
|
||
for a key press that was on its way while the first write was still in progress.
|
||
"""
|
||
if any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
|
||
return 0
|
||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||
if idx is None:
|
||
return 0
|
||
await write_channel(ctx, device, idx, "Public", DEFAULT_PUBLIC_SECRET)
|
||
return 1
|
||
|
||
|
||
async def _add_public(
|
||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||
) -> int:
|
||
"""Create a public channel. The key comes from its name, which starts with ``#``."""
|
||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||
if idx is None:
|
||
return 0
|
||
raw = await ctx.ui.text(
|
||
"Public channel name:",
|
||
help_text="A leading # is added automatically; the key is derived from the name",
|
||
validate=_nonblank,
|
||
)
|
||
if not raw:
|
||
return 0
|
||
name = raw.strip()
|
||
if not name.startswith("#"):
|
||
name = f"#{name}"
|
||
secret = derive_secret(name) # what the firmware will calculate. It is for the QR and share
|
||
await write_channel(ctx, device, idx, name, None) # None: the firmware derives the key
|
||
await _show_share(ctx, name, secret)
|
||
return 1
|
||
|
||
|
||
async def _join_with_key(
|
||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||
) -> int:
|
||
"""Join an existing private channel. The user enters its name and its key of 16 bytes.
|
||
|
||
The two prompts are a stack (:func:`~meshterm.ui.menus.run_steps`). Esc on the key goes
|
||
back to the name, and the text that the user typed is still in the field. The code does
|
||
not remove both answers. A key of 32 hex digits is long, and it is easy to type it
|
||
wrong.
|
||
"""
|
||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||
if idx is None:
|
||
return 0
|
||
answers = await run_steps(
|
||
[
|
||
lambda vals: ctx.ui.text("Channel name:", default=vals[0] or "", validate=_nonblank),
|
||
lambda vals: ctx.ui.text(
|
||
"Channel key (32 hex characters / 16 bytes):",
|
||
default=vals[1] or "",
|
||
validate=_valid_secret,
|
||
),
|
||
]
|
||
)
|
||
if answers is None:
|
||
return 0
|
||
name, key = answers
|
||
secret = normalize_secret(key)
|
||
await write_channel(ctx, device, idx, name.strip(), secret)
|
||
return 1
|
||
|
||
|
||
async def _import_link(
|
||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||
) -> int:
|
||
"""Import a channel from a pasted ``meshcore://channel/add`` link."""
|
||
idx = await _pick_free_slot(ctx, device, slots, capacity)
|
||
if idx is None:
|
||
return 0
|
||
url = await ctx.ui.text("Paste a meshcore:// channel link:", validate=_valid_link)
|
||
if not url:
|
||
return 0
|
||
parsed = parse_share_url(url)
|
||
if parsed is None: # pragma: no cover - the validator prevents this
|
||
await _say(ctx, "not a valid channel link", "err")
|
||
return 0
|
||
name, secret = parsed
|
||
await write_channel(ctx, device, idx, name, secret)
|
||
return 1
|
||
|
||
|
||
async def _edit(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
|
||
"""Rename an existing channel, change its key, or do both. Return whether it changed.
|
||
|
||
The prompts are the name, then the key, as a stack (:func:`~meshterm.ui.menus.run_steps`).
|
||
Esc on the key goes back to the name, and the code does not remove the rename. Each step
|
||
opens with the last answer that it got. The first time, this is the current value of the
|
||
slot. When the user comes back to a step, this is the text that the user typed. A blank
|
||
key that the user committed is included, because blank means *derive the key from the
|
||
name*.
|
||
"""
|
||
answers = await run_steps(
|
||
[
|
||
lambda vals: ctx.ui.text(
|
||
"Channel name:",
|
||
default=slot.name if vals[0] is None else vals[0],
|
||
validate=_nonblank,
|
||
),
|
||
lambda vals: ctx.ui.text(
|
||
"Channel key (32 hex chars; blank to derive from the name):",
|
||
default=(
|
||
("" if slot.is_name_derived else slot.secret.hex())
|
||
if vals[1] is None
|
||
else vals[1]
|
||
),
|
||
validate=_optional_secret,
|
||
),
|
||
]
|
||
)
|
||
if answers is None:
|
||
return False
|
||
name, key = answers
|
||
name = name.strip()
|
||
secret = normalize_secret(key) if key.strip() else None
|
||
await write_channel(ctx, device, slot.idx, name, secret)
|
||
return True
|
||
|
||
|
||
async def _clear(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
|
||
"""Clear a channel slot after a confirmation. Return whether it was cleared.
|
||
|
||
The confirmation is a red dialog for a loss of data. Cancel is on the left, and the verb
|
||
is on the right and is the default. It has the ``destructive`` colours, because the
|
||
clear removes the key of the slot. It is not a bare yes/no dialog. Thus it looks like
|
||
each other delete confirm.
|
||
"""
|
||
choice = await ctx.ui.dialog(
|
||
f"Clear {slot.name}? This removes the channel from this device.",
|
||
[("Cancel", None), ("Clear", "clear")],
|
||
title="Clear channel",
|
||
default=1,
|
||
destructive=True,
|
||
)
|
||
if choice != "clear":
|
||
return False
|
||
ctx.chat.set_active(slot.conversation.key) # remove its unread count before the slot is gone
|
||
ctx.chat.set_active(None)
|
||
await write_channel(ctx, device, slot.idx, "", None) # an empty name: the slot is unused
|
||
return True
|
||
|
||
|
||
# --- reordering --------------------------------------------------------------
|
||
|
||
|
||
async def _write_slot(ctx: AppContext, device: Device, idx: int, slot: ChannelSlot) -> None:
|
||
"""Write the contents of ``slot`` into slot ``idx``.
|
||
|
||
A channel whose key comes from its name derives the key again.
|
||
"""
|
||
secret = None if slot.is_name_derived else slot.secret
|
||
await write_channel(ctx, device, idx, slot.name, secret)
|
||
|
||
|
||
async def _apply_order(
|
||
ctx: AppContext, device: Device, slots: list[ChannelSlot], order: list[int]
|
||
) -> int:
|
||
"""Write the channel slots again so that they show in ``order``. Return the writes made.
|
||
|
||
``slots`` is the current list in the order of the slot index. ``order`` is a
|
||
permutation of its positions (the reorder screen returns it). The code puts the
|
||
channels again in the same physical slot indices, lowest first. Thus their order on the
|
||
device is the same as the new display order. The code writes only the slots whose
|
||
occupant changes. The reads come from the snapshot in memory. Thus the writes between
|
||
them never overwrite a channel that is not placed yet.
|
||
"""
|
||
indices = sorted(s.idx for s in slots) # the physical slots to fill, ascending
|
||
moves = [
|
||
(target_idx, slots[pos])
|
||
for target_idx, pos in zip(indices, order, strict=True)
|
||
if slots[pos].idx != target_idx # already in place, so no write is necessary
|
||
]
|
||
if not moves:
|
||
return 0
|
||
# One card for the whole batch. Its title changes as the batch advances. This is the
|
||
# longest operation of the manager: one write for each channel that moves, and each
|
||
# write is a round trip to the device. It is the one place where a count is useful,
|
||
# because the user can see that the operation advances and is not stuck. The cards of
|
||
# the single writes are nested in this card.
|
||
async with ctx.ui.busy_dialog("reordering channels…", title="Channels") as busy:
|
||
for position, (target_idx, slot) in enumerate(moves, start=1):
|
||
busy.message = f"moving {slot.name} · {position}/{len(moves)}"
|
||
await _write_slot(ctx, device, target_idx, slot)
|
||
return len(moves)
|
||
|
||
|
||
async def _reorder_channels(ctx: AppContext, device: Device, slots: list[ChannelSlot]) -> int:
|
||
"""Let the user move channels into a new order with the arrows. Return the writes made."""
|
||
if len(slots) < 2: # pragma: no cover - the menu offers reorder only with 2 or more channels
|
||
return 0
|
||
labels = [_slot_label(slot) for slot in slots]
|
||
order = await ctx.ui.reorder("Reorder channels", labels)
|
||
if not order or order == list(range(len(slots))):
|
||
return 0 # the user cancelled, or the order did not change
|
||
# The key of the chat history is the intrinsic identity of each channel and not its
|
||
# slot. Thus the history follows the channels automatically, and a reorder needs no
|
||
# migration of the history. The cache from slot to identity, which the chat service uses
|
||
# to resolve inbound messages, is refreshed in one place by manage_channels when any
|
||
# change arrives (refer to :func:`_refresh_chat_channels`).
|
||
try:
|
||
return await _apply_order(ctx, device, slots, order)
|
||
except Exception as exc: # noqa: BLE001 - reported here, where the half-done state is known
|
||
# A reorder is a sequence of writes with no transaction under it. If the link drops
|
||
# in the middle, the slots are between the two orders. The code must say this
|
||
# plainly and at once. The manager reads again when this round ends, so the user will
|
||
# look at the half-applied layout, and nothing else tells the user that.
|
||
ctx.log.debug("channels: reorder stopped partway: %s", exc)
|
||
await _say(ctx, f"reorder stopped partway — {exc}", "err")
|
||
return 0
|
||
|
||
|
||
# --- export and import ---------------------------------------------------------
|
||
|
||
#: The file that the export and import prompts offer. The user can type another path.
|
||
_CHANNEL_FILE = "meshterm-channels.toml"
|
||
|
||
|
||
async def _export_channels(ctx: AppContext, slots: list[ChannelSlot]) -> None:
|
||
"""Write the channels of the device to a file, so that another device can import them.
|
||
|
||
Each channel goes into the file with its key, its send scope, and its mute, in slot
|
||
order (refer to :mod:`meshterm.core.channel_file`). The export writes nothing to the
|
||
device, so the list does not change. Thus a dialog says what the export wrote and where,
|
||
because nothing else on the screen shows that the file exists.
|
||
"""
|
||
raw = await ctx.ui.path(
|
||
"Export channels", prompt="Write the channels to this file:", default=_CHANNEL_FILE
|
||
)
|
||
if not raw:
|
||
return
|
||
path = Path(raw).expanduser().resolve()
|
||
entries = [
|
||
entry_from_slot(slot, scope=_channel_scope(ctx, slot), muted=_is_muted(ctx, slot))
|
||
for slot in slots
|
||
]
|
||
try:
|
||
write_channel_file(path, entries)
|
||
except OSError as exc:
|
||
await _say(ctx, f"could not write {path} — {exc.strerror or exc}", "err")
|
||
return
|
||
private = sum(1 for slot in slots if not slot.is_public)
|
||
text = f"{_count(len(entries), 'channel')} written to {path}"
|
||
if private:
|
||
text += f". It holds the keys of {_count(private, 'private channel')}: keep it private."
|
||
await _say(ctx, text, "ok")
|
||
|
||
|
||
async def _import_channels(ctx: AppContext, device: Device, capacity: int) -> int:
|
||
"""Add the channels of an exported file to the device, in the order of the file.
|
||
|
||
The import reads all the slots again before it plans. The plan writes many slots, and a
|
||
probe that stopped early makes a slot that holds a channel look free (refer to
|
||
:func:`_pick_free_slot` for the same reason on a single add). Then a dialog shows the
|
||
plan, and nothing is written until the user selects Import. A file that has nothing new
|
||
for the device still gives its send scopes and mutes.
|
||
|
||
Returns:
|
||
The number of slot writes that the import made.
|
||
"""
|
||
raw = await ctx.ui.path(
|
||
"Import channels", prompt="Read the channels from this file:", default=_CHANNEL_FILE
|
||
)
|
||
if not raw:
|
||
return 0
|
||
path = Path(raw).expanduser()
|
||
try:
|
||
entries = read_channel_file(path)
|
||
except ChannelFileError as exc:
|
||
await _say(ctx, str(exc), "err")
|
||
return 0
|
||
async with ctx.ui.busy_dialog("reading channels…", title="Channels"):
|
||
slots, complete = await probe_channel_slots(device)
|
||
if not complete:
|
||
await _say(ctx, "could not read every slot — nothing was written", "err")
|
||
return 0
|
||
plan = plan_import(entries, slots, capacity)
|
||
kept = list(plan.present) + [entry for _, entry in plan.added]
|
||
if not plan.changes_device:
|
||
apply_preferences(kept, ctx.region_store, ctx.mute_store)
|
||
if plan.skipped:
|
||
await _say(ctx, "every slot is full — clear a channel, then import again", "warn")
|
||
else:
|
||
text = f"this device already has the {_count(len(entries), 'channel')} of {path.name}"
|
||
await _say(ctx, text, "ok")
|
||
return 0
|
||
choice = await ctx.ui.dialog(
|
||
_plan_text(path, entries, plan),
|
||
[("Cancel", None), ("Import", "import")],
|
||
title="Import channels",
|
||
default=1,
|
||
)
|
||
if choice != "import":
|
||
return 0
|
||
done = 0
|
||
try:
|
||
# One card for the whole import, as for a reorder. The card of each write is nested
|
||
# in it, and the count shows that the import advances.
|
||
async with ctx.ui.busy_dialog("importing channels…", title="Channels") as busy:
|
||
for position, (idx, name, secret) in enumerate(plan.writes, start=1):
|
||
what = f"saving {name}" if name else f"clearing slot {idx}"
|
||
busy.message = f"{what} · {position}/{len(plan.writes)}"
|
||
await write_channel(ctx, device, idx, name, secret)
|
||
done += 1
|
||
except Exception as exc: # noqa: BLE001 - reported here, where the half-done state is known
|
||
# The same case as a reorder that stops: the writes have no transaction, so the
|
||
# slots are between the two layouts. Say so at once. The list reads the slots again
|
||
# when this round ends.
|
||
ctx.log.debug("channels: import stopped partway: %s", exc)
|
||
await _say(ctx, f"import stopped partway — {exc}", "err")
|
||
return done
|
||
apply_preferences(kept, ctx.region_store, ctx.mute_store)
|
||
if plan.skipped:
|
||
names = ", ".join(entry.name for entry in plan.skipped)
|
||
await _say(ctx, f"no free slot for {names} — clear a channel, then import again", "warn")
|
||
return done
|
||
|
||
|
||
def _plan_text(path: Path, entries: list, plan: ImportPlan) -> Text:
|
||
"""The body of the import dialog: what the import adds, moves, and cannot add.
|
||
|
||
The last line says that nothing is removed. A user who imports onto a device that has
|
||
channels of its own must know that before Import, and not find it after.
|
||
"""
|
||
width = max((cell_len(entry.name) for _, entry in plan.added), default=0)
|
||
text = Text(f"{path.name} has {_count(len(entries), 'channel')}.\n")
|
||
for slot, entry in plan.added:
|
||
text.append(f" + {channel_glyph(entry.name, entry.key)} ")
|
||
text.append(entry.name.ljust(width), style="brand")
|
||
text.append(f" into slot {slot}\n", style="muted")
|
||
if plan.moved:
|
||
moved = _count(plan.moved, "channel")
|
||
text.append(f" ↕ {moved} on this device change slot, to match the file\n")
|
||
if plan.skipped:
|
||
names = ", ".join(entry.name for entry in plan.skipped)
|
||
text.append(f" ⚠ no free slot for {names}\n", style="warn")
|
||
if plan.present:
|
||
text.append(f" {_count(len(plan.present), 'channel')} already on this device\n")
|
||
text.append("Nothing is removed from the device.", style="muted")
|
||
return text
|
||
|
||
|
||
def _count(n: int, noun: str) -> str:
|
||
"""``n`` and the noun, plural when ``n`` is not 1: ``1 channel``, ``3 channels``."""
|
||
return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
|
||
|
||
|
||
# --- shared views ------------------------------------------------------------
|
||
|
||
|
||
async def _show_share(ctx: AppContext, name: str, secret: bytes) -> None:
|
||
"""Show the QR code of the channel and its share URL, in the full frame.
|
||
|
||
Refer to ``share_screen``.
|
||
"""
|
||
await share_screen(ctx, name=name, url=share_url(name, secret))
|
||
|
||
|
||
async def _show_key(ctx: AppContext, slot: ChannelSlot) -> None:
|
||
"""Show the name, type, hash, key, and share link of a channel as blocks with labels.
|
||
|
||
This is not a table, on purpose. A grid with borders in a dialog is visual noise, and it
|
||
wastes the columns that the values need. Each field is a muted label in the style of a
|
||
column header, and its value is on the line under it. The long values (the hash, the
|
||
key, and the link are for the user to copy completely) *wrap* to the width of the
|
||
dialog (``overflow="fold"``, because they are single words that cannot break). They are
|
||
not cut at an ellipsis. The first byte of the hash keeps the brand highlight. It is the
|
||
fingerprint of two characters that the MeshCore companion app reports.
|
||
"""
|
||
if slot.is_name_derived:
|
||
kind = "public (key from name)"
|
||
elif slot.is_public:
|
||
kind = "public (default channel)"
|
||
else:
|
||
kind = "private"
|
||
hash_text = Text(overflow="fold")
|
||
hash_text.append(slot.full_hash[:2], style="brand")
|
||
hash_text.append(slot.full_hash[2:], style="muted")
|
||
fields: list[tuple[str, Text]] = [
|
||
("NAME", Text(slot.name, style="brand", overflow="fold")),
|
||
("TYPE", Text(kind)),
|
||
("HASH", hash_text),
|
||
("KEY", Text(slot.secret.hex(), style="warn", overflow="fold")),
|
||
("LINK", Text(share_url(slot.name, slot.secret), style="accent", overflow="fold")),
|
||
]
|
||
blocks: list[Text] = []
|
||
for label, value in fields:
|
||
if blocks:
|
||
blocks.append(Text())
|
||
blocks.append(Text(label, style="muted"))
|
||
blocks.append(value)
|
||
await ctx.ui.view(Group(*blocks), title=f"Key — {slot.name}", footer_hint="Esc close")
|
||
|
||
|
||
async def _open_chat(ctx: AppContext, slot: ChannelSlot) -> None:
|
||
"""Open this channel on the live chat screen."""
|
||
from .chat import open_chat
|
||
|
||
await open_chat(ctx, slot.conversation)
|
||
|
||
|
||
async def _pick_free_slot(
|
||
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
|
||
) -> int | None:
|
||
"""Return a slot that is *confirmed* empty. If there is none, say why and return ``None``.
|
||
|
||
The list that this function selects from is a snapshot, and a snapshot can be different
|
||
from the truth. A slot probe that failed in the middle returns the slots that it could
|
||
read. Then a slot that has a channel that the probe did not reach looks free. Each add
|
||
flow comes here, and an unconditional write follows. Thus the function reads the slot one
|
||
more time before it gives the slot to the caller. The code must not overwrite a channel
|
||
because of a list that can have a missing row.
|
||
|
||
Args:
|
||
ctx: The shared application context (for the message dialogs).
|
||
device: The connected device, for the confirming read.
|
||
slots: The slots as they were last read.
|
||
capacity: The number of slots of the device.
|
||
|
||
Returns:
|
||
The index of a free slot, or ``None`` if there is no slot to give.
|
||
"""
|
||
idx = _next_free_slot(slots, capacity)
|
||
if idx is None:
|
||
await _say(ctx, "all channel slots are full — clear one first, then try again", "warn")
|
||
return None
|
||
try:
|
||
async with ctx.ui.busy_dialog(f"checking slot {idx}…", title="Channels"):
|
||
occupant = await device.get_channel(idx)
|
||
except Exception as exc: # noqa: BLE001 - a slot that the code cannot read is not proved free
|
||
ctx.log.debug("channels: could not confirm slot %s is free: %s", idx, exc)
|
||
await _say(ctx, f"could not read slot {idx} — nothing was written", "err")
|
||
return None
|
||
if occupant and occupant.get("channel_name"):
|
||
await _say(ctx, f"slot {idx} is in use — reopen Channels to see what is there", "err")
|
||
return None
|
||
return idx
|
||
|
||
|
||
async def _say(ctx: AppContext, text: str, style: str) -> None:
|
||
"""Tell the user something *now*, in a dialog over the manager where it happened.
|
||
|
||
An outcome during the visit is not a tool result. It belongs to the action in front of
|
||
the user. Thus it goes to a dialog and not into the buffer that the menu empties when the
|
||
whole tool has finished. A failed import once stayed in that buffer.
|
||
"""
|
||
mark = {"err": "✗", "warn": "⚠", "ok": "✓"}.get(style, "")
|
||
body = Text(f"{mark} {text}" if mark else text, style=style)
|
||
session = getattr(ctx.ui, "session", None)
|
||
if session is None: # the scripted CLI has no dialogs, so print the text
|
||
ctx.ui.note(f"[{style}]{body.plain}[/{style}]")
|
||
return
|
||
await session.message_dialog(body, title="Channels")
|
||
|
||
|
||
# --- validators --------------------------------------------------------------
|
||
|
||
|
||
def _nonblank(text: str) -> bool | str:
|
||
"""Make sure that the name is not empty."""
|
||
return bool(text.strip()) or "Enter a channel name."
|
||
|
||
|
||
def _valid_secret(text: str) -> bool | str:
|
||
"""Make sure that the text is a valid key of 16 bytes in hex."""
|
||
try:
|
||
normalize_secret(text)
|
||
return True
|
||
except ValueError as exc:
|
||
return str(exc)
|
||
|
||
|
||
def _optional_secret(text: str) -> bool | str:
|
||
"""Accept a blank key (derive it from the name) or a valid key of 16 bytes in hex."""
|
||
return True if not text.strip() else _valid_secret(text)
|
||
|
||
|
||
def _valid_link(text: str) -> bool | str:
|
||
"""Make sure that the text is a ``meshcore://channel/add`` link that the code can parse."""
|
||
return parse_share_url(text) is not None or "Not a valid meshcore:// channel link."
|