The mark's art signs itself now, so no splash adds a line under it — the
picker, the smoke-test spinner, the PIN prompt, the failure notices, the
scan and the settings offer all drop the footnote they were passing, and
nothing draws the notice twice. The written About pages keep their {copyright}
colophon: that is a page of prose, not a screen with the mark on it.
Moebius spells an untouched cell as a NUL, which a DOS console draws blank and
Rich measures as nothing at all — so every one swallowed a column and slid the
rest of its row leftwards, eighteen of them on the worst line. They are spaces
in the file now, and the loader translates any that come back (with codepage
437's hard space) so re-exporting the art can't reintroduce the drift.
Those NULs were also the only reason git guessed the file was binary. With them
gone, .gitattributes has to say so outright, before core.autocrlf starts
rewriting CRLFs that are part of the canvas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
173 lines
7.2 KiB
Python
173 lines
7.2 KiB
Python
"""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")
|