Files
MeshTerm/meshterm/ui/settings_offer.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

174 lines
7.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The connect-time offer to reconcile a device's settings with what MeshTerm remembers.
A firmware-less radio bridge forgets its configuration whenever it restarts, so the settings you
changed through MeshTerm last session come back as firmware defaults. Unlike channels — silently
replayed because filling an empty slot overwrites nothing — a setting is a single canonical
value, so restoring a stale one could clobber a change made elsewhere. This surface therefore
*asks* rather than acting: on connect, when the device's current settings drift from what
MeshTerm saved for it, it offers to restore the saved values, adopt the device's current ones, or
stop remembering the device (see :mod:`meshterm.core.settings_store`).
One drift is the channel case in disguise and acts silently (JP, 2026-08-08): a manually set
position on a device with no GPS. After a restart such a device reports no usable fix at all —
an *empty slot*, not a competing value — so the saved position is written straight back instead
of asking the operator whether to override a position with an undefined one.
It runs once at startup, on the splash, only when a device is connected and something is actually
remembered for it — so a firmware radio you don't manage through MeshTerm is never interrupted.
"""
from __future__ import annotations
from rich.text import Text
from ..context import AppContext
from ..core.device_config import build_snapshot, get_spec
from ..core.geo import usable_fix
from ..core.settings_store import SettingDrift, adopt, restore, settings_drift
from .menus import menu_rows
from .tui import Separator
# The offer's actions, returned by the startup select.
_RESTORE = "restore"
_ADOPT = "adopt"
_FORGET = "forget"
#: The advertised-position pair — the settings whose drift is judged as one fix, not two
#: scalars, against :func:`_position_undefined`.
_POSITION_KEYS = frozenset({"adv_lat", "adv_lon"})
def _position_undefined(snapshot: dict) -> bool:
"""Whether the device currently advertises no usable fix at all.
The same :func:`~meshterm.core.geo.usable_fix` judgment the map surfaces make: the
0/0 null island a GPS-less (or freshly restarted) device reports is no position, and
out-of-range junk is no position either.
"""
try:
lat = float(snapshot.get("adv_lat") or 0.0)
lon = float(snapshot.get("adv_lon") or 0.0)
except (TypeError, ValueError):
return True
return not usable_fix(lat, lon)
async def offer_remembered_settings(ctx: AppContext) -> None:
"""Offer to reconcile the connected device's settings with MeshTerm's saved copy.
Best-effort and quiet: does nothing without a live link, without anything remembered for the
device, or when the device already matches. Never opens the radio itself — a deferred connect
(``connect_on_start`` off) simply skips this, since ``is_connected`` is false. A failure here
must not block reaching the menu, so everything is guarded.
Args:
ctx: The shared application context.
"""
store = ctx.settings_store
if store is None or not ctx.is_connected:
return
try:
device = await ctx.device()
info = await device.get_self_info()
pubkey = str(info.get("public_key") or "")
if not store.settings(pubkey):
return # nothing remembered — a firmware radio pays only this cheap probe
snapshot = await build_snapshot(device)
drifted = settings_drift(store, pubkey, snapshot)
except Exception as exc: # noqa: BLE001 - a probe failure must not block the menu
ctx.log.debug("settings: drift check on connect failed: %s", exc)
return
if not drifted:
return
# A drifted position on a device that reports none is the empty-slot case: restoring
# it overwrites nothing, and the alternative on offer — "keep the device's settings"
# — would mean trading a deliberately set position for an undefined one. So those
# keys restore silently, and only real value-against-value conflicts reach the
# operator.
if _position_undefined(snapshot):
silent = [d for d in drifted if d.key in _POSITION_KEYS]
if silent:
try:
restored = await restore(store, device, snapshot, [d.key for d in silent])
ctx.devstate.invalidate_config()
ctx.log.info(
"settings: restored the saved position (%d value(s)) — the device "
"reported no fix to weigh it against",
restored,
)
except Exception as exc: # noqa: BLE001 - best-effort, like the rest of the offer
ctx.log.debug("settings: silent position restore failed: %s", exc)
drifted = [d for d in drifted if d.key not in _POSITION_KEYS]
if not drifted:
return
choice = await _prompt(ctx, drifted)
keys = [d.key for d in drifted]
try:
if choice == _RESTORE:
restored = await restore(store, device, snapshot, keys)
ctx.devstate.invalidate_config()
ctx.log.info("settings: restored %d remembered setting(s) to the device", restored)
elif choice == _ADOPT:
adopt(store, pubkey, snapshot, keys)
ctx.log.info("settings: adopted the device's values for %d setting(s)", len(keys))
elif choice == _FORGET:
store.forget_all(pubkey)
ctx.log.info("settings: stopped remembering this device's settings")
except Exception as exc: # noqa: BLE001 - acting on the choice must not block the menu
ctx.log.debug("settings: reconciling on connect failed: %s", exc)
async def _prompt(ctx: AppContext, drifted: list[SettingDrift]) -> object:
"""Show the startup offer and return the chosen action, or ``None`` if dismissed."""
from .logo import load_logo
count = len(drifted)
plural = "" if count == 1 else "s"
rows = menu_rows(
[
(
"📂 Restore my saved settings",
f"Write the {count} saved value{plural} back onto the device",
_RESTORE,
),
(
"💾 Keep the device's settings",
"Update my saved copy to match the device now",
_ADOPT,
),
(
"🗑 Stop remembering this device",
"Forget the saved settings and don't ask again",
_FORGET,
),
]
)
items = [
Separator(
f" This device reports {count} setting{plural} that differ from what "
"you last saved through MeshTerm:"
),
Separator(_drift_summary(drifted)),
Separator(" "),
*rows,
]
return await ctx.ui.select_startup(
"Saved settings differ",
items,
default=_RESTORE,
banner=load_logo(),
)
def _drift_summary(drifted: list[SettingDrift], *, cap: int = 6) -> Text:
"""A muted one-line list of the drifted settings' labels, capped with a ``+N more`` tail."""
labels = [get_spec(d.key).label for d in drifted]
shown = labels[:cap]
text = " " + ", ".join(shown)
if len(labels) > cap:
text += f", +{len(labels) - cap} more"
return Text(text, style="muted")