Files
MeshTerm/meshterm/core/device_store.py
T
Jean-Pierre MartineauandClaude Opus 5 e2161fd191 Two MeshTerms can share a machine, but not a directory
Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a
downloaded build, a second one for a second radio — and nothing about how it was
installed changes that. Two at once will not corrupt anything, but each holds the
JSON stores in memory and rewrites them whole, so the second to save discards the
first's changes. A lost contact is much harder to notice than a crash.

The interactive session now takes an exclusive lock on the directory it is using,
and a second one is refused with the fix in the message: set MESHTERM_HOME. The
lock belongs to the operating system rather than to a process id written in a
file, because a pid file has to answer "is that still alive?", the answer differs
per platform, and it is wrong once a reboot recycles the number — so a crash
leaves a lock nobody can explain and everyone learns to delete it. One-shot
subcommands do not take it; blocking `meshterm contacts` because a menu is open in
another window would be an obstacle rather than a guard.

The scratch-file race is fixed at the same time, and it was ten places rather than
the three I first counted: every store under core/ carried the same three lines,
writing to a fixed `.tmp` name beside its file. Two processes saving together took
turns inside that one scratch file and then each renamed whatever was in it over
the real one — the rename is atomic, the thing being renamed was not. They now go
through one `write_atomically`, whose temporary carries the writing process's id,
and which cleans up after a failed write instead of leaving a partial file behind.

And the Windows console error is answered rather than raised. prompt_toolkit needs
a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives
as a traceback that reads like a broken application when the answer is "open a
different terminal". It now says that, and points out that every screen has a
subcommand which pipes perfectly well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:31:39 -04:00

264 lines
11 KiB
Python

"""Persistence for confirmed companion devices.
Every device that has ever spoken the MeshCore protocol to us successfully is remembered
forever in a small JSON registry in the config directory (``<config_dir>/devices.json``),
keyed by :attr:`~meshterm.core.discovery.DiscoveredDevice.stable_id` (a USB serial number
when available, so the record survives a changed COM number). This is deliberately *not* in
the SQLite database: the database can be swapped per-invocation with ``--db``, whereas the
remembered devices are global machine state that should survive that.
The registry also tracks which device was most recently connected (``last``), used to
preselect and star a default on the startup splash. Membership in the registry is what marks
a device as a confirmed MeshCore companion — the splash reserves its "MeshCore device" tag
for these, rather than guessing from the USB vendor ID.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from .atomicwrite import write_atomically
from .discovery import (
TRANSPORT_BLE,
TRANSPORT_SERIAL,
TRANSPORT_TCP,
DiscoveredDevice,
)
from .models import utcnow
@dataclass(slots=True)
class RememberedDevice:
"""A previously confirmed device recorded for next-time defaulting.
Attributes:
stable_id: The device's :attr:`DiscoveredDevice.stable_id` at connect time.
port: The serial port it was last seen on (informational; may have changed). Blank
for a BLE/TCP device.
label: A friendly label for display in prompts and tables.
last_connected: ISO-8601 timestamp of the last successful connection.
node_name: The device's own mesh node name, learned at connect time (may be empty).
transport: ``"serial"``, ``"ble"``, or ``"tcp"`` — how this device was last reached.
address: The Bluetooth address, for a BLE device (blank otherwise), so it can be
reconnected directly without re-scanning.
hardware_model: The firmware's own model string (e.g. ``"Seeed Tracker T1000-E"``),
learned from the device-query at connect time. This is the only reliable source of
the model — a BLE companion advertises none — so it's remembered here to fill the
hardware column even when the device is merely attached, not connected. May be empty
for a serial device confirmed by an older firmware that predates the query.
host: The network host, for a TCP device (blank otherwise), so it can be reconnected
directly. A TCP companion isn't discoverable, so this remembered endpoint is the
*only* way it reappears in the picker.
tcp_port: The TCP port, for a TCP device (0 otherwise).
"""
stable_id: str
port: str
label: str
last_connected: str
node_name: str = ""
transport: str = TRANSPORT_SERIAL
address: str = ""
hardware_model: str = ""
host: str = ""
tcp_port: int = 0
@property
def is_ble(self) -> bool:
"""Whether this remembered device was reached over Bluetooth LE."""
return self.transport == TRANSPORT_BLE
@property
def is_tcp(self) -> bool:
"""Whether this remembered device was reached over a TCP network connection."""
return self.transport == TRANSPORT_TCP
@property
def target(self) -> str:
"""Where this device is reached.
``host:port`` for TCP, the BLE address for Bluetooth, else the serial port.
"""
if self.is_tcp:
return f"{self.host}:{self.tcp_port}"
return self.address or self.port if self.is_ble else self.port
def matches(self, device: DiscoveredDevice) -> bool:
"""Return whether ``device`` is the same hardware as this record."""
return device.stable_id == self.stable_id
class DeviceStore:
"""Reads and writes the registry of confirmed companion devices."""
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 first write).
"""
self._path = path
def _read(self) -> tuple[dict[str, RememberedDevice], str | None]:
"""Return the parsed ``(registry, last_id)``; empty on a missing/corrupt file.
A missing or corrupt file is treated as "nothing remembered" rather than an error,
so a stray edit never blocks startup. An old flat-format file (a single record at
the top level, from before the registry) is migrated in-memory to a one-entry
registry so upgrades are seamless.
"""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}, None
if not isinstance(data, dict):
return {}, None
# Old flat shape: a single record with ``stable_id`` at the top level.
if "devices" not in data and "stable_id" in data:
record = self._record_from(data)
if record is None:
return {}, None
return {record.stable_id: record}, record.stable_id
registry: dict[str, RememberedDevice] = {}
for entry in (data.get("devices") or {}).values():
record = self._record_from(entry)
if record is not None:
registry[record.stable_id] = record
last = data.get("last")
if last not in registry:
last = None
return registry, last
@staticmethod
def _record_from(entry: object) -> RememberedDevice | None:
"""Build a :class:`RememberedDevice` from one raw JSON entry, or ``None`` if invalid."""
if not isinstance(entry, dict):
return None
try:
return RememberedDevice(
stable_id=entry["stable_id"],
port=entry.get("port", ""),
label=entry.get("label", entry.get("port", "")),
last_connected=entry.get("last_connected", ""),
node_name=entry.get("node_name", ""),
transport=entry.get("transport", TRANSPORT_SERIAL),
address=entry.get("address", ""),
hardware_model=entry.get("hardware_model", ""),
host=entry.get("host", ""),
tcp_port=int(entry.get("tcp_port", 0) or 0),
)
except (KeyError, TypeError, ValueError):
return None
def load(self) -> RememberedDevice | None:
"""Return the most recently connected device, or ``None`` if none is remembered.
This is the "last known good" default used to preselect and star a row on the
startup splash and to resolve a port non-interactively.
Returns:
The last-connected :class:`RememberedDevice`, or ``None``.
"""
registry, last = self._read()
return registry.get(last) if last is not None else None
def load_all(self) -> dict[str, RememberedDevice]:
"""Return the full registry of confirmed devices, keyed by ``stable_id``."""
registry, _ = self._read()
return registry
def is_known(self, device: DiscoveredDevice) -> bool:
"""Return whether ``device`` has ever been confirmed as a MeshCore companion."""
registry, _ = self._read()
return device.stable_id in registry
def remember(
self, device: DiscoveredDevice, *, node_name: str = "", hardware_model: str = ""
) -> None:
"""Record ``device`` as a confirmed connection and the new default.
Upserts the device into the registry (so it is remembered forever) and marks it as
the most recently connected one.
Args:
device: The device that just connected successfully.
node_name: The device's own mesh node name, if known; preserved across
reconnects and shown in the picker. A blank value keeps any name already
on file for this device rather than erasing it.
hardware_model: The firmware's model string, if known; preserved the same way, so
a reconnect on firmware that couldn't answer the query doesn't erase a model
learned earlier.
"""
registry, _ = self._read()
existing = registry.get(device.stable_id)
if not node_name and existing is not None:
node_name = existing.node_name
if not hardware_model and existing is not None:
hardware_model = existing.hardware_model
registry[device.stable_id] = RememberedDevice(
stable_id=device.stable_id,
port=device.port,
label=device.label,
last_connected=utcnow().isoformat(),
node_name=node_name,
transport=device.transport,
address=device.address or "",
hardware_model=hardware_model,
host=device.host or "",
tcp_port=device.tcp_port or 0,
)
self._write(registry, device.stable_id)
def forget(self, stable_id: str) -> bool:
"""Drop a remembered device from the registry.
The inverse of :meth:`remember`: removes the record keyed by ``stable_id`` so the
device is no longer listed as a confirmed companion. Used by the device picker's
Delete action to prune a network (TCP) companion the user no longer wants — a TCP
device is listed *only* from its remembered endpoint, so forgetting it is what makes
it leave the picker. If the forgotten device was the most-recently-connected default,
that pointer is handed to the newest surviving record (or cleared when none remain),
so the next startup still preselects a sensible device.
Args:
stable_id: The :attr:`RememberedDevice.stable_id` of the device to forget.
Returns:
``True`` if a record was removed, ``False`` if none matched.
"""
registry, last = self._read()
if stable_id not in registry:
return False
del registry[stable_id]
if last == stable_id:
last = max(registry, key=lambda k: registry[k].last_connected, default=None)
self._write(registry, last)
return True
def _write(self, registry: dict[str, RememberedDevice], last: str | None) -> None:
"""Persist the registry, marking ``last`` as the most recently connected device."""
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"devices": {
stable_id: {
"stable_id": record.stable_id,
"port": record.port,
"label": record.label,
"last_connected": record.last_connected,
"node_name": record.node_name,
"transport": record.transport,
"address": record.address,
"hardware_model": record.hardware_model,
"host": record.host,
"tcp_port": record.tcp_port,
}
for stable_id, record in registry.items()
},
"last": last,
}
write_atomically(self._path, json.dumps(payload, indent=2))