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>
264 lines
11 KiB
Python
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))
|