Files
Jean-Pierre MartineauandClaude Haiku 5.5 723f7bdfef The identity key shows, imports from a file, and imports on an SPI radio
Show private key drew its warning and no key in the menu since 0.10.0.
On the uConsole and the Cardputer Zero, MeshTerm now writes an imported
key into the node's identity.key and restarts the node. An import also
reads a file (hex text or raw bytes), expands a 32-byte seed, refuses a
key of the wrong length before the confirmation, and names a firmware
that has its import turned off. The run log no longer stores the key of
config import-key (a migration removes the stored ones), and an exported
key file is written with mode 0600. Refer to #26.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-12 23:51:03 -04:00

1493 lines
63 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""The Device config page: all the settings of the companion, and the operations on the device.
This page is behind the ``config`` tool (:func:`edit_config`). On purpose, its shape is the
same as the repeater-admin page (:mod:`meshterm.ui.repeater_admin`): the same full-screen
list, the same setting, value, and description lanes under category headings, staged
``current → new`` values, Apply on the page, and an **Actions** section at the end of the
list. Thus the configuration of the device in your hand looks the same as the
configuration of a repeater over the mesh.
* **Settings**: each value that the companion firmware lets an app read and write (refer
to :data:`~meshterm.core.device_config.DEVICE_SETTINGS`), and each custom variable that
the device reports. When the user edits a row, the new value is *staged*. Nothing goes
to the device until *Apply*. Apply sends the staged values one at a time, and the page
stays open. If the device refuses a value, that value stays staged with its reason, the
same as on the remote page. If the user leaves with staged changes, the page asks
before it discards them.
* **Actions**: the operations on the device itself, not on a value: re-read, clock sync,
backup and restore, the identity key, reboot, and factory reset. These *run
immediately*. Each action except the re-read is behind its own prompt or confirmation
(the destructive actions are behind a typed word). The actions use the same snapshot
and the same :func:`~meshterm.tools.config.apply_ops` executor as Apply. The everyday
advert has its own entry in the main menu instead (the ``advert`` tool, through
:func:`send_advert`). It is one key press away, as a dialog over the menu.
The user selects multiple-choice values in dialogs (booleans as a pair of On and Off
buttons, enums as a floating select list). The user can set the location of the node with
a point on the full-screen map (refer to :class:`~meshterm.ui.map_screen.LocationPickScreen`).
A reboot goes to the same reconnect dialog that the app shows when a device is unplugged.
(Channels have their own full manager: refer to the ``channels`` tool.)
"""
from __future__ import annotations
import asyncio
import textwrap
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import quote
from rich import box
from rich.cells import cell_len
from rich.markup import escape
from rich.table import Table
from rich.text import Text
from ..core.device_config import (
DEVICE_SETTINGS,
DeviceConfigError,
SettingSpec,
build_snapshot,
effective_maximum,
format_value,
get_spec,
parse_value,
settings_by_category,
)
from ..core.privatekey import parse_private_key, read_private_key_file
from ..platforms import Platform, on_platform
from .device_info_screen import REVEAL_KEY
from .marks import MASK_MARK
from .menus import (
PICK_LOCATION_HELP,
PICK_LOCATION_LABEL,
confirm_discard,
exit_rows,
icon_lane,
lane_header,
lane_row,
marked_label,
menu_rows,
run_steps,
section_heading,
)
from .tui import Choice, SelectScreen, Separator
from .tui.select import splice_hint
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
# Sentinels for menu actions (different from setting keys, which are plain strings).
_LOCATION = "__location__"
_PRESETS = "__presets__"
_CUSTOM = "__custom__"
#: The prefix of the row value of a custom variable. The name of the variable follows it.
_CUSTOM_VAR = "__custom_var__:"
_READ = "__read__"
_SYNC_CLOCK = "__sync_clock__"
_REBOOT = "__reboot__"
_BACKUP = "__backup__"
_RESTORE = "__restore__"
_IDENTITY_KEY = "__identity_key__"
_RESET = "__reset__"
_APPLY = "__apply__"
_CANCEL = "__cancel__"
# The sentinel for the "enter a value myself" choice, on prompts for enums that are not
# strict.
_OTHER = "__other__"
#: The setting keys that the map selection sets together (each one also has its own row,
#: for a typed value).
_COORD_KEYS = ("adv_lat", "adv_lon")
#: How long the reboot flow holds the page after it announces the reboot (seconds). It
#: waits for the disconnect watcher of the session to cancel it and to open the reconnect
#: dialog. The watcher wakes on the announcement itself. Thus this time runs out only
#: where no watcher runs.
_REBOOT_HANDOFF_TIMEOUT_S = 5.0
async def cached_snapshot(ctx: AppContext, device: Device) -> dict:
"""A snapshot of the device settings for a screen that opens, from the devstate cache.
Devstate already holds two facts: ``SELF_INFO`` and the path-hash mode. To ask the
device for them again is the slowest part of
:func:`~meshterm.core.device_config.build_snapshot`. Each apply of the settings
invalidates them (refer to ``tools/config.py``), so a screen that opens can trust the
cache. The refresh after a restore, a key change, or a factory reset still calls
``build_snapshot(device)`` directly, because there the state of the device really
changed.
"""
self_info: dict | None = None
path_hash_mode: int | None = None
try:
self_info = await ctx.devstate.self_info()
path_hash_mode = await ctx.devstate.path_hash_mode()
except Exception: # noqa: BLE001 - use the raw reads below instead
pass
from ..tools.config import learn_default_scope
snapshot = await build_snapshot(device, self_info=self_info, path_hash_mode=path_hash_mode)
learn_default_scope(ctx, snapshot)
return snapshot
async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
"""Run the Device config page until the user leaves it.
There is one screen for the full visit. Its rows change in place after each round:
they have the staged ``current → new`` values, and the title counts them. Thus the
content changes under a highlight and a typed filter that stay where the user put
them. Each sub-prompt floats over the page. Apply sends the staged values, and the
page stays (refer to :func:`_apply`). Each action runs where the user selects it. Esc
leaves, and asks first while some values are still staged. A reboot closes the page
and gives control to the reconnect dialog of the session.
Args:
ctx: The shared application context (gives the connected device and the UI
surface).
Returns:
``{"applied": n}`` when staged values got to the device during the visit, or
``None`` when none did.
"""
from .tui import CANCEL
device = await ctx.device()
snapshot = await cached_snapshot(ctx, device)
custom = await device.get_custom_vars()
session = getattr(ctx.ui, "session", None)
if session is None: # pragma: no cover - the only caller is in the menu
raise RuntimeError("the config editor is only available in the menu")
pending: dict[str, Any] = {} # setting key -> staged new value
custom_pending: dict[str, str] = {} # custom variable name -> staged new value
applied = 0
def staged() -> int:
return len(pending) + len(custom_pending)
def summary() -> dict[str, Any] | None:
return {"applied": applied} if applied else None
# The rows read ``snapshot`` and ``custom`` through this closure. Thus the next refresh
# draws exactly what a re-read binds to them.
menu = _ConfigMenu(
session,
lambda reveal: _menu_items(
snapshot,
pending,
staged(),
reveal,
custom=custom,
custom_pending=custom_pending,
),
conceals=has_pin(snapshot),
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
if choice is CANCEL: # Esc at the page
choice = _CANCEL
if choice in (None, _CANCEL):
if staged() and not await confirm_discard(ctx, staged(), verb="applying"):
continue # continue to edit: the same page, at the same place in it
return summary()
if choice == _APPLY:
applied += await _apply(ctx, device, snapshot, pending, custom_pending)
snapshot, custom = await _reread(ctx, device)
elif choice == _READ:
snapshot, custom = await _reread(ctx, device)
_unstage_held(snapshot, custom, pending, custom_pending)
elif choice == _SYNC_CLOCK:
await _sync_clock(ctx, device, snapshot)
elif choice == _BACKUP:
await _backup_now(ctx, device, snapshot)
elif choice == _RESTORE:
if await _restore_now(ctx, device, snapshot):
snapshot, custom = await _reread(ctx, device)
_unstage_held(snapshot, custom, pending, custom_pending)
elif choice == _IDENTITY_KEY:
if await _identity_key_menu(ctx, device, snapshot, staged()):
if device.import_restarts and ctx.active_transport is not None:
# The node reads its new key only when it starts. The reconnect of
# the session stops this node and starts a new one, as after a
# reboot, and the visit ends with it (refer to _reboot).
ctx.announce_reboot()
await asyncio.sleep(_REBOOT_HANDOFF_TIMEOUT_S)
return summary()
snapshot, custom = await _reread(ctx, device)
elif choice == _REBOOT:
# A reboot ends the visit, and the staged values go with it. Thus ask first.
# If the user agrees to the discard, the values are discarded, whatever the
# reboot does.
if staged():
if not await confirm_discard(ctx, staged(), verb="applying"):
continue
pending.clear()
custom_pending.clear()
if await _reboot(ctx, device, snapshot):
return summary() # the link goes down, and the reconnect dialog takes control
elif choice == _RESET:
if await _factory_reset(ctx, device, snapshot):
# All the staged values were staged against a device that does not exist
# now.
pending.clear()
custom_pending.clear()
snapshot, custom = await _reread(ctx, device)
elif choice == _LOCATION:
await _stage_location(ctx, snapshot, pending)
elif choice == _PRESETS:
await _stage_preset(ctx, snapshot, pending)
elif choice == _CUSTOM:
await _stage_custom_var(ctx, custom, custom_pending)
elif isinstance(choice, str) and choice.startswith(_CUSTOM_VAR):
await _stage_var(ctx, choice.removeprefix(_CUSTOM_VAR), custom, custom_pending)
else: # a setting key
await _stage_setting(ctx, choice, snapshot, pending)
menu.conceal_pin(has_pin(snapshot))
menu.refresh()
# --- rendering ---------------------------------------------------------------
def config_table(
snapshot: dict,
custom: dict[str, str],
pending: dict[str, Any] | None = None,
*,
reveal_pin: bool = False,
) -> Table:
"""Build the full table of the settings, with the staged changes over it.
Args:
snapshot: The device snapshot from ``build_snapshot``.
custom: The current custom variables.
pending: Optional staged changes (setting key -> new value).
reveal_pin: ``True`` to print the BLE pairing PIN. By default, it is concealed.
This table is the dump of the full device: people read it over a shoulder,
take screenshots of it, and paste it into a bug report. And a pairing code is
the one value in it that lets the phone of another person connect to the
device. The Device info page shows it on a key press (refer to
:class:`~meshterm.ui.device_info_screen.DeviceInfoScreen`). The CLI shows it
only as one value, with ``meshterm config get device_pin``.
Returns:
A Rich :class:`Table` with the current (and staged) value of each setting, and
the custom variables, ready for ``ctx.ui.show`` or ``ctx.ui.view``.
"""
pending = pending or {}
show_staged = bool(pending)
# The DESCRIPTION lane must have a screen that is wide enough for text next to the
# setting and its value or values: 72 cells, and no third narrow lane. Where it does
# not fit, the table has only the settings (at those widths, Rich already removed the
# column completely), and the explanations stay in the editor, one row at a time.
describe = _describe and not show_staged
# The same as the contacts list: a SIMPLE_HEAD table with no frame, a left-justified
# accent title, and muted headers. Thus the two screens look like one family (refer to
# widgets.contacts_table).
table = Table(
title="[accent]Device config[/accent]",
title_justify="left",
box=box.SIMPLE_HEAD,
show_edge=False,
pad_edge=False,
header_style="muted",
expand=False,
padding=(0, 2, 0, 0),
)
# The two narrow lanes are ``no_wrap``, and they are folded first to a cap (refer to
# _fold_label and _fold_value). Thus each lane takes exactly the width of its cap, and
# DESCRIPTION (the lane whose content is always the widest) keeps all the remaining
# space. When the labels and the values had their natural size, the descriptions got
# 19 cells of a 72-cell screen, and almost each description wrapped to three lines.
table.add_column("SETTING", style="muted", no_wrap=True)
table.add_column("CURRENT", no_wrap=True)
if show_staged:
table.add_column("STAGED", style="warn", no_wrap=True)
if describe:
table.add_column("DESCRIPTION", style="muted")
def row_of(cells: list[str], description: str) -> list[str]:
"""One table row: the narrow lanes, and the description where it is drawn."""
return [*cells, description] if describe else cells
for category, specs in settings_by_category():
table.add_section()
header = [f"[accent]── {category} ──[/accent]", ""]
if show_staged:
header.append("")
table.add_row(*row_of(header, ""))
for spec in specs:
current = format_value(spec, spec.getter(snapshot))
if spec.key == PIN_KEY and not reveal_pin:
current = conceal(current)
row = [_fold_label(spec.label, describe), _fold_value(current, describe)]
if show_staged:
row.append(
_fold_value(format_value(spec, pending[spec.key]), describe)
if spec.key in pending
else ""
)
table.add_row(*row_of(row, spec.help))
if custom:
table.add_section()
blanks = 2 if show_staged else 1
table.add_row(*row_of(["[accent]── Custom ──[/accent]", *([""] * blanks)], ""))
for key, value in custom.items():
row = [_fold_label(key, describe), _fold_value(value, describe)]
if show_staged:
row.append("")
table.add_row(*row_of(row, ""))
return table
#: The one setting that this table conceals: the BLE pairing PIN. The firmware reports it
#: as ``ble_pin``, and :data:`~meshterm.core.device_config.DEVICE_SETTINGS` gives it the
#: key ``device_pin``.
PIN_KEY = "device_pin"
#: The width of a concealed PIN: the width of the field, from the upper bound of the
#: setting itself (999999), not the length of the value in it. A typed password has one
#: mask for each character, because the person who types must count the characters. A
#: stored PIN shows its field. Thus nobody can read from the row how many digits to guess.
#: Also, a PIN of ``0`` behind one bullet looks like a stray dot, not like a concealed
#: value.
_PIN_WIDTH = len(str(get_spec(PIN_KEY).maximum))
def conceal(value: str, *, absent: str = "?") -> str:
"""``value`` as a row of :data:`_PIN_WIDTH` mask bullets.
A value that the device could not report is not a secret. It is an absence:
``format_value`` already renders it as ``?``. A ``?`` behind bullets says that there
is something to show when there is nothing. Thus only a real value is concealed.
:func:`has_pin` asks the same question in the opposite direction: a page with nothing
to conceal offers no keyboard key for it.
Args:
value: The value to mask, already rendered.
absent: How this surface writes "the device never reported one". The token of the
menu is ``?``. The token of the scripted CLI is
:data:`~meshterm.ui.script.NONE`. The rule is the same on the two surfaces,
but the glyph is not, so the caller names it. A ``conceal`` that knew only the
token of the menu also masked the absence in the CLI, and six bullets told the
user that the device had a PIN lock when it did not.
"""
return value if value == absent else MASK_MARK * _PIN_WIDTH
def has_pin(snapshot: dict) -> bool:
"""``True`` if ``snapshot`` has a PIN, that is, if a concealed PIN has a meaning."""
return get_spec(PIN_KEY).getter(snapshot) is not None
#: The cell caps for the two narrow lanes of the table, with the indent. They have the
#: size of the widest label and value of the settings, without the few longest. The four
#: that overflow fold onto a second line. This costs nothing on rows whose description
#: wraps in all cases, and it gives each other row ten more cells of text.
_SETTING_CAP = 24
_VALUE_CAP = 15
#: The name of each setting is indented two spaces, so that the rows look like they are
#: under their accent section heading, which stays flush left. A folded continuation line
#: is indented two more spaces. Thus a wrapped label never looks like a heading at
#: column 0.
_INDENT = " "
_HANG = " "
def _fold_label(label: str, describe: bool) -> str:
"""One SETTING cell: indented, and hard-wrapped at :data:`_SETTING_CAP` if necessary.
The cap only gives space to the DESCRIPTION lane. Thus where that lane is not drawn,
the label keeps its natural width: nothing else uses the cells.
"""
if not describe:
return f"{_INDENT}{label}"
return (
"\n".join(
textwrap.wrap(label, _SETTING_CAP, initial_indent=_INDENT, subsequent_indent=_HANG)
)
or _INDENT
)
def _fold_value(value: str, describe: bool) -> str:
"""One CURRENT or STAGED cell, hard-wrapped at :data:`_VALUE_CAP`.
An enum value shows as ``N (label)``. Thus the fold prefers the break before the label
in parentheses: ``1`` above ``(2-byte hashes)``, not a torn ``1 (2-byte``. And a long
node name (the field is 31 bytes wide) folds, and does not make the lane as wide as
the screen.
"""
if not describe or cell_len(value) <= _VALUE_CAP:
return value
head, sep, tail = value.partition(" (")
if sep and cell_len(head) <= _VALUE_CAP and cell_len(tail) + 1 <= _VALUE_CAP:
return f"{head}\n({tail}"
return "\n".join(textwrap.wrap(value, _VALUE_CAP)) or value
#: ``True`` if the screen of this platform is wide enough for a DESCRIPTION lane. On the
#: PicoCalc, the setting and its value use all the 53 cells.
_describe: bool = True
@on_platform
def _bind_describe(platform: Platform) -> None:
"""Bind the DESCRIPTION lane to the platform (now, and at each platform change)."""
global _describe
_describe = platform.readable_cols >= 72
def _setting_value(
spec: SettingSpec, snapshot: dict, pending: dict, reveal_pin: bool = False
) -> Text:
"""The VALUE lane of one setting: ``current [→ staged]``.
The staged arrow is drawn in the warn style, so that a row with a staged change is
easy to see. The span stays under the select highlight (which tints only the base
style of the row).
The pairing PIN is concealed here on the same terms as in :func:`config_table`, also
a staged PIN. A PIN that you typed a short time ago is still a PIN. A row that shows
its secret at the moment when the user edits it leaves the secret on the screen for
the rest of the staging in the session.
"""
hide = spec.key == PIN_KEY and not reveal_pin
current = spec.getter(snapshot)
# The words of the repeater page for the two non-values: ``?`` for never reported, and
# ``empty`` for a string that the device holds blank. They are muted, and never in
# parentheses.
if current is None:
value = Text("?", style="muted")
elif spec.value_type == "str" and current == "":
value = Text("empty", style="muted")
else:
shown = format_value(spec, current)
value = Text(conceal(shown) if hide else shown)
if spec.key in pending:
new = pending[spec.key]
if spec.value_type == "str" and new == "":
staged = "empty"
else:
staged = format_value(spec, new)
staged = conceal(staged) if hide else staged
value.append(f" → {staged}", style="warn")
return value
class _ConfigMenu(SelectScreen):
"""The list of the editor, with the row of the pairing PIN concealed until ``^S`` shows it.
It does all that a grouped select list does. It also has the same toggle as the
Device info page: the same chord, the same chip, and the same words. The two pages
show the same value. A secret that comes out under a different key on each page is a
second thing to learn, with no benefit. Here the key must be a chord: this list
filters as you type, so each bare letter already has a use.
The rows are data (each row has its staged ``current → new``). Thus a toggle changes
them in place through :meth:`~meshterm.ui.tui.select.SelectScreen.replace_items`,
which follows the highlighted row by value and keeps the typed filter. The toggle does
not build the screen again under a user who was part of the way down it.
It is a full-screen page, not a floating dialog, the same as the repeater-admin page
that it copies. All the settings of a device, with its actions, are a place where the
user works for some time. The value prompts, the confirms, and the results float over
it.
"""
floating = False
def __init__(
self,
session, # noqa: ANN001 - TuiSession, imported lazily to prevent an import cycle
build: Callable[[bool], tuple[str, list]],
*,
conceals: bool,
**kwargs: Any,
) -> None:
"""Build the list over its row builder.
Args:
session: The running TUI session (it paints again when the PIN is toggled).
build: Renders ``(title, items)`` for a given ``reveal_pin``.
conceals: ``True`` if this device reports a PIN. ``False`` gives the page
nothing to show, so the footer and the lane offer no keyboard key for it.
**kwargs: Passed to :class:`~meshterm.ui.tui.select.SelectScreen`.
"""
title, items = build(False)
# The lanes end at the edge, and do not slide. The Actions rows pin a head block
# (menus.menu_rows). That alone turns on ←→ scrolling for the whole list. If ←→
# scrolling is on, a setting row, which pins nothing, slides its label out with
# its description.
kwargs.setdefault("hscroll", False)
super().__init__(title, items, **kwargs)
self._session = session
self._build = build
self._conceals = conceals
self._revealed = False
def conceal_pin(self, conceals: bool) -> None:
"""Set if the device reports a PIN now. A reset or a restore can change it."""
self._conceals = conceals
if not conceals:
self._revealed = False
def refresh(self) -> None:
"""Build the rows again from the staged values. Keep the reveal, highlight, and filter."""
title, items = self._build(self._revealed)
self.replace_items(items, title=title)
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The hint of the list itself, with the reveal atom before the Esc atom at the end."""
base = super().footer_hint
if not self._conceals:
return base
verb = "hide" if self._revealed else "show"
return splice_hint(base, f"{REVEAL_KEY} {verb} PIN")
@property
def picocalc_lyra_lane(self):
"""The lane of the list, with the reveal on F3, which a list with no Delete leaves free.
F1 and F2 are the section jumps of this grouped list, and F4 and F5 are the pager.
A select list uses F3 for its own verb (``Delete``, where it has one), and the verb
of this list is the reveal. A device with no PIN leaves the slot empty, not dim,
because the action does not exist on this page.
"""
from .tui.fkeys import FPair
lane = list(super().picocalc_lyra_lane)
if self._conceals:
lane[2] = FPair("Hide" if self._revealed else "Reveal", "reveal")
return lane
def handle(self, action: str, data: str = "") -> None:
"""Toggle the PIN, or act the same as each select list."""
if action == "reveal":
if not self._conceals:
return
self._revealed = not self._revealed
self.refresh()
self._session.invalidate()
return
super().handle(action, data)
def _menu_items(
snapshot: dict,
pending: dict,
staged: int,
reveal_pin: bool = False,
*,
custom: dict[str, str] | None = None,
custom_pending: dict[str, str] | None = None,
) -> tuple[str, list]:
"""Build the title and the rows of the page for the current snapshot and staged state.
The settings are in three aligned lanes (the setting, the current value with any
staged new value, and the description) under one header line, grouped by category.
The custom variables of the device follow, in the same lanes. The **Actions** are at
the end of the list. This is the same shape as the repeater-admin page (refer to
:func:`meshterm.ui.repeater_admin._menu_items`), so that the two pages look like one
editor.
``reveal_pin`` is the toggle of :class:`_ConfigMenu`, passed directly to the value
lane of the PIN. ``custom`` holds the variables that the device reports, and
``custom_pending`` holds the values staged for them.
"""
custom = custom or {}
custom_pending = custom_pending or {}
# First pass: collect the lanes of each row for each category. Thus the columns get the
# size of their content (with any staged ``→ new`` arrows) before any row is built.
sections: list[tuple[str, list[tuple[str, Text, str, Any]]]] = []
for category, specs in settings_by_category():
rows: list[tuple[str, Text, str, Any]] = []
for spec in specs:
if spec.key == "adv_lat":
# The map selection sets the two coordinates at the same time, so it comes
# before the pair that it fills. It is the same row, at the same place, as on
# the repeater admin page.
rows.append((PICK_LOCATION_LABEL, Text(), PICK_LOCATION_HELP, _LOCATION))
rows.append(
(
spec.label,
_setting_value(spec, snapshot, pending, reveal_pin),
spec.help,
spec.key,
)
)
if category == "Radio":
rows.append(
(
"Radio presets…",
Text(),
"Stage MeshCore's settings for a region",
_PRESETS,
)
)
sections.append((category, rows))
# Custom variables: each variable that the firmware or the sensors of this board report,
# staged the same as a setting. (The variables of the firmware itself, for GPS, have a
# name and a type: refer to _KNOWN_VARS.) Also each new name staged by hand, and the
# row to stage one.
var_rows: list[tuple[str, Text, str, Any]] = []
for name in sorted(set(custom) | set(custom_pending)):
label, help_text, _kind = _KNOWN_VARS.get(name, (name, "A firmware variable", "str"))
value = _custom_value(name, custom, custom_pending)
var_rows.append((label, value, help_text, _CUSTOM_VAR + name))
var_rows.append(("Set by name…", Text(), "Set a raw firmware variable by name", _CUSTOM))
sections.append(("Custom variables", var_rows))
label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows)
value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)
# The shared editor header (menus.lane_header): each label is above its own lane, clear
# of the pointer column. Where the terminal is too narrow for the full words, a label is
# abbreviated, not wrapped. The header is pinned for the full list, because the lanes
# have the same meaning in each category. Thus a scrolled row keeps its column header
# and its section heading above it (refer to Screen.sticky_rows).
items: list = [Separator(lambda w: lane_header(label_w, value_w, w), pinned=True)]
for category, rows in sections:
items.append(section_heading(category))
for label, value, help_text, key in rows:
items.append(
Choice(title=lane_row(label, value, help_text, label_w, value_w), value=key)
)
# The operations on the device itself. They run immediately when the user confirms
# them. They are the local counterpart of the Actions of the repeater page, built in the
# same way. ↻ and ⚠ are one cell wide, and 🕒 💾 📂 🔐 🔄 are two. Thus the column is
# measured once, and each mark is padded to it. The one irreversible row is tinted, on
# its mark (or on its words, when there are no icons).
actions = [
("↻", "Read settings", "Re-read every value from the device", _READ),
("🕒", "Sync clock…", "Set the device clock from this computer", _SYNC_CLOCK),
("💾", "Back up config…", "Write every setting to a TOML file", _BACKUP),
("📂", "Restore config…", "Preview or apply a saved TOML backup", _RESTORE),
("🔐", "Identity key…", "Export or import the node's private key", _IDENTITY_KEY),
("🔄", "Reboot device…", "Restart the companion and reconnect", _REBOOT),
("⚠", "Factory reset…", "Erase everything (typed confirmation)", _RESET),
]
lane = icon_lane(icon for icon, _, _, _ in actions)
items.append(section_heading("Actions"))
items.extend(
menu_rows(
(
marked_label(icon, label, "err" if value == _RESET else "", lane=lane),
help_text,
value,
)
for icon, label, help_text, value in actions
)
)
# No rows while nothing is staged: Esc leaves, and we removed the row that said so from
# the full app. When changes are staged, the pair shows below one blank line. Apply has
# no keyboard key of its own, and Back says what the user loses if they leave (refer
# to menus.exit_rows).
items.extend(exit_rows(staged, apply_value=_APPLY, back_value=_CANCEL))
name = str(snapshot.get("name") or "")
title = "Device config" + (f" — {name}" if name else "")
if staged:
title += f" · {staged} staged"
return title, items
# --- stage each change -------------------------------------------------------
async def _stage_setting(
ctx: AppContext, key: str, snapshot: dict, pending: dict[str, Any]
) -> None:
"""Ask for the new value of one setting, and stage it."""
spec = get_spec(key)
current = pending.get(key, spec.getter(snapshot))
value = await _prompt_value(ctx, spec, current, snapshot)
if value is None:
return
# A typed value was checked while the user typed it. A selected value (relaying on or
# off) is checked here, against the settings as staged, not only as the device still
# holds them.
complaint = spec.validate(value, {**snapshot, **pending}) if spec.validate else None
if complaint:
ctx.ui.note(f"[err]✗[/err] {escape(complaint)}")
await ctx.ui.present(title=spec.label)
return
if value == spec.getter(snapshot):
pending.pop(key, None) # set back to the value of the device: nothing to change
else:
pending[key] = value
def _range_hint(spec: SettingSpec, snapshot: dict) -> str:
"""A muted hint with the range of values (``Allowed: …``) for a numeric prompt.
It uses the *effective* maximum: the bound that the device reports (for example, the
maximum TX power of this board) when the spec names one, else the static bound. Thus
the hint promises exactly what the validation will accept.
"""
maximum = effective_maximum(spec, snapshot)
if spec.minimum is not None and maximum is not None:
return f"Allowed: {spec.minimum:g} – {maximum:g}"
if spec.minimum is not None:
return f"Allowed: ≥ {spec.minimum:g}"
if maximum is not None:
return f"Allowed: ≤ {maximum:g}"
return ""
async def _prompt_value(ctx: AppContext, spec: SettingSpec, current: Any, snapshot: dict) -> Any:
"""Ask for a typed value for ``spec`` (in the correct dialog). ``None`` on cancel."""
if spec.value_type == "bool":
# A simple choice of two states is clearest as a pair of buttons. The current state
# is the highlighted default, so that Enter does not change a value by accident.
return await ctx.ui.dialog(
spec.help,
[("Off", False), ("On", True)],
title=spec.label,
default=1 if current else 0,
keys={"0": False, "1": True, "n": False, "y": True},
)
if spec.value_type == "enum" and spec.choices is not None:
items: list = [
Choice(
title=f"{k} — {label}" + (" (current)" if k == current else ""),
value=k,
)
for k, label in spec.choices.items()
]
# An enum that is not strict lists the common values to help the user, but it still
# accepts each integer in the range. Thus offer a way to type one.
if not spec.strict_choices:
items.append(Choice(title="Other (enter a value)…", value=_OTHER))
selected = await ctx.ui.select(
spec.label,
items,
prompt=spec.help,
default=current if current in spec.choices else None,
)
if selected is None:
return None
if selected != _OTHER:
return selected
# else: continue to the free-text prompt below.
def validate(text: str) -> bool | str:
try:
parse_value(spec, text, snapshot)
return True
except DeviceConfigError as exc:
return str(exc)
raw = await ctx.ui.text(
spec.label,
prompt=spec.help,
default="" if current is None else str(current),
validate=validate,
help_text=_range_hint(spec, snapshot),
)
return None if raw is None else parse_value(spec, raw, snapshot)
async def _stage_location(ctx: AppContext, snapshot: dict, pending: dict[str, Any]) -> None:
"""Select the advertised location on the map, and stage the two coordinates that it sets.
The map opens on the staged position (or on the position that the device holds). If
there is no position, the map opens on the mesh. Each coordinate keeps its own row for
a typed value: ``0`` in the two rows is the "no fix" of MeshCore, and then the node no
longer advertises a position. The coordinates are staged the same as each other
setting: they go to the device only on Apply.
"""
from .map_screen import coords_or_none, pick_location
lat = pending.get("adv_lat", snapshot.get("adv_lat"))
lon = pending.get("adv_lon", snapshot.get("adv_lon"))
picked = await pick_location(ctx, initial=coords_or_none(lat, lon))
if picked is None:
return
# Six decimals ≈ 0.1 m: more than the precision of the map, and sufficient for an advert.
for key, value in zip(_COORD_KEYS, picked, strict=True):
if round(value, 6) == snapshot.get(key):
pending.pop(key, None) # the value of the device itself: nothing to change
else:
pending[key] = round(value, 6)
async def _stage_preset(ctx: AppContext, snapshot: dict, pending: dict[str, Any]) -> None:
"""Select a MeshCore radio preset, and stage each field that it names, to examine and apply.
The rows are the settings that MeshCore itself suggests (refer to
:data:`~meshterm.core.device_config.RADIO_PRESETS`), with the name in one lane and the
parameters in the other. If a preset agrees with the current tuning of the radio, the
list opens on that preset. This is the "which of these am I on?" information that the
phone app gives. Thus to select a neighbour is a comparison, not a guess. Staged values
count: if the user edited the frequency by hand and then selects a preset, the preset
is compared with the values that Apply will send, not with the values that the radio
still holds.
"""
from ..core.device_config import RADIO_PRESETS, current_preset
items = menu_rows([(p.name, p.summary, i) for i, p in enumerate(RADIO_PRESETS)])
active = current_preset({**snapshot, **pending})
idx = await ctx.ui.select(
"Radio presets",
items,
prompt="Stage a standard set of radio parameters:",
default=RADIO_PRESETS.index(active) if active is not None else None,
)
if idx is None:
return
pending.update(RADIO_PRESETS[idx].as_settings())
async def _stage_custom_var(
ctx: AppContext, custom: dict[str, str], custom_pending: dict[str, str]
) -> None:
"""Ask for the name of a custom or experimental variable, and stage a value for it.
The known variable names are offered as suggestions, so that the user can recall an
existing name without typing it again. Each new name is accepted as free text.
The name, then the value, as a stack (:func:`~meshterm.ui.menus.run_steps`). Esc on
the value goes back to the name that it is for. It does not discard the two answers
and start again.
"""
answers = await run_steps(
[
lambda vals: _ask_custom_name(ctx, custom, vals[0]),
lambda vals: ctx.ui.text(
"Custom variable",
prompt=f"Value for {vals[0]}:",
default=custom.get(vals[0], "") if vals[1] is None else vals[1],
),
]
)
if answers is None:
return
key, value = answers
if value == custom.get(key):
custom_pending.pop(key, None) # the device already holds this value: nothing to send
else:
custom_pending[key] = value
async def _ask_custom_name(
ctx: AppContext, custom: dict[str, str], previous: str | None
) -> str | None:
"""The step for the variable name: suggestions where there are some, else free text.
Args:
ctx: The shared application context.
custom: The variables that the device already reports, offered for recall.
previous: The value that this step returned the last time. When the user comes
back to this step, it opens on that value.
Returns:
The trimmed name, or ``None`` if it was blank or cancelled.
"""
prompt = "Name of the firmware variable to set:"
if custom:
typed = await ctx.ui.autocomplete(
"Custom variable", sorted(custom), prompt=prompt, default=previous or ""
)
else:
typed = await ctx.ui.text("Custom variable", prompt=prompt, default=previous or "")
return typed.strip() if typed and typed.strip() else None
#: The custom variables that the companion firmware itself defines (``CMD_SET_CUSTOM_VAR``
#: in ``examples/companion_radio/MyMesh.cpp`` of MeshCore): ``(label, description, kind)``,
#: so that their rows look like settings. Each other variable that the sensors of a board
#: report is listed under its own name, and edited as text.
_KNOWN_VARS: dict[str, tuple[str, str, str]] = {
"gps": ("GPS", "Run the GPS receiver (boards with one)", "bool"),
"gps_interval": ("GPS interval (s)", "Seconds between GPS position reads", "int"),
}
#: The maximum of the firmware for ``gps_interval`` (``constrain(…, 0, 86400)``): one day.
_GPS_INTERVAL_MAX = 86400
def _custom_value(name: str, custom: dict[str, str], custom_pending: dict[str, str]) -> Text:
"""The VALUE lane of a custom variable: ``current [→ staged]``, in the words of a setting."""
def shown(raw: str) -> str:
if _KNOWN_VARS.get(name, ("", "", "str"))[2] == "bool" and raw in ("0", "1"):
return "on" if raw == "1" else "off"
return raw if raw != "" else "empty"
if name not in custom:
value = Text("?", style="muted") # staged by name, and the device never reported it
elif custom[name] == "":
value = Text("empty", style="muted")
else:
value = Text(shown(custom[name]))
if name in custom_pending:
value.append(f" → {shown(custom_pending[name])}", style="warn")
return value
async def _stage_var(
ctx: AppContext, name: str, custom: dict[str, str], custom_pending: dict[str, str]
) -> None:
"""Ask for the new value of one listed custom variable, and stage it."""
label, help_text, kind = _KNOWN_VARS.get(name, (name, "A firmware variable", "str"))
current = custom_pending.get(name, custom.get(name, ""))
if kind == "bool":
picked = await ctx.ui.dialog(
help_text,
[("Off", "0"), ("On", "1")],
title=label,
default=1 if current == "1" else 0,
keys={"0": "0", "1": "1", "n": "0", "y": "1"},
)
if picked is None:
return
value = str(picked)
else:
extra: dict[str, Any] = {}
if kind == "int":
extra = {
"validate": _valid_interval,
"help_text": f"Allowed: 0 – {_GPS_INTERVAL_MAX}",
}
raw = await ctx.ui.text(label, prompt=f"Value for {name}:", default=current, **extra)
if raw is None:
return
value = raw.strip()
if value == custom.get(name):
custom_pending.pop(name, None) # back to the value of the device: nothing to send
else:
custom_pending[name] = value
def _valid_interval(text: str) -> bool | str:
"""Check for a whole number of seconds inside the GPS-interval bound of the firmware."""
try:
seconds = int(text.strip())
except ValueError:
return "Enter a whole number of seconds."
return True if 0 <= seconds <= _GPS_INTERVAL_MAX else f"Must be 0 – {_GPS_INTERVAL_MAX}."
# --- apply, and read again ------------------------------------------------------
def _staged_ops(pending: dict[str, Any], custom_pending: dict[str, str]) -> list[tuple[str, tuple]]:
"""The staged values as ``(stage key, op)`` pairs, in the order in which to send them.
The settings go in the order of the registry, not in the order in which the user
staged them. Thus a new frequency gets to the device before relaying is turned on for
it. The custom variables come after the settings.
"""
order = {spec.key: i for i, spec in enumerate(DEVICE_SETTINGS)}
ops = [
(key, ("set", key, pending[key]))
for key in sorted((k for k in pending if k in order), key=order.__getitem__)
]
ops += [(name, ("set_custom", name, value)) for name, value in custom_pending.items()]
return ops
async def _apply(
ctx: AppContext,
device: Device,
snapshot: dict,
pending: dict[str, Any],
custom_pending: dict[str, str],
) -> int:
"""Send each staged value, one at a time, and show the answer of the device to each one.
This is the local copy of the Apply of the repeater page. Each value goes through
:func:`~meshterm.tools.config.apply_ops` (the executor that ``config set`` also runs),
in the order of :func:`_staged_ops`. A value that the device accepts leaves the stage.
A value that the device refuses *stays* staged, with the reason next to it, so that
the user can correct it or unstage it. It is never removed silently. A lost link is
not a refusal: it is raised again for the disconnect handling of the app. One ``runs``
row stores the batch. It names the settings, but never their values, because one of
them can be the pairing PIN.
Returns:
The number of staged values that the device accepted.
"""
from ..core.connection import is_connection_lost
from ..tools.config import apply_ops
batch = _staged_ops(pending, custom_pending)
names = [key for key, _op in batch]
run_id = ctx.repo.start_run("config", {"mode": "apply", "settings": names}, ctx.profile_name)
accepted = 0
for (key, op), name in zip(batch, names, strict=True):
try:
await apply_ops(ctx, device, snapshot, [op])
except Exception as exc: # noqa: BLE001 - a refusal is shown, and stays staged
if is_connection_lost(exc):
ctx.repo.finish_run(run_id, "error", {"applied": accepted, "error": str(exc)})
raise
ctx.ui.note(
f"[err]✗[/err] [brand]{escape(name)}[/brand] — {escape(str(exc))} (still staged)"
)
continue
accepted += 1
(custom_pending if op[0] == "set_custom" else pending).pop(key, None)
left = len(batch) - accepted
ctx.repo.finish_run(
run_id, "error" if left else "ok", {"applied": accepted, "staged_left": left}
)
ctx.ui.note(
f"[brand]{accepted}[/brand] of {len(batch)} staged changes applied"
+ (" [warn](the rest stay staged)[/warn]" if left else "")
)
await ctx.ui.present(title="Apply")
return accepted
async def _reread(ctx: AppContext, device: Device) -> tuple[dict, dict[str, str]]:
"""Read the full device again, after an apply or an action changed it under the page.
The read goes directly to the device, not through the session cache, because that
cache is exactly what became stale. Also, this function invalidates the cache, so that
the next screen that trusts it also reads the correct values.
"""
from ..tools.config import learn_default_scope
ctx.devstate.invalidate_config()
async with ctx.ui.busy_overlay():
snapshot = await build_snapshot(device)
custom = await device.get_custom_vars()
learn_default_scope(ctx, snapshot)
return snapshot, custom
def _unstage_held(
snapshot: dict, custom: dict[str, str], pending: dict[str, Any], custom_pending: dict[str, str]
) -> None:
"""Unstage each value that the device already holds. A re-read can find such a value."""
for key in list(pending):
if get_spec(key).getter(snapshot) == pending[key]:
del pending[key]
for name in [n for n, value in custom_pending.items() if custom.get(n) == value]:
del custom_pending[name]
async def _run_now(
ctx: AppContext, device: Device, snapshot: dict, ops: list[tuple], title: str
) -> int:
"""Do ``ops`` on the device immediately, and show the results.
This is the counterpart of the staged Apply path, for an action that runs immediately.
It uses the same executor (:func:`~meshterm.tools.config.apply_ops`), so the notes and
the behaviour are the same. But the output shows immediately, and does not wait until
the tool finishes.
Returns:
The number of changes applied.
"""
from ..tools.config import apply_ops
changes, artifacts, _report = await apply_ops(ctx, device, snapshot, ops)
for artifact in artifacts:
ctx.ui.note(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
await ctx.ui.present(title=title)
return changes
async def send_advert(ctx: AppContext) -> None:
"""Run the Send advert flow, behind the ``advert`` dialog tool of the main menu.
Send an advert (zero-hop or flood), or show the contact card of this node, which the
user can share. Each advert first waits for the end of the transmit cooldown (a flood
advert waits for its own, longer cooldown). When the wait is long enough to notice, it
is a countdown that the user can cancel. Only ``SELF_INFO`` is read here. The advert
command itself never uses the snapshot, and the contact card uses only the name, the
public key, and the advert type. Thus this flow does not do the tuning and path-hash
reads of a full :func:`~meshterm.core.device_config.build_snapshot`, and it stays
fast over Bluetooth.
Args:
ctx: The shared application context (gives the connected device and the UI
surface).
"""
device = await ctx.device()
snapshot = dict(await ctx.devstate.self_info()) # the session cache, with no new read
# No emoji in the title of this floating dialog. A terminal can paint an emoji one
# cell narrower than Rich measures it. Then the title border of the dialog, which has
# the size of its content, is too short, and the backdrop shows through the border.
# The menu row keeps its 📡 icon (drawn in the full-width base, where the wrong
# measurement cannot show). Also filterable=False: a stray key must not make this fixed
# list of three items narrower (and thus change its size).
choice = await ctx.ui.select(
"Send advert",
[
*menu_rows(
[
("Zero-hop", "Announce to direct neighbours", "zero"),
("Flood", "Repeaters rebroadcast it across the mesh", "flood"),
("Share QR / URI", "Show this node's contact card", "share"),
]
),
],
prompt="Announce this node to the mesh:",
filterable=False,
)
if choice is None:
return
if choice == "share":
await _show_contact_card(ctx, snapshot)
return
# An advert is the one transmission that a user sends by hand, again and again. Thus the
# user feels the shared transmit cooldown here (refer to meshterm.ui.cooldown). A wait
# that is long enough to notice becomes a countdown that the user can cancel. A short
# wait occurs with no message.
from .cooldown import wait_for_cooldown
flood = choice == "flood"
if not await wait_for_cooldown(
ctx, action="Flood advert" if flood else "Zero-hop advert", flood_advert=flood
):
return
await _run_now(ctx, device, snapshot, [("advert", flood)], "Advert")
def contact_share_url(name: str, public_key: str, node_type: int = 1) -> str:
"""Build the MeshCore ``meshcore://contact/add`` share URL for this node.
The format of the companion app (refer to the MeshCore ``qr_codes`` document): the
advertised name, the full 32-byte public key as hex, and the node type
(1 = companion, 2 = repeater, 3 = room server, 4 = sensor).
Args:
name: The advertised name of the node.
public_key: The public key of the node, as a hex string.
node_type: The MeshCore advert type byte.
Returns:
A ``meshcore://contact/add?name=…&public_key=…&type=…`` URL.
"""
return (
f"meshcore://contact/add?name={quote(name, safe='')}"
f"&public_key={public_key.lower()}&type={int(node_type)}"
)
async def show_contact_card(
ctx: AppContext, name: str, public_key: str, node_type: int = 1
) -> None:
"""Show the contact card of a node, full-frame: a scannable QR code above the raw share link.
This is the only screen that shares a contact. It is used for our node
(``Share QR / URI`` in the advert menu), and for each contact with a full key
(``Share contact`` on the node detail page). A phone scans the code (or the user
gives the link to someone as text), and the companion app adds the node as a
contact.
Args:
ctx: The shared application context (gives the UI surface).
name: The advertised name of the node (the title of the card, and the name field
of the link).
public_key: The full public key of the node, as hex (the link has no use without
it).
node_type: The MeshCore advert type byte (1 companion, 2 repeater, 3 room
server, 4 sensor).
"""
from .qr import share_screen
await share_screen(ctx, name=name, url=contact_share_url(name, public_key, node_type))
async def _show_contact_card(ctx: AppContext, snapshot: dict) -> None:
"""Show the contact card of our node, from its ``SELF_INFO`` snapshot."""
public_key = str(snapshot.get("public_key") or "")
if not public_key:
ctx.ui.note("[err]the device did not report a public key — nothing to share[/err]")
await ctx.ui.present(title="Share contact")
return
name = str(snapshot.get("name") or "this node")
await show_contact_card(ctx, name, public_key, int(snapshot.get("adv_type") or 1))
async def _reboot(ctx: AppContext, device: Device, snapshot: dict) -> bool:
"""Confirm and reboot the device, then give control to the reconnect dialog of the session.
After the command is sent, MeshTerm declares the link down
(:meth:`~meshterm.context.AppContext.announce_reboot`), and does not wait for it.
Thus the disconnect watcher of the session opens its dialog immediately, with a reboot
label. The watcher waits for the companion to come back, and connects again. This is
exactly the flow for an unplugged device, and nothing else is sent to the board while
it restarts.
Returns:
``True`` if the reboot was sent and the page must close. ``False`` if the user
cancelled (or the simulator, which has no link to drop, absorbed the reboot).
"""
choice = await ctx.ui.dialog(
"Reboot the device now?",
[("Cancel", None), ("Reboot", "reboot")],
title="Reboot device",
default=1,
danger=True,
)
if choice != "reboot":
return False
if ctx.active_transport is None:
# The simulator has no link to drop, and it comes back immediately. Only send the
# command.
await device.reboot()
ctx.ui.note("[warn]device rebooting[/warn]")
await ctx.ui.present(title="Reboot")
return False
await device.reboot()
# The command is sent, so the link is effectively down. Declare it, and do not wait for
# a liveness poll to find it. If MeshTerm waits for a poll, a quick reboot behind a port
# that never disappears is never found, and the next command goes to a board in the
# middle of its restart.
ctx.announce_reboot()
# The watcher of the session now cancels this task and opens the reconnect dialog.
# Wait here until it does, so that the page does not go back to the menu for a short
# time between the two. The timeout is important only where no watcher runs to take
# control.
await asyncio.sleep(_REBOOT_HANDOFF_TIMEOUT_S)
return True
async def _sync_clock(ctx: AppContext, device: Device, snapshot: dict) -> None:
"""Show the drift of the device clock against this computer, and offer to correct it.
A companion that boots with a bad RTC puts a wrong time stamp on each message. Thus
the dialog starts with the measured drift (or says that the clock cannot be read),
before the Sync button writes the time of the host.
"""
import time
device_time: int | None = None
try:
device_time = await device.get_time()
except Exception: # noqa: BLE001 - old firmware. Offer the blind sync instead.
pass
if device_time:
drift = device_time - int(time.time())
stamp = _clock_text(device_time)
prompt = (
f"The device clock reads {stamp} — {_drift_text(drift)}. Set it from this computer?"
)
else:
prompt = "The device did not report its clock. Set it from this computer anyway?"
choice = await ctx.ui.dialog(
prompt,
[("Cancel", None), ("Sync", "sync")],
title="Sync clock",
default=1,
)
if choice == "sync":
await _run_now(ctx, device, snapshot, [("sync_clock",)], "Sync clock")
def _clock_text(epoch: int) -> str:
"""A device timestamp, rendered in the local time of this computer."""
from datetime import datetime
return datetime.fromtimestamp(epoch).astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _drift_text(drift: int) -> str:
"""Describe a clock drift in seconds: ``"12 s behind"``, ``"3 s ahead"``, ``"in sync"``."""
if abs(drift) < 2:
return "in sync with this computer"
direction = "ahead of" if drift > 0 else "behind"
return f"{abs(drift)} s {direction} this computer"
async def _backup_now(ctx: AppContext, device: Device, snapshot: dict) -> None:
"""Ask for a destination, and write the TOML backup immediately."""
path = await ctx.ui.path(
"Back up config",
prompt="Write every setting to this TOML file:",
default="meshterm-config.toml",
)
if path:
await _run_now(ctx, device, snapshot, [("backup", Path(path))], "Backup")
async def _restore_now(ctx: AppContext, device: Device, snapshot: dict) -> bool:
"""Restore from a TOML backup: select the file, preview it if the user wants, then apply.
Returns:
``True`` if the device changed (then the caller refreshes its snapshot).
"""
raw = await ctx.ui.path("Restore config", prompt="Read settings from this TOML backup file:")
if not raw:
return False
path = Path(raw)
if not path.exists():
ctx.ui.note(f"[err]no such file:[/err] {path}")
await ctx.ui.present(title="Restore")
return False
choice = await ctx.ui.dialog(
"Apply the backup now, or preview the changes first?",
[("Cancel", None), ("Preview", "preview"), ("Apply", "apply")],
title="Restore from backup",
default=1,
)
if choice == "preview":
await _run_now(ctx, device, snapshot, [("restore", path, True)], "Restore preview")
choice = await ctx.ui.dialog(
"Apply these changes to the device?",
[("Cancel", None), ("Apply", "apply")],
title="Restore from backup",
default=1,
)
if choice != "apply":
return False
changed = await _run_now(ctx, device, snapshot, [("restore", path, False)], "Restore")
return changed > 0
async def _identity_key_menu(
ctx: AppContext, device: Device, snapshot: dict, staged: int = 0
) -> bool:
"""Export or import the private identity key of the device.
An import takes a pasted key or a file. Both accept the 64-byte key that a companion
exports, and also the 32-byte seed that the node of an SPI radio keeps
(:mod:`meshterm.core.privatekey`). Each input is checked before the confirmation, so
a key that is not one never gets to the typed confirmation.
Args:
ctx: The shared application context.
device: The connected device.
snapshot: The current device snapshot.
staged: The number of staged changes on the page. An import that restarts the node
ends the visit, as a reboot does, so the user must agree to discard them first.
Returns:
``True`` if the identity changed (a key was imported). Then the caller reads its
snapshot again, or gives control to the reconnect if the node must restart
(:attr:`~meshterm.core.connection.Device.import_restarts`).
"""
choice = await ctx.ui.select(
"Identity key",
[
*menu_rows(
[
("Show private key", "Display it on screen (sensitive)", "show"),
("Export to a file…", "Write it to disk (keep it secret)", "file"),
("Import a key…", "Paste it (replaces the identity)", "import"),
("Import from a file…", "Read it from disk (replaces the identity)", "load"),
]
),
],
prompt="Manage this node's private identity key:",
)
if choice is None:
return False
if choice == "show":
ok = await ctx.ui.dialog(
"The private key IS the node's identity — anyone who sees it can impersonate "
"this node. Show it on screen?",
[("Cancel", None), ("Show key", "show")],
title="Show private key",
default=1,
danger=True,
)
if ok == "show":
await _run_now(ctx, device, snapshot, [("export_key",)], "Private key")
return False
if choice == "file":
path = await ctx.ui.path(
"Export identity key",
prompt="Write the private key to this file (keep it secret):",
default="meshterm-identity.key",
)
if path:
await _run_now(ctx, device, snapshot, [("export_key", Path(path))], "Private key")
return False
# Import: get the key, then ask for the typed confirmation.
if choice == "load":
raw = await ctx.ui.path(
"Import identity key", prompt="Read the private key from this file:"
)
if not raw:
return False
try:
key = read_private_key_file(Path(raw))
except (OSError, ValueError) as exc:
ctx.ui.note(f"[err]not imported:[/err] {escape(str(exc))}")
await ctx.ui.present(title="Import key")
return False
else:
key_hex = await ctx.ui.text(
"Import identity key",
prompt="Paste the private key as hex:",
validate=_is_private_key,
)
if not key_hex:
return False
key = parse_private_key(key_hex)
restarts = device.import_restarts and ctx.active_transport is not None
if restarts and staged and not await confirm_discard(ctx, staged, verb="applying"):
return False
warning = (
"Importing a key permanently overwrites this device's identity. Contacts and "
"messages keyed to the old identity will no longer match it."
)
if restarts:
warning += " The node restarts to use the new key."
confirmed = await ctx.ui.typed_confirm(warning, "IMPORT", title="Import private key")
if not confirmed:
return False
await _run_now(ctx, device, snapshot, [("import_key", key.hex())], "Import key")
return True
async def _factory_reset(ctx: AppContext, device: Device, snapshot: dict) -> bool:
"""Do a factory reset of the device, behind a typed confirmation.
Returns:
``True`` if the reset ran. Then the caller discards all its staged values, and
reads the device again.
"""
confirmed = await ctx.ui.typed_confirm(
"This erases EVERYTHING on the device — identity, contacts, channels, and every "
"setting — and cannot be undone.",
"RESET",
title="Factory reset",
)
if not confirmed:
return False
await _run_now(ctx, device, snapshot, [("factory_reset",)], "Factory reset")
return True
# --- validators --------------------------------------------------------------
def _is_private_key(text: str) -> bool | str:
"""Check that ``text`` is a private key in hex: ``True``, or the problem for the prompt."""
try:
parse_private_key(text)
return True
except ValueError as exc:
return str(exc)