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:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-24 18:20:49 -04:00
1 parent c3d712eff5
commit 711621d69e
6 files changed
+542 -20

No files matched your search

+31
View File
@@ -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.
+239
View File
@@ -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)
+12 -6
View File
@@ -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
View File
@@ -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 ------------------------------------------------------------
+205
View File
@@ -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"))
+2 -2
View File
@@ -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()