From 711621d69e126ec9110fb9d305fa8a4c80bd8fa2 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Fri, 24 Jul 2026 18:20:49 -0400 Subject: [PATCH] Channels: remember MeshTerm-created channels across sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (/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) --- meshterm/context.py | 31 +++++ meshterm/core/channel_store.py | 239 +++++++++++++++++++++++++++++++++ meshterm/tools/channels.py | 18 ++- meshterm/ui/channels.py | 65 +++++++-- tests/test_channel_store.py | 205 ++++++++++++++++++++++++++++ tests/test_channels.py | 4 +- 6 files changed, 542 insertions(+), 20 deletions(-) create mode 100644 meshterm/core/channel_store.py create mode 100644 tests/test_channel_store.py diff --git a/meshterm/context.py b/meshterm/context.py index 64e2966..780d4c8 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -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 ``/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 ``/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. diff --git a/meshterm/core/channel_store.py b/meshterm/core/channel_store.py new file mode 100644 index 0000000..a04dc93 --- /dev/null +++ b/meshterm/core/channel_store.py @@ -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 (``/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) diff --git a/meshterm/tools/channels.py b/meshterm/tools/channels.py index 5bd6d3a..9f7e268 100644 --- a/meshterm/tools/channels.py +++ b/meshterm/tools/channels.py @@ -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}) diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index 25314d2..80e2923 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -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 ------------------------------------------------------------ diff --git a/tests/test_channel_store.py b/tests/test_channel_store.py new file mode 100644 index 0000000..6fb33c8 --- /dev/null +++ b/tests/test_channel_store.py @@ -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")) diff --git a/tests/test_channels.py b/tests/test_channels.py index 86489f6..3c24ef6 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -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()