Channels: remember MeshTerm-created channels across sessions
A firmware-less radio bridge holds its channels only in RAM, so every one is lost when the bridge restarts — the Channels page came up empty each session even after you added channels. MeshTerm read channels live from the device and never remembered them. Add a durable, per-device channel store (<config_dir>/channels.json, keyed by the device's public key) that records every channel written through MeshTerm. On connect, reconcile() replays any remembered channel the device isn't reporting into a free slot — filling gaps, never overwriting an occupied slot. The gate is provenance, not device type: only channels you wrote through MeshTerm are recorded, so a firmware radio you never edit through MeshTerm keeps an empty record and does a single identity probe on connect (untouched). A bridge, where every channel is necessarily created through MeshTerm, gets its whole set back. Every mutation (create/join/import/edit/reorder/clear) now flows through a single write_channel() boundary that keeps the store faithful. Verified end to end against the live SPI bridge: write a channel, clear it off the device to mimic a restart, reconnect in a fresh session, and reconcile restores it with the right key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c3d712eff5
commit
711621d69e
6 files changed
+542
-20
No files matched your search
@@ -14,6 +14,7 @@ from rich.console import Console
|
||||
|
||||
from .core.admin_store import AdminStore
|
||||
from .core.advert_store import AdvertStore
|
||||
from .core.channel_store import ChannelStore
|
||||
from .core.config import DeviceProfile, Settings
|
||||
from .core.courier_store import CourierStore
|
||||
from .core.mute_store import MuteStore
|
||||
@@ -61,6 +62,9 @@ class AppContext:
|
||||
mute_store: Store for muted channel notifications — the channels whose new
|
||||
messages don't raise the unread badge (defaults to
|
||||
``<config_dir>/mutes.json`` when not injected).
|
||||
channel_store: Store for channels created through MeshTerm, replayed into a device
|
||||
that forgot them (a firmware-less radio bridge). Keyed by device public key;
|
||||
defaults to ``<config_dir>/channels.json`` when not injected.
|
||||
mock: Whether the simulator device is in use.
|
||||
port_override: Explicit serial port (from ``--port`` or the interactive picker),
|
||||
overriding the profile.
|
||||
@@ -87,6 +91,7 @@ class AppContext:
|
||||
watch_store: Optional[WatchStore] = None
|
||||
courier_store: Optional[CourierStore] = None
|
||||
mute_store: Optional[MuteStore] = None
|
||||
channel_store: Optional[ChannelStore] = None
|
||||
profile: Optional[DeviceProfile] = None
|
||||
mock: bool = False
|
||||
port_override: Optional[str] = None
|
||||
@@ -133,6 +138,8 @@ class AppContext:
|
||||
self.courier_store = CourierStore(self.settings.config_dir / "courier.json")
|
||||
if self.mute_store is None:
|
||||
self.mute_store = MuteStore(self.settings.config_dir / "mutes.json")
|
||||
if self.channel_store is None:
|
||||
self.channel_store = ChannelStore(self.settings.config_dir / "channels.json")
|
||||
|
||||
@property
|
||||
def profile_name(self) -> Optional[str]:
|
||||
@@ -432,6 +439,7 @@ class AppContext:
|
||||
self._active_address = None
|
||||
await self._device.connect()
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
|
||||
# A Bluetooth endpoint (an explicit ``--ble``, a BLE profile, a device picked at
|
||||
@@ -463,6 +471,7 @@ class AppContext:
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
|
||||
resolution = resolve_device(
|
||||
@@ -481,6 +490,7 @@ class AppContext:
|
||||
self._active_endpoint = None
|
||||
await self._device.connect()
|
||||
await self._remember_connected()
|
||||
await self._reconcile_channels()
|
||||
return self._device
|
||||
|
||||
def _resolve_tcp_endpoint(self) -> tuple[Optional[str], Optional[int]]:
|
||||
@@ -566,6 +576,27 @@ class AppContext:
|
||||
hardware_model=await self._hardware_model(),
|
||||
)
|
||||
|
||||
async def _reconcile_channels(self) -> None:
|
||||
"""Replay channels remembered for this device that it isn't reporting (best-effort).
|
||||
|
||||
A firmware-less radio bridge loses its channels whenever it restarts, so the channels
|
||||
you added through MeshTerm are restored into free slots on connect (see
|
||||
:func:`~meshterm.core.channel_store.reconcile`). With nothing remembered for the device
|
||||
this is a single identity probe, so a firmware radio pays almost nothing. A failure here
|
||||
must never break connecting — it is logged and swallowed.
|
||||
"""
|
||||
if self._device is None or self.channel_store is None:
|
||||
return
|
||||
from .core.channel_store import reconcile
|
||||
|
||||
try:
|
||||
restored = await reconcile(self.channel_store, self._device)
|
||||
except Exception as exc: # noqa: BLE001 - channel replay must not block a connection
|
||||
self.log.debug("channels: reconcile on connect failed: %s", exc)
|
||||
return
|
||||
if restored:
|
||||
self.log.info("channels: restored %d remembered channel(s) to the device", restored)
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
"""Drop a lost device connection and rebuild it, restoring live services.
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Persistence for channels created through MeshTerm, so they survive a device that forgets.
|
||||
|
||||
Most companions keep their own channel table in firmware, so MeshTerm reads channels live from
|
||||
the device and never has to remember them. A firmware-less radio bridge is the exception: it
|
||||
holds its channels only in RAM, so every one is lost when the bridge process restarts — the
|
||||
Channels page comes up empty each session even though you added channels last time.
|
||||
|
||||
This store is the durable backup for exactly that case. It records every channel *written
|
||||
through MeshTerm* (the channel manager and the ``channels`` CLI both go through
|
||||
:func:`~meshterm.ui.channels.write_channel`), keyed by the device's own public key so two radios
|
||||
keep separate sets. On connect, :func:`reconcile` replays any remembered channel the device
|
||||
isn't currently reporting into a free slot — *filling gaps, never overwriting an occupied slot*.
|
||||
|
||||
The gate is provenance, not device type: only channels you wrote through MeshTerm are recorded,
|
||||
so a firmware radio you never edit through MeshTerm keeps an empty record and is left completely
|
||||
untouched (with nothing remembered, ``reconcile`` does a single identity probe and returns). A
|
||||
firmware-less bridge, where every channel is necessarily created through MeshTerm, gets its
|
||||
whole set back.
|
||||
|
||||
Like the operator preferences (mutes, watched nodes, admin passwords), this is global machine
|
||||
state in a small JSON file (``<config_dir>/channels.json``), not the per-invocation SQLite
|
||||
database. Reads are served from memory after the first load; the rare, user-driven writes flush
|
||||
immediately and atomically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from .channels import (
|
||||
CHANNEL_SECRET_BYTES,
|
||||
CHANNEL_SLOT_EMPTY_RUN,
|
||||
CHANNEL_SLOT_PROBE_CAP,
|
||||
MAX_CHANNELS,
|
||||
channel_identity,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .connection import Device
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RememberedChannel:
|
||||
"""One channel MeshTerm remembers for a device: its slot, name, and 16-byte secret."""
|
||||
|
||||
idx: int
|
||||
name: str
|
||||
secret: bytes
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
"""The channel's slot-independent intrinsic identity (see :func:`channel_identity`)."""
|
||||
return channel_identity(self.name, self.secret)
|
||||
|
||||
|
||||
def _norm(pubkey: str) -> str:
|
||||
"""Normalise a device public key to the lowercase hex used as the store's device key."""
|
||||
return (pubkey or "").lower().removeprefix("0x")
|
||||
|
||||
|
||||
class ChannelStore:
|
||||
"""Reads and writes the per-device set of MeshTerm-managed channels, memory-first.
|
||||
|
||||
Interact through :meth:`channels` (a device's remembered channels), :meth:`remember` (record
|
||||
a channel written to a slot), and :meth:`forget` (a cleared slot). The backing map is loaded
|
||||
once on first access and kept in memory; each mutation persists the whole map atomically.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Open the store against a JSON file location.
|
||||
|
||||
Args:
|
||||
path: Path to the JSON state file (created lazily on the first remembered channel).
|
||||
"""
|
||||
self._path = path
|
||||
self._devices: Optional[dict[str, list[RememberedChannel]]] = None
|
||||
|
||||
@property
|
||||
def _state(self) -> dict[str, list[RememberedChannel]]:
|
||||
"""The device -> remembered-channels map, loaded from disk on first access."""
|
||||
if self._devices is None:
|
||||
self._devices = self._load()
|
||||
return self._devices
|
||||
|
||||
def _load(self) -> dict[str, list[RememberedChannel]]:
|
||||
"""Parse the file into the device map, or empty on a missing/corrupt file."""
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
devices: dict[str, list[RememberedChannel]] = {}
|
||||
for pubkey, entries in (data.get("devices") or {}).items():
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
channels: list[RememberedChannel] = []
|
||||
for entry in entries:
|
||||
channel = _channel_from_json(entry)
|
||||
if channel is not None:
|
||||
channels.append(channel)
|
||||
devices[_norm(str(pubkey))] = channels
|
||||
return devices
|
||||
|
||||
def channels(self, pubkey: str) -> list[RememberedChannel]:
|
||||
"""The channels remembered for a device (a copy, in slot order), empty if none."""
|
||||
return sorted(self._state.get(_norm(pubkey), []), key=lambda c: c.idx)
|
||||
|
||||
def remember(self, pubkey: str, idx: int, name: str, secret: bytes) -> None:
|
||||
"""Record the channel now occupying a slot, replacing any prior entry for that slot.
|
||||
|
||||
Called after a channel is written to the device through MeshTerm. A slot already
|
||||
holding an identical channel is a no-op (no needless write).
|
||||
|
||||
Args:
|
||||
pubkey: The device's own public key.
|
||||
idx: The slot the channel was written to.
|
||||
name: The channel name.
|
||||
secret: The channel's effective 16-byte secret (a name-derived channel stores the
|
||||
derived key so it can be replayed without re-deriving).
|
||||
"""
|
||||
key = _norm(pubkey)
|
||||
if not key:
|
||||
return
|
||||
channels = list(self._state.get(key, []))
|
||||
new = RememberedChannel(idx=idx, name=name, secret=bytes(secret))
|
||||
existing = next((c for c in channels if c.idx == idx), None)
|
||||
if existing == new:
|
||||
return
|
||||
channels = [c for c in channels if c.idx != idx]
|
||||
channels.append(new)
|
||||
self._state[key] = channels
|
||||
self._save()
|
||||
|
||||
def forget(self, pubkey: str, idx: int) -> None:
|
||||
"""Drop the channel remembered for a slot (a cleared slot); persist only a real change."""
|
||||
key = _norm(pubkey)
|
||||
channels = self._state.get(key)
|
||||
if not channels or not any(c.idx == idx for c in channels):
|
||||
return
|
||||
self._state[key] = [c for c in channels if c.idx != idx]
|
||||
self._save()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Persist the whole device map atomically (a crash mid-write keeps the old file)."""
|
||||
data = {
|
||||
"devices": {
|
||||
pubkey: [
|
||||
{"idx": c.idx, "name": c.name, "secret": c.secret.hex()}
|
||||
for c in sorted(channels, key=lambda c: c.idx)
|
||||
]
|
||||
for pubkey, channels in sorted(self._state.items())
|
||||
if channels
|
||||
}
|
||||
}
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self._path)
|
||||
|
||||
|
||||
def _channel_from_json(entry: object) -> Optional[RememberedChannel]:
|
||||
"""Parse one stored channel entry, or ``None`` if it is malformed."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
try:
|
||||
idx = int(entry["idx"])
|
||||
name = str(entry["name"])
|
||||
secret = bytes.fromhex(str(entry["secret"]))
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return None
|
||||
if not name or len(secret) != CHANNEL_SECRET_BYTES:
|
||||
return None
|
||||
return RememberedChannel(idx=idx, name=name, secret=secret)
|
||||
|
||||
|
||||
async def reconcile(store: ChannelStore, device: "Device") -> int:
|
||||
"""Replay a device's remembered channels into any slots it isn't already reporting.
|
||||
|
||||
Restores each remembered channel the device is missing (matched by intrinsic identity, so a
|
||||
channel that moved slots still counts as present) into its remembered slot when free, else
|
||||
the lowest free slot. An occupied slot is never overwritten, so a firmware radio that kept
|
||||
its own channels is left as-is.
|
||||
|
||||
Read-cheap by design: with nothing remembered for this device it does a single identity
|
||||
probe and returns, so a firmware radio you don't manage through MeshTerm pays almost nothing.
|
||||
Best-effort — the caller runs it on connect and must not let a failure here break connecting.
|
||||
|
||||
Args:
|
||||
store: The channel store to read remembered channels from.
|
||||
device: The freshly connected device to reconcile.
|
||||
|
||||
Returns:
|
||||
The number of channels restored (0 when nothing needed replaying).
|
||||
"""
|
||||
info = await device.get_self_info()
|
||||
remembered = store.channels(info.get("public_key", ""))
|
||||
if not remembered:
|
||||
return 0
|
||||
|
||||
used: set[int] = set()
|
||||
present_ids: set[str] = set()
|
||||
empty_run = 0
|
||||
for idx in range(CHANNEL_SLOT_PROBE_CAP):
|
||||
try:
|
||||
payload = await device.get_channel(idx)
|
||||
except Exception: # noqa: BLE001 - firmware may reject an out-of-range index
|
||||
break
|
||||
if payload and payload.get("channel_name"):
|
||||
empty_run = 0
|
||||
used.add(idx)
|
||||
name = str(payload["channel_name"])
|
||||
secret = bytes(payload.get("channel_secret") or b"\x00" * CHANNEL_SECRET_BYTES)
|
||||
present_ids.add(channel_identity(name, secret))
|
||||
else:
|
||||
empty_run += 1
|
||||
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
|
||||
break # off the end of a never-rejecting firmware; nothing more configured
|
||||
|
||||
restored = 0
|
||||
for channel in remembered:
|
||||
if channel.identity in present_ids:
|
||||
continue # already on the device (possibly at another slot) — leave it be
|
||||
target = channel.idx if channel.idx not in used else _next_free_slot(used)
|
||||
if target is None:
|
||||
break # every slot is full; nothing more we can restore
|
||||
await device.set_channel(target, channel.name, channel.secret)
|
||||
used.add(target)
|
||||
present_ids.add(channel.identity)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
def _next_free_slot(used: set[int]) -> Optional[int]:
|
||||
"""The lowest slot index not in ``used`` within the stock capacity, or ``None`` if full."""
|
||||
return next((i for i in range(MAX_CHANNELS) if i not in used), None)
|
||||
@@ -109,28 +109,32 @@ class ChannelsTool(Tool):
|
||||
|
||||
async def _cli_add(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Add a public, private-random, or explicitly-keyed channel on a slot."""
|
||||
from ..ui.channels import write_channel
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
name = str(params["name"])
|
||||
if name.startswith("#"): # public: firmware derives the key from the name
|
||||
await device.set_channel(idx, name, None)
|
||||
await write_channel(ctx, device, idx, name, None)
|
||||
secret = derive_secret(name)
|
||||
else:
|
||||
secret = (
|
||||
normalize_secret(params["secret"]) if params.get("secret") else random_secret()
|
||||
)
|
||||
await device.set_channel(idx, name, secret)
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] channel [brand]{name}[/brand] set on slot {idx}")
|
||||
self._print_share(ctx, name, secret)
|
||||
return ToolResult(summary={"index": idx, "name": name})
|
||||
|
||||
async def _cli_join(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""Join a channel from a name and an explicit key."""
|
||||
from ..ui.channels import write_channel
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
name = str(params["name"])
|
||||
secret = normalize_secret(str(params["secret"]))
|
||||
await device.set_channel(idx, name, secret)
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name}[/brand] on slot {idx}")
|
||||
return ToolResult(summary={"index": idx, "name": name})
|
||||
|
||||
@@ -140,9 +144,11 @@ class ChannelsTool(Tool):
|
||||
if parsed is None:
|
||||
raise typer.BadParameter("not a valid meshcore:// channel link")
|
||||
name, secret = parsed
|
||||
from ..ui.channels import write_channel
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
await device.set_channel(idx, name, secret)
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand] on slot {idx}")
|
||||
return ToolResult(summary={"index": idx, "name": name})
|
||||
|
||||
@@ -167,7 +173,7 @@ class ChannelsTool(Tool):
|
||||
command (a private channel's key is lost with the slot unless it's saved
|
||||
elsewhere), mirroring the interactive flow's danger confirmation.
|
||||
"""
|
||||
from ..ui.channels import read_channel_slots
|
||||
from ..ui.channels import read_channel_slots, write_channel
|
||||
|
||||
device = await ctx.device()
|
||||
idx = int(params["index"])
|
||||
@@ -175,7 +181,7 @@ class ChannelsTool(Tool):
|
||||
if slot is None:
|
||||
ctx.ui.note(f"[muted]slot {idx} is already empty[/muted]")
|
||||
return ToolResult(summary={"index": idx, "cleared": False})
|
||||
await device.set_channel(idx, "", None) # empty name => the slot reads as unused
|
||||
await write_channel(ctx, device, idx, "", None) # empty name => the slot reads as unused
|
||||
ctx.ui.note(f"[warn]cleared channel {slot.name} on slot {idx}[/warn]")
|
||||
return ToolResult(summary={"index": idx, "cleared": True})
|
||||
|
||||
|
||||
+53
-12
@@ -318,6 +318,45 @@ def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> Optional[int]:
|
||||
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: Optional[bytes]
|
||||
) -> None:
|
||||
"""Write a channel to a device slot, remembering it so a forgetful device can be restored.
|
||||
|
||||
The single boundary every channel mutation goes through — create, join, import, edit,
|
||||
reorder, and clear all land here — so the channel store stays a faithful record of what
|
||||
MeshTerm wrote (see :mod:`meshterm.core.channel_store`). Clearing a slot (an empty ``name``)
|
||||
forgets it; any other write remembers the channel under the device's own public key, storing
|
||||
a name-derived channel's derived key so it can later be replayed as-is. Remembering is
|
||||
best-effort: it never blocks or fails the actual device write.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context (for the channel store and cached self-info).
|
||||
device: The connected device to write to.
|
||||
idx: The slot to write.
|
||||
name: The channel name (empty clears the slot).
|
||||
secret: The 16-byte secret, or ``None`` to let the firmware derive it from the name.
|
||||
"""
|
||||
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 - identity probe is best-effort; skip remembering
|
||||
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): just its glyph and name.
|
||||
|
||||
@@ -697,7 +736,7 @@ async def _create_private(
|
||||
if not name:
|
||||
return 0
|
||||
secret = random_secret()
|
||||
await device.set_channel(idx, name.strip(), secret)
|
||||
await write_channel(ctx, device, 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
|
||||
@@ -710,7 +749,7 @@ async def _add_default_public(
|
||||
idx = await _pick_free_slot(ctx, slots, capacity)
|
||||
if idx is None:
|
||||
return 0
|
||||
await device.set_channel(idx, "Public", DEFAULT_PUBLIC_SECRET)
|
||||
await write_channel(ctx, device, idx, "Public", DEFAULT_PUBLIC_SECRET)
|
||||
ctx.ui.note("[ok]✓[/ok] added the standard [brand]Public[/brand] channel")
|
||||
return 1
|
||||
|
||||
@@ -733,7 +772,7 @@ async def _add_public(
|
||||
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
|
||||
await write_channel(ctx, device, 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
|
||||
@@ -755,7 +794,7 @@ async def _join_with_key(
|
||||
if not key:
|
||||
return 0
|
||||
secret = normalize_secret(key)
|
||||
await device.set_channel(idx, name.strip(), secret)
|
||||
await write_channel(ctx, device, idx, name.strip(), secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name.strip()}[/brand]")
|
||||
return 1
|
||||
|
||||
@@ -777,7 +816,7 @@ async def _import_link(
|
||||
ctx.ui.note("[err]not a valid channel link[/err]")
|
||||
return 0
|
||||
name, secret = parsed
|
||||
await device.set_channel(idx, name, secret)
|
||||
await write_channel(ctx, device, idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand]")
|
||||
return 1
|
||||
|
||||
@@ -796,7 +835,7 @@ async def _edit(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool:
|
||||
if key is None:
|
||||
return False
|
||||
secret = normalize_secret(key) if key.strip() else None
|
||||
await device.set_channel(slot.idx, name, secret)
|
||||
await write_channel(ctx, device, slot.idx, name, secret)
|
||||
ctx.ui.note(f"[ok]✓[/ok] updated channel [brand]{name}[/brand]")
|
||||
return True
|
||||
|
||||
@@ -819,7 +858,7 @@ async def _clear(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool:
|
||||
return False
|
||||
ctx.chat.set_active(slot.conversation.key) # drop its unread before the slot goes away
|
||||
ctx.chat.set_active(None)
|
||||
await device.set_channel(slot.idx, "", None) # empty name => the slot reads as unused
|
||||
await write_channel(ctx, device, slot.idx, "", None) # empty name => the slot reads as unused
|
||||
ctx.ui.note(f"[warn]cleared channel {slot.name}[/warn]")
|
||||
return True
|
||||
|
||||
@@ -827,14 +866,16 @@ async def _clear(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool:
|
||||
# --- reordering --------------------------------------------------------------
|
||||
|
||||
|
||||
async def _write_slot(device: Device, idx: int, slot: ChannelSlot) -> None:
|
||||
async def _write_slot(
|
||||
ctx: "AppContext", 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)
|
||||
await write_channel(ctx, device, idx, slot.name, secret)
|
||||
|
||||
|
||||
async def _apply_order(
|
||||
device: Device, slots: list[ChannelSlot], order: list[int]
|
||||
ctx: "AppContext", device: Device, slots: list[ChannelSlot], order: list[int]
|
||||
) -> int:
|
||||
"""Rewrite the channel slots so they display in ``order``; return the writes made.
|
||||
|
||||
@@ -850,7 +891,7 @@ async def _apply_order(
|
||||
slot = slots[pos]
|
||||
if slot.idx == target_idx:
|
||||
continue # already in place; no write needed
|
||||
await _write_slot(device, target_idx, slot)
|
||||
await _write_slot(ctx, device, target_idx, slot)
|
||||
changes += 1
|
||||
return changes
|
||||
|
||||
@@ -869,7 +910,7 @@ async def _reorder_channels(
|
||||
# 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)
|
||||
return await _apply_order(ctx, device, slots, order)
|
||||
|
||||
|
||||
# --- shared views ------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for remembering channels across sessions (``core.channel_store``).
|
||||
|
||||
Covers the durable store itself (per-device, memory-first JSON), the ``reconcile`` replay that
|
||||
restores a forgetful device's channels on connect, and the write-through that records every
|
||||
channel MeshTerm creates. The reconcile tests run against the :class:`MockDevice` simulator,
|
||||
whose channel table starts empty — standing in for the firmware-less radio bridge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.context import AppContext
|
||||
from meshterm.core.admin_store import AdminStore
|
||||
from meshterm.core.channel_store import ChannelStore, reconcile
|
||||
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, channel_identity, derive_secret
|
||||
from meshterm.core.config import Settings
|
||||
from meshterm.core.connection import make_device
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.ui.channels import read_channel_slots, write_channel
|
||||
|
||||
PUB_A = "aa" * 32
|
||||
PUB_B = "bb" * 32
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path: Path) -> AppContext:
|
||||
"""A mock-backed application context with the plain (console) UI surface."""
|
||||
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "chan.db")
|
||||
context = AppContext(
|
||||
console=Console(file=io.StringIO()),
|
||||
settings=settings,
|
||||
repo=Repository(settings.db_path),
|
||||
device_store=DeviceStore(tmp_path / "devices.json"),
|
||||
admin_store=AdminStore(tmp_path / "admin.json"),
|
||||
mock=True,
|
||||
)
|
||||
yield context
|
||||
context.repo.close()
|
||||
|
||||
|
||||
# --- the store ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_round_trips_and_persists(tmp_path: Path) -> None:
|
||||
"""Remembered channels survive a fresh store instance, sorted by slot, keyed per device."""
|
||||
path = tmp_path / "channels.json"
|
||||
store = ChannelStore(path)
|
||||
store.remember(PUB_A, 1, "Ops", bytes(range(16)))
|
||||
store.remember(PUB_A, 0, "Public", DEFAULT_PUBLIC_SECRET)
|
||||
store.remember(PUB_B, 0, "Other", bytes(range(16, 32)))
|
||||
|
||||
reloaded = ChannelStore(path) # a new process reads the same file
|
||||
a = reloaded.channels(PUB_A)
|
||||
assert [c.idx for c in a] == [0, 1] # slot order
|
||||
assert a[0].name == "Public" and a[0].secret == DEFAULT_PUBLIC_SECRET
|
||||
assert a[1].name == "Ops" and a[1].secret == bytes(range(16))
|
||||
# A second device keeps a separate set.
|
||||
assert [c.name for c in reloaded.channels(PUB_B)] == ["Other"]
|
||||
|
||||
|
||||
def test_store_forget_and_replace(tmp_path: Path) -> None:
|
||||
"""Forgetting drops one slot; re-remembering a slot replaces its occupant."""
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
store.remember(PUB_A, 0, "First", bytes(range(16)))
|
||||
store.remember(PUB_A, 0, "Second", bytes(range(16, 32))) # same slot, new channel
|
||||
assert [c.name for c in store.channels(PUB_A)] == ["Second"]
|
||||
|
||||
store.remember(PUB_A, 1, "Keep", bytes(range(16)))
|
||||
store.forget(PUB_A, 0)
|
||||
assert [c.idx for c in store.channels(PUB_A)] == [1]
|
||||
|
||||
|
||||
def test_store_normalises_device_key(tmp_path: Path) -> None:
|
||||
"""A device key is matched case-insensitively and with an optional 0x prefix stripped."""
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
store.remember("AABB", 0, "Ops", bytes(range(16)))
|
||||
assert store.channels("0xaabb") # same device, different spelling
|
||||
|
||||
|
||||
def test_store_ignores_corrupt_file(tmp_path: Path) -> None:
|
||||
"""A garbage or malformed file reads as empty rather than raising, and stays writable."""
|
||||
path = tmp_path / "channels.json"
|
||||
path.write_text("not json at all", encoding="utf-8")
|
||||
store = ChannelStore(path)
|
||||
assert store.channels(PUB_A) == []
|
||||
store.remember(PUB_A, 0, "Ops", bytes(range(16))) # recovers and persists
|
||||
assert ChannelStore(path).channels(PUB_A)[0].name == "Ops"
|
||||
|
||||
|
||||
def test_store_drops_malformed_entries(tmp_path: Path) -> None:
|
||||
"""Entries with a bad secret length or missing fields are skipped, good ones kept."""
|
||||
path = tmp_path / "channels.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"devices": {
|
||||
PUB_A: [
|
||||
{"idx": 0, "name": "Good", "secret": bytes(range(16)).hex()},
|
||||
{"idx": 1, "name": "BadKey", "secret": "00"}, # 1-byte secret
|
||||
{"idx": 2, "name": "Nameless"}, # missing secret
|
||||
]
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert [c.name for c in ChannelStore(path).channels(PUB_A)] == ["Good"]
|
||||
|
||||
|
||||
# --- reconcile ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _mock_device():
|
||||
device = make_device(mock=True, port=None)
|
||||
await device.connect()
|
||||
pubkey = (await device.get_self_info())["public_key"]
|
||||
return device, pubkey
|
||||
|
||||
|
||||
async def test_reconcile_restores_missing_channels(tmp_path: Path) -> None:
|
||||
"""A device that forgot its channels gets every remembered one replayed into its slot."""
|
||||
device, pubkey = await _mock_device()
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
store.remember(pubkey, 0, "Public", DEFAULT_PUBLIC_SECRET)
|
||||
store.remember(pubkey, 1, "Ops", bytes(range(16)))
|
||||
|
||||
restored = await reconcile(store, device)
|
||||
assert restored == 2
|
||||
|
||||
after = {s.idx: s for s in await read_channel_slots(device)}
|
||||
assert after[0].name == "Public" and after[0].secret == DEFAULT_PUBLIC_SECRET
|
||||
assert after[1].name == "Ops" and after[1].secret == bytes(range(16))
|
||||
|
||||
# Idempotent: a second reconcile sees the channels already present (by identity) and writes
|
||||
# nothing more.
|
||||
assert await reconcile(store, device) == 0
|
||||
|
||||
|
||||
async def test_reconcile_noop_when_nothing_remembered(tmp_path: Path) -> None:
|
||||
"""With an empty record the device is never written to (a firmware radio pays nothing)."""
|
||||
device, _ = await _mock_device()
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
assert await reconcile(store, device) == 0
|
||||
assert await read_channel_slots(device) == []
|
||||
|
||||
|
||||
async def test_reconcile_never_overwrites_an_occupied_slot(tmp_path: Path) -> None:
|
||||
"""A remembered channel whose slot is taken lands on a free slot, leaving the occupant."""
|
||||
device, pubkey = await _mock_device()
|
||||
await device.set_channel(0, "Squatter", bytes(range(16, 32))) # already on slot 0
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
store.remember(pubkey, 0, "Ops", bytes(range(16))) # remembered for slot 0 — now taken
|
||||
|
||||
restored = await reconcile(store, device)
|
||||
assert restored == 1
|
||||
|
||||
after = {s.idx: s for s in await read_channel_slots(device)}
|
||||
assert after[0].name == "Squatter" # untouched
|
||||
ops = next(s for s in after.values() if s.name == "Ops")
|
||||
assert ops.idx != 0 # relocated to a free slot
|
||||
|
||||
|
||||
async def test_reconcile_leaves_a_channel_present_at_another_slot(tmp_path: Path) -> None:
|
||||
"""A remembered channel already on the device (at any slot) is not duplicated."""
|
||||
device, pubkey = await _mock_device()
|
||||
await device.set_channel(3, "Ops", bytes(range(16))) # present, but at slot 3 not 0
|
||||
store = ChannelStore(tmp_path / "channels.json")
|
||||
store.remember(pubkey, 0, "Ops", bytes(range(16))) # remembered at slot 0
|
||||
|
||||
assert await reconcile(store, device) == 0 # matched by identity — nothing to restore
|
||||
assert len([s for s in await read_channel_slots(device) if s.name == "Ops"]) == 1
|
||||
|
||||
|
||||
# --- write-through -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_channel_remembers_and_forgets(ctx) -> None:
|
||||
"""Writing a channel records it under the device key; clearing the slot forgets it."""
|
||||
device = await ctx.device()
|
||||
pubkey = (await device.get_self_info())["public_key"]
|
||||
|
||||
await write_channel(ctx, device, 2, "Ops", bytes(range(16)))
|
||||
remembered = ctx.channel_store.channels(pubkey)
|
||||
assert [(c.idx, c.name) for c in remembered] == [(2, "Ops")]
|
||||
|
||||
await write_channel(ctx, device, 2, "", None) # clear the slot
|
||||
assert ctx.channel_store.channels(pubkey) == []
|
||||
|
||||
|
||||
async def test_write_channel_stores_derived_key_for_name_derived(ctx) -> None:
|
||||
"""A name-derived (#) channel remembers the key the firmware derives, ready to replay."""
|
||||
device = await ctx.device()
|
||||
pubkey = (await device.get_self_info())["public_key"]
|
||||
|
||||
await write_channel(ctx, device, 1, "#general", None) # None => firmware derives from name
|
||||
stored = ctx.channel_store.channels(pubkey)[0]
|
||||
assert stored.secret == derive_secret("#general")
|
||||
assert stored.identity == channel_identity("#general", derive_secret("#general"))
|
||||
@@ -329,7 +329,7 @@ async def test_apply_order_relays_channels_into_new_positions(ctx: AppContext) -
|
||||
|
||||
slots = await read_channel_slots(device)
|
||||
# Reverse the display order: the row that was third moves first, first moves last.
|
||||
writes = await _apply_order(device, slots, [2, 1, 0])
|
||||
writes = await _apply_order(ctx, device, slots, [2, 1, 0])
|
||||
assert writes == 2 # the middle channel keeps its slot; the two ends swap
|
||||
|
||||
after = {s.idx: s for s in await read_channel_slots(device)}
|
||||
@@ -456,7 +456,7 @@ async def test_reorder_keeps_history_because_key_is_intrinsic(ctx: AppContext) -
|
||||
alpha_id, beta_id = before["Alpha"].identity, before["Beta"].identity
|
||||
|
||||
# Swap the two channels' slots, then refresh the service's slot→identity cache.
|
||||
writes = await _apply_order(device, slots, [1, 0])
|
||||
writes = await _apply_order(ctx, device, slots, [1, 0])
|
||||
assert writes == 2
|
||||
await chat.refresh_channels()
|
||||
|
||||
|
||||
Reference in new issue
Block a user