Files
MeshTerm/meshterm/ui/channels.py
T
Jean-Pierre MartineauandClaude Fable 5 b27af22f36 Header shows the node, its port, unread mail, and a mesh pulse
The status bar drops the static "device:" prefix and the heard
counters. The device segment now leads with the node's own mesh name —
the remembered registry name, then the profile alias, then the
hardware name — with where it's reached in parentheses (the COM port,
or BLE), cached per connection so repaints never re-read the registry
file.

The heard numbers give way to an unread badge (channels and direct,
shown only when something waits) and a two-hour braille activity
indicator: every packet the hub hears — adverts, telemetry, messages,
acks — lands in a five-minute bucket, drawn twelve characters wide at
1/5/21/89 thresholds with the ●/○ live-light in front. The braille
sparkline renderer moves from the channel manager to widgets so both
draw with one brush.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:23:32 -04:00

831 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The interactive channel manager, rendered in the full-screen session.
This is the user-facing channel-management experience for the ``channels`` tool: a live
list of the device's channel slots with, for each, a detail view that shows the sharable
QR code and key, renames or re-keys it, opens it in chat, or clears it. New channels are
created four ways — a fresh private channel (random key), a public ``#`` channel (key
derived from the name), joining by pasting a key, or importing a scanned ``meshcore://``
link. Every change is written to the device immediately (like a phone app), so the list you
see always reflects the radio.
The list is laid out like the config editor: fixed, column-aligned lanes under one header
line — name, openness, hash fingerprint, unread badge, total messages, last-message age,
and a braille sparkline of the trailing two hours' traffic — so a glance shows not just
*which* channels exist but which ones are alive. The message statistics come from
:meth:`~meshterm.persistence.repository.Repository.channel_stats` (read through a small
TTL cache) and the unread counts from the live chat service, and each row is a callable
title re-resolved on repaint, so a message arriving while the list sits open updates its
row in place — the same trick the conversation picker uses. The menus also follow the
config editor's persistent-backdrop pattern: the list stays pushed while every sub-prompt
floats over it as a modal popup, rather than replacing the screen.
The module sits in the UI layer but, like the config editor and chat screen, is allowed to
depend on the context and services; it owns no persistence of its own.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
from rich.cells import cell_len
from rich.console import Group
from rich.table import Table
from rich.text import Text
from ..core.channels import (
CHANNEL_SLOT_PROBE_CAP,
MAX_CHANNELS,
channel_hash,
channel_identity,
derive_secret,
full_channel_hash,
is_name_derived,
is_public_channel,
normalize_secret,
parse_share_url,
random_secret,
share_url,
)
from ..core.connection import Device
from ..core.models import Conversation
from ..persistence.repository import ACTIVITY_BUCKETS
from .qr import qr_text
from .tui import CANCEL, Choice, SelectScreen, Separator
from .widgets import _age_seconds, _format_age, activity_sparkline, channel_glyph
if TYPE_CHECKING:
from ..context import AppContext
from ..persistence.repository import ChannelStats
# Top-menu action sentinels (distinct from a plain slot index, which selects that channel).
_CREATE = "__create__"
_PUBLIC = "__public__"
_JOIN = "__join__"
_IMPORT = "__import__"
_REORDER = "__reorder__"
_BACK = "__back__"
# Channel-detail action sentinels.
_QR = "qr"
_KEY = "key"
_CHAT = "chat"
_EDIT = "edit"
_CLEAR = "clear"
@dataclass(slots=True)
class ChannelSlot:
"""One configured channel slot read back from the device.
Attributes:
idx: The 0-based slot index.
name: The channel's name.
secret: The channel's 16-byte shared secret.
"""
idx: int
name: str
secret: bytes
@property
def is_name_derived(self) -> bool:
"""Whether this channel's key is reproducible from its name (so it needn't be stored)."""
return is_name_derived(self.name, self.secret)
@property
def is_public(self) -> bool:
"""Whether this channel is public (shared meshwide) rather than a private one.
Covers both name-derived channels and the firmware's fixed-key default ``Public``.
"""
return is_public_channel(self.name, self.secret)
@property
def hash(self) -> str:
"""The channel's two-character hash fingerprint (the leading byte MeshCore shows)."""
return channel_hash(self.secret)
@property
def full_hash(self) -> str:
"""The channel's complete ``sha256(secret)`` digest; its first byte is :attr:`hash`."""
return full_channel_hash(self.secret)
@property
def identity(self) -> str:
"""The channel's slot-independent identity, used to key its chat history."""
return channel_identity(self.name, self.secret)
@property
def conversation(self) -> Conversation:
"""A :class:`~meshterm.core.models.Conversation` for opening this channel in chat."""
return Conversation(
label=self.name,
is_channel=True,
channel_idx=self.idx,
channel_id=self.identity,
secret=self.secret,
)
async def manage_channels(ctx: "AppContext") -> int:
"""Run the interactive channel manager until the user backs out.
Args:
ctx: Shared application context (provides the connected device and UI surface).
Returns:
The number of channels created, changed, or cleared during the session.
"""
device = await ctx.device()
# The firmware's slot count is fixed for the session, so discover it once (a read-only
# probe) rather than assuming a hard-coded 8; it drives the free-slot check and the
# used/total display below. A device that can't report any slots falls back to the
# standard count so the manager stays usable instead of showing zero capacity.
capacity = await device.channel_capacity() or MAX_CHANNELS
stats = _LiveStats(ctx)
changes = 0
highlight: Optional[object] = None
while True:
slots = await _read_slots(device)
title, items = _menu_items(ctx, slots, capacity, stats)
async def handle(choice: object) -> bool:
"""Dispatch one menu choice (over the still-pushed list); ``False`` exits."""
nonlocal changes, highlight
if choice in (None, _BACK):
return False
highlight = choice
before = changes
if choice == _CREATE:
changes += await _create_private(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)
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)
if changes > before:
# A slot's occupant changed (created, re-keyed, cleared, or moved). Inbound
# messages carry only a slot index, which the chat service maps to a channel
# identity through a cache keyed by slot; refresh it now so a message on a
# reused/re-keyed slot is filed under the channel that's actually there and
# not the one that used to be — otherwise its transcript surfaces in the
# wrong chat.
await _refresh_chat_channels(ctx)
return True
if not await _menu_round(ctx, title, items, default=highlight, handle=handle):
return changes
# unreachable
async def _menu_round(
ctx: "AppContext",
title: str,
items: list,
*,
handle: Callable[[object], Awaitable],
default: object = None,
prompt: str = "",
) -> object:
"""Show one round of a channels menu and dispatch the choice over it.
In the full-screen session the menu stays *pushed* while ``handle`` runs, so every
sub-prompt floats over the list as a modal popup with its own border — the config
editor's persistent-backdrop pattern (see
:func:`~meshterm.ui.config_editor.edit_config`) — instead of replacing the screen. A
surface without a session (the plain CLI surface, scripted tests) simply selects and
then dispatches; ``prompt`` is a session-only nicety and is dropped there.
Args:
ctx: Shared application context.
title: The menu's border heading.
items: The menu's :class:`Choice`/:class:`Separator` rows.
handle: Async dispatcher awaited with the chosen value (``None`` for Esc).
default: A choice value to re-highlight, so the menu reopens where it was left.
prompt: An optional summary line drawn inside the box above the rows.
Returns:
Whatever ``handle`` returns.
"""
session = getattr(ctx.ui, "session", None)
if session is None:
return await handle(await ctx.ui.select(title, items, default=default))
menu = SelectScreen(title, items, prompt=prompt, default=default, wrap=False)
menu.future = asyncio.get_running_loop().create_future()
session.push(menu)
try:
choice = await menu.future
return await handle(None if choice is CANCEL else choice)
finally:
session.pop(menu)
async def _refresh_chat_channels(ctx: "AppContext") -> None:
"""Rebuild the chat service's slot→identity cache after a channel mutation.
Best-effort: a device read hiccup here must never break the channel manager, and the
cache also self-heals on the next miss, so a failure is only logged.
"""
try:
await ctx.chat.refresh_channels()
except Exception as exc: # noqa: BLE001 - refresh is best-effort; never fatal here
ctx.log.debug("channels: chat cache refresh failed: %s", exc)
# --- reading -----------------------------------------------------------------
async def _read_slots(device: Device) -> list[ChannelSlot]:
"""Probe the channel slots and return the configured ones, in index order.
The scan runs up to :data:`CHANNEL_SLOT_PROBE_CAP` and stops as soon as the firmware
rejects a slot index, so it reads exactly the slots the device actually has regardless
of its capacity.
Args:
device: The connected device to query.
Returns:
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
"""
slots: list[ChannelSlot] = []
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
if payload and payload.get("channel_name"):
slots.append(
ChannelSlot(
idx=idx,
name=str(payload["channel_name"]),
secret=bytes(payload.get("channel_secret") or b"\x00" * 16),
)
)
return slots
def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> Optional[int]:
"""Return the lowest unused slot index, or ``None`` when every slot is full."""
used = {s.idx for s in slots}
return next((i for i in range(capacity) if i not in used), None)
def _slot_label(slot: ChannelSlot) -> str:
"""Format a channel for a compact row (the reorder screen): just its glyph and name.
The reorder screen is about *position*, not vitals — the openness, hash, and message
lanes of the manager list would only widen the popup — so each row is the channel
exactly as the manager's first lane shows it: glyph, gap, name.
"""
return f"{channel_glyph(slot.name, slot.secret)} {slot.name}"
# --- message statistics --------------------------------------------------------
class _LiveStats:
"""A self-refreshing view of every channel's stored-message statistics.
The conversation picker's ``_LiveLasts`` pattern applied to
:meth:`~meshterm.persistence.repository.Repository.channel_stats`: the list rows read
through this on every repaint (their titles are callables), so a message arriving while
the manager sits open updates that channel's counts, age, and sparkline in place —
but the repository is re-queried at most once per ``ttl`` seconds rather than once per
row per repaint, so a full slot table stays cheap at the session's ~1 Hz repaint.
"""
def __init__(self, ctx: "AppContext", *, ttl: float = 1.0) -> None:
"""Bind to a context; the first read populates the cache."""
self._ctx = ctx
self._ttl = ttl
self._cache: Optional[dict[str, "ChannelStats"]] = None
self._at = 0.0
def get(self, channel_id: str) -> Optional["ChannelStats"]:
"""Return the stats for one channel identity, refreshing once the TTL lapses."""
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 - keep the last good snapshot on a read error
self._cache = self._cache or {}
self._at = now
return self._cache.get(channel_id)
#: Widest the name lane grows (longer names are ellipsized so the lanes stay put).
_NAME_WIDTH_MAX = 18
#: Width of the openness lane (fits ``private``).
_TYPE_WIDTH = 7
#: Width of the hash lane (sized to its ``HASH`` header; the value itself is two chars).
_HASH_WIDTH = 4
#: Width of the unread-badge lane (fits ``● 999``), matching the conversation picker's.
_BADGE_WIDTH = 5
#: Width of the right-aligned total-messages lane.
_COUNT_WIDTH = 5
#: Width of the right-aligned last-message-age lane (fits ``never``-length ages).
_AGE_WIDTH = 5
#: Message counts a bucket must reach for each extra dot of bar height (Fibonacci-ish, so
#: each dot roughly means "a conversation tier up"): 1 message lights one dot, 3 light two,
#: 8 light three, and 21 or more max the column out.
_ACTIVITY_LEVELS = (1, 3, 8, 21)
def _activity_sparkline(histogram: "tuple[int, ...]") -> Text:
"""The channel's braille activity sparkline over the trailing two hours.
The shared :func:`~meshterm.ui.widgets.activity_sparkline`, drawn at this list's
message-count thresholds over the repository histogram's :data:`ACTIVITY_BUCKETS`
five-minute buckets.
"""
return activity_sparkline(histogram, _ACTIVITY_LEVELS, ACTIVITY_BUCKETS)
def _fit(text: str, width: int) -> str:
"""Left-justify ``text`` to ``width`` columns, ellipsizing anything that would overflow."""
if len(text) > width:
return text[: width - 1] + "…"
return text.ljust(width)
# --- menus -------------------------------------------------------------------
def _lanes_header(name_w: int) -> str:
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
The leading spaces cover the select screen's pointer column (2 cells) plus the glyph
lane (3 cells), so each header lands exactly over its column. ``UNREAD`` borrows its
lane's trailing gap — the badge lane itself is one cell too narrow for the word — which
still leaves a space before the message count.
"""
return (
" "
+ "CHANNEL".ljust(name_w + 2)
+ "TYPE".ljust(_TYPE_WIDTH + 2)
+ "HASH".ljust(_HASH_WIDTH + 2)
+ "UNREAD".ljust(_BADGE_WIDTH + 2)
+ f"{'MSGS':>{_COUNT_WIDTH}}"
+ " "
+ f"{'LAST':>{_AGE_WIDTH}}"
+ " ACTIVITY"
)
def _slot_row(
ctx: "AppContext", slot: ChannelSlot, stats: _LiveStats, name_w: int
) -> Callable[[], Text]:
"""Return a list-row title *callable* the select screen re-renders on each repaint.
The unread badge, counts, age, and activity sparkline are all read live (see
:class:`_LiveStats`), so a message arriving while the list sits open updates the row on
the next repaint — exactly the conversation picker's behavior.
"""
return lambda: _slot_text(ctx, slot, stats, name_w)
def _slot_text(
ctx: "AppContext", slot: ChannelSlot, stats: _LiveStats, name_w: int
) -> Text:
"""Build one channel's list row as fixed-width, colour-coded lanes.
Alignment carries the readability — glyph, name, openness, hash, unread badge, total
messages, last-message age, and the activity sparkline each sit in their own lane under
the :func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's
focus in the base colour, the descriptive lanes are muted, the unread ``●`` badge is
red with its count in warn (the conversation picker's language), and the sparkline
draws in the ok green over a faint flatline. The row is always a Rich
:class:`~rich.text.Text` so those spans
survive under the select screen's row highlight.
"""
st = stats.get(slot.identity)
unread = ctx.chat.unread(slot.conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
text.append(_fit(slot.name, name_w))
text.append(" ")
text.append(("public" if slot.is_public else "private").ljust(_TYPE_WIDTH), style="muted")
text.append(" ")
text.append(slot.hash.ljust(_HASH_WIDTH), style="muted")
text.append(" ")
if unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
else:
text.append(" " * _BADGE_WIDTH)
text.append(" ")
total = st.total if st is not None else 0
if total:
# Clamped so a pathological backlog can't push the row out of its lanes.
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
else:
text.append(f"{'·':>{_COUNT_WIDTH}}", style="muted")
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(" ")
text.append_text(_activity_sparkline(st.histogram if st is not None else ()))
return text
def _menu_items(
ctx: "AppContext", slots: list[ChannelSlot], capacity: int, stats: _LiveStats
) -> tuple[str, list]:
"""Build the channel manager's title and rows for the current slot table.
Returns the ``(title, items)`` for one :func:`_menu_round`: the channel rows in their
aligned lanes under a column-header line (the config editor's presentation), then the
Organize and Add-a-channel action sections. The slot usage lives in the title, so the
header line is free to be pure column labels.
"""
items: list = []
if slots:
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
items.append(Separator(_lanes_header(name_w)))
for slot in slots:
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
else:
items.append(Separator(" (no channels configured yet)"))
if len(slots) > 1:
items.append(Separator("── Organize ──", style="accent"))
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
# label's text column-aligned with theirs.
items.append(Choice(title="↕ Reorder channels", value=_REORDER))
items.append(Separator("── Add a channel ──", style="accent"))
items.append(Choice(title="+ New private channel (random key)", value=_CREATE))
items.append(Choice(title="# Public channel (key from its name)", value=_PUBLIC))
items.append(Choice(title="🔑 Join a channel with its key", value=_JOIN))
items.append(Choice(title="🔗 Import a meshcore:// link", value=_IMPORT))
items.append(Separator(" "))
items.append(Choice(title="Back", value=_BACK))
return f"Channels — {len(slots)}/{capacity} slots", items
def _detail_summary(ctx: "AppContext", slot: ChannelSlot, stats: _LiveStats) -> str:
"""One line of vital signs for the detail screen: slot, totals, unread, last activity."""
st = stats.get(slot.identity)
unread = ctx.chat.unread(slot.conversation.key)
if st is None or not st.total:
return f"Slot {slot.idx} · no messages recorded yet"
parts = [f"Slot {slot.idx}", f"{st.total} message{'' if st.total == 1 else 's'}"]
if unread:
parts.append(f"{unread} unread")
if st.last_at is not None:
age = _format_age(_age_seconds(st.last_at))
parts.append("last message just now" if age == "now" else f"last message {age} ago")
return " · ".join(parts)
def _detail_items(ctx: "AppContext", slot: ChannelSlot) -> list:
"""Build the channel-detail rows: label and description in two aligned lanes.
The Device actions presentation (menu-style lanes, no header line — these are commands,
not tabular data), padded in display cells so the double-width emoji can't skew the
description column. Open-in-chat carries the channel's live unread badge, and the one
destructive row keeps an err-tinted label so it reads as such.
"""
unread = ctx.chat.unread(slot.conversation.key)
chat_label = Text("💬 Open in chat")
if unread:
chat_label.append(" ●", style="err")
chat_label.append(f" {unread}", style="warn")
rows: list[tuple[Text, str, object]] = [
(Text("📱 Show QR code"), "Share this channel as a scannable code", _QR),
(Text("🔑 Show key"), "The name, key, hash, and share link", _KEY),
(chat_label, "Read and send messages on this channel", _CHAT),
(Text("✎ Rename / change key"), "Edit the name or paste a different key", _EDIT),
(Text("🗑 Clear this slot", style="err"), "Remove the channel from this device", _CLEAR),
]
width = max(cell_len(label.plain) for label, _, _ in rows)
items: list = []
for label, help_text, value in rows:
row = Text()
row.append_text(label)
row.append(" " * (width - cell_len(label.plain) + 2))
row.append(help_text, style="muted")
items.append(Choice(title=row, value=value))
items.append(Separator(" "))
items.append(Choice(title="Back", value=_BACK))
return items
async def _channel_detail(
ctx: "AppContext", device: Device, slot: ChannelSlot, stats: _LiveStats
) -> int:
"""Show one channel's actions (QR, key, chat, rename, clear); return changes made.
The screen leads with the channel's vital signs (see :func:`_detail_summary`) above the
action rows, and — like the main list — stays pushed while each action's prompts float
over it. A rename/re-key or clear closes the detail (the slot's occupant changed, so
the caller re-reads the device); the read-only actions loop back here.
"""
kind = "public" if slot.is_public else "private"
title = f"{slot.name} ({kind}, hash {slot.hash})"
cursor: object = None
async def handle(choice: object) -> Optional[int]:
"""Run one action; an int closes the detail with that many changes, ``None`` stays."""
nonlocal cursor
if choice in (None, _BACK):
return 0
cursor = choice
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 == _EDIT and await _edit(ctx, device, slot):
return 1
elif choice == _CLEAR and await _clear(ctx, device, slot):
return 1
return None
while True:
result = await _menu_round(
ctx,
title,
_detail_items(ctx, slot),
default=cursor,
prompt=_detail_summary(ctx, slot, stats),
handle=handle,
)
if result is not None:
return result
# --- create / join flows -----------------------------------------------------
async def _create_private(
ctx: "AppContext", device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a private channel with a fresh random key on the next free slot."""
idx = await _pick_free_slot(ctx, 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 device.set_channel(idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] created private channel [brand]{name.strip()}[/brand]")
await _show_share(ctx, name.strip(), secret, intro="Share this channel:")
return 1
async def _add_public(
ctx: "AppContext", device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a public channel whose key is derived from its (``#``-prefixed) name."""
idx = await _pick_free_slot(ctx, 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 compute; kept for the QR/share
await device.set_channel(idx, name, None) # None => firmware derives the key from name
ctx.ui.note(f"[ok]✓[/ok] added public channel [brand]{name}[/brand]")
await _show_share(ctx, name, secret, intro="Share this channel:")
return 1
async def _join_with_key(
ctx: "AppContext", device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Join an existing private channel by entering its name and 16-byte key."""
idx = await _pick_free_slot(ctx, slots, capacity)
if idx is None:
return 0
name = await ctx.ui.text("Channel name:", validate=_nonblank)
if not name:
return 0
key = await ctx.ui.text(
"Channel key (32 hex characters / 16 bytes):", validate=_valid_secret
)
if not key:
return 0
secret = normalize_secret(key)
await device.set_channel(idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name.strip()}[/brand]")
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, 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 - guarded by the validator
ctx.ui.note("[err]not a valid channel link[/err]")
return 0
name, secret = parsed
await device.set_channel(idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand]")
return 1
async def _edit(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool:
"""Rename and/or re-key an existing channel; return whether it changed."""
name = await ctx.ui.text("Channel name:", default=slot.name, validate=_nonblank)
if name is None:
return False
name = name.strip()
key = await ctx.ui.text(
"Channel key (32 hex chars; blank to derive from the name):",
default="" if slot.is_name_derived else slot.secret.hex(),
validate=_optional_secret,
)
if key is None:
return False
secret = normalize_secret(key) if key.strip() else None
await device.set_channel(slot.idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] updated channel [brand]{name}[/brand]")
return True
async def _clear(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool:
"""Clear a channel slot after confirmation; return whether it was cleared."""
if not await ctx.ui.confirm(
f"Clear [brand]{slot.name}[/brand]? This removes the channel from this device.",
default=False,
):
return False
await device.set_channel(slot.idx, "", None) # empty name => the slot reads as unused
ctx.ui.note(f"[warn]cleared channel {slot.name}[/warn]")
return True
# --- reordering --------------------------------------------------------------
async def _write_slot(device: Device, idx: int, slot: ChannelSlot) -> None:
"""Write ``slot``'s contents into slot ``idx`` (name-derived channels re-derive their key)."""
secret = None if slot.is_name_derived else slot.secret
await device.set_channel(idx, slot.name, secret)
async def _apply_order(
device: Device, slots: list[ChannelSlot], order: list[int]
) -> int:
"""Rewrite the channel slots so they display in ``order``; return the writes made.
``slots`` is the current list in slot-index order and ``order`` is a permutation of its
positions (as returned by the reorder screen). The channels are re-laid across the same
physical slot indices, lowest first, so their on-device order matches the new display
order. Only slots whose occupant actually changes are written. Reads come from the
in-memory snapshot, so the interleaved writes never clobber a not-yet-placed channel.
"""
indices = sorted(s.idx for s in slots) # the physical slots to fill, ascending
changes = 0
for target_idx, pos in zip(indices, order, strict=True):
slot = slots[pos]
if slot.idx == target_idx:
continue # already in place; no write needed
await _write_slot(device, target_idx, slot)
changes += 1
return changes
async def _reorder_channels(
ctx: "AppContext", device: Device, slots: list[ChannelSlot]
) -> int:
"""Let the user drag channels into a new order with the arrows; return writes made."""
if len(slots) < 2: # pragma: no cover - the menu only offers reorder with 2+ 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 # cancelled or left unchanged
# Chat history is keyed by each channel's intrinsic identity, not its slot, so it follows
# the channels automatically — reordering needs no history migration. The slot→identity
# cache the chat service resolves inbound messages through is refreshed centrally by
# manage_channels once any change lands (see :func:`_refresh_chat_channels`).
return await _apply_order(device, slots, order)
# --- shared views ------------------------------------------------------------
async def _show_share(
ctx: "AppContext", name: str, secret: bytes, *, intro: str = "Scan to add this channel:"
) -> None:
"""Show the channel's QR code and its share URL in a dismissable window."""
url = share_url(name, secret)
body = Group(
Text(intro, style="muted"),
Text(""),
qr_text(url),
Text(""),
Text(url, style="accent"),
)
await ctx.ui.view(body, title=f"Share {name}", footer_hint="Esc back")
def _hash_text(full_hash: str) -> Text:
"""Render a full channel hash with its first byte (the MeshCore fingerprint) highlighted."""
text = Text(no_wrap=True)
text.append(full_hash[:2], style="brand") # the leading byte the companion app reports
text.append(full_hash[2:], style="muted")
return text
async def _show_key(ctx: "AppContext", slot: ChannelSlot) -> None:
"""Show a channel's name, key, hash, and share URL in a small table."""
table = Table(border_style="muted", expand=False, show_header=False)
table.add_column("", style="muted")
table.add_column("")
table.add_row("Name", Text(slot.name, style="brand"))
if slot.is_name_derived:
kind = "public (key from name)"
elif slot.is_public:
kind = "public (default channel)"
else:
kind = "private"
table.add_row("Type", kind)
table.add_row("Hash", _hash_text(slot.full_hash))
table.add_row("Key", Text(slot.secret.hex(), style="warn"))
table.add_row("Link", Text(share_url(slot.name, slot.secret), style="accent"))
await ctx.ui.view(table, title=f"Key — {slot.name}", footer_hint="Esc back")
async def _open_chat(ctx: "AppContext", slot: ChannelSlot) -> None:
"""Open this channel in the live chat screen."""
from .chat import open_chat
await open_chat(ctx, slot.conversation)
async def _pick_free_slot(
ctx: "AppContext", slots: list[ChannelSlot], capacity: int
) -> Optional[int]:
"""Return the next free slot, warning (and returning ``None``) if all are full."""
idx = _next_free_slot(slots, capacity)
if idx is None:
await ctx.ui.view(
Text.from_markup(
"[warn]All channel slots are full.[/warn] Clear one first, then try again."
),
title="No free slot",
)
return idx
# --- validators --------------------------------------------------------------
def _nonblank(text: str) -> bool | str:
"""Require a non-empty name."""
return bool(text.strip()) or "Enter a channel name."
def _valid_secret(text: str) -> bool | str:
"""Require a valid 16-byte hex key."""
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 from name) or a valid 16-byte hex key."""
return True if not text.strip() else _valid_secret(text)
def _valid_link(text: str) -> bool | str:
"""Require a parseable ``meshcore://channel/add`` link."""
return parse_share_url(text) is not None or "Not a valid meshcore:// channel link."