Files
Jean-Pierre MartineauandClaude Opus 5.5 abfd32c491 Docstrings and comments of meshterm/ui are in ASD-STE100
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).

The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 20:37:26 -04:00

1182 lines
50 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The repeater-admin screens: edit the settings of a remote node over the mesh, in an editor.
These screens are the interactive side of the ``repeater-admin`` tool. First the user
selects a node (in the shared node picker, which shows the nodes with credentials first)
and logs in (with a remembered password, or else with a one-time prompt). Then an editor
opens. Its shape is the same as the Device config screen, on purpose: the same setting,
value, and description lanes, staged ``current → new`` values, and Apply. But it uses the
text CLI of the repeater (refer to :mod:`meshterm.core.remote_config`) instead of the
binary protocol of the companion. Thus it has the settings that only a repeater has, which
the local editor never had: TX delay, Direct TX delay, duty cycle, flood caps, and the
bridge.
Each read is one paced round trip over the mesh. Thus the editor does not read all the
values when it opens. Each value shows what the last read (or the last applied ``set``)
said, from the cache for each node (:class:`~meshterm.core.remote_store.RemoteStore`).
The "Read settings" action refreshes all of them under a progress dialog that the user can
abort, one paced read at a time. ``^R`` (``Read`` on the F-key lane) reads only the
highlighted row again. A setting that the firmware of the node does not have shows as
``n/a``. That is different from ``?``, which means never read. Apply sends the staged
values in the same way, and puts each confirmed value immediately back into the cache.
Other than the settings, the action rows are for the node itself: advert, clock sync,
change of the admin password, and reboot, each behind its own floating confirm dialog.
**Regions** opens the region editor (:mod:`meshterm.ui.region_editor`), which sets the
floods that the node relays, region by region. **Command line** opens the readline-style
remote CLI (:mod:`meshterm.ui.remote_cli`) for all that the catalog does not include.
That command line is also where the page gets more rows. Third-party builds have settings
that the catalog does not know. MeshTerm cannot know which build a node runs unless it
asks the node. Each question is one paced round trip, and each guessed row is a useless
``n/a`` on the page of each other node. Thus MeshTerm probes nothing on speculation. When
the user runs a ``get`` or ``set`` here, and this node answers it, the setting key gets a
row on the page of this node, under **Extra** (:func:`learn_from_cli`). The user used that
round trip anyway, the catalog does not grow, and the page of no other node changes. Only
the user can remove a row, by hand (``Del``). A setting key that stops answering shows
``n/a``, like any other setting key. If it did not, a misread reply would delete the one
thing about this node that nobody else can restore.
"""
from __future__ import annotations
import asyncio
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
from rich.text import Text
from ..core.models import Contact
from ..core.remote_config import (
DISCOVERED_CATEGORY,
NEVER_STORED,
REPEATER_SETTINGS,
RemoteSetting,
composite_members,
composite_reads,
discovered_setting,
get_setting,
learnable_from_get,
normalize_value,
parse_reply_value,
parse_setting_command,
range_hint,
read_plan,
reply_is_error,
settings_by_category,
validate_value,
write_plan,
)
from .menus import (
PICK_LOCATION_HELP,
PICK_LOCATION_LABEL,
confirm_discard,
exit_rows,
fit_cells,
icon_lane,
lane_header,
lane_row,
marked_label,
menu_rows,
section_heading,
)
from .trace_screen import TracingDialog
from .tui import Choice, Separator
from .tui.select import DeleteRequest, SelectScreen, splice_hint
from .tui.spinner import Spinner, spinner_interval
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
#: The time in seconds to wait for one remote reply. Repeaters answer over the mesh, and a
#: route of more than one hop takes seconds. Thus this value is longer on purpose than the
#: value for a local command.
_REPLY_TIMEOUT_S = 10.0
# The sentinels of the menu actions. They are different from the setting keys (CLI
# parameter names).
_CLI = "__cli__"
_LOCATION = "__location__"
_READ = "__read__"
_ADVERT = "__advert__"
_CLOCK = "__clock__"
_PASSWORD = "__password__"
_REBOOT = "__reboot__"
_REGIONS = "__regions__"
_APPLY = "__apply__"
_CANCEL = "__cancel__"
#: The footer atom that names the chord that reads the highlighted setting again.
READ_ONE_HINT = "^R read"
#: The footer atom for the keyboard key that removes a discovered row (only on those rows).
FORGET_HINT = "Del forget"
#: The minimum width of the value lane for a discovered setting. Thus a page whose catalog
#: rows are all ``?`` still shows a part of what the node answered.
_EXTRA_VALUE_MIN = 14
def _extra_value(text: str, width: int) -> str:
"""One discovered value, fitted to the lane: whitespace collapsed, and a long reply cut.
The value of a discovered setting is whatever the node said. A build can answer with a
full diagnostic line (``desired=off effective=off supported=yes …``). Without this cut,
that line would set the width of the value lane (one width for all the rows on the
page), and push the description of each setting off the edge. Thus the function cuts
the row here, and the page keeps its shape. The user reads the full reply on the
command line.
"""
flat = " ".join(text.split())
return fit_cells(flat, width) if cell_len(flat) > width else flat
def _spec_for(key: str, cache: dict) -> RemoteSetting | None:
"""The setting that ``key`` names on this node: from the catalog, or discovered there."""
spec = get_setting(key)
if spec is not None:
return spec
cached = cache.get(key)
return discovered_setting(key) if cached is not None and cached.discovered else None
def _discovered_specs(cache: dict) -> list[RemoteSetting]:
"""The discovered settings of this node, sorted by setting key: rows the catalog lacks."""
return [discovered_setting(key) for key, cached in sorted(cache.items()) if cached.discovered]
def _all_specs(cache: dict) -> list[RemoteSetting]:
"""All the settings on the page of this node: the catalog, and what the node told us."""
return [*REPEATER_SETTINGS, *_discovered_specs(cache)]
def _extra_keys(cache: dict) -> frozenset[str]:
"""The setting keys on the page of this node that came from the node, not the catalog."""
return frozenset(key for key, cached in cache.items() if cached.discovered)
@dataclass(frozen=True, slots=True)
class ReadOne:
"""The result of the admin menu when ``^R`` asks for the value of one setting again.
Attributes:
key: The catalog key of the highlighted setting.
"""
key: str
class AdminMenu(SelectScreen):
"""The admin editor's list, with a keyboard key that reads the highlighted setting again.
A full read is one paced round trip for each setting. On a node with all the sections,
that takes minutes. Thus, to check whether one value was accepted, or to refresh the
one row that timed out, the user must not have to read all of them. The keyboard key is
``^R``, the chord that the app already uses for "retry" (the chat sends an
unacknowledged message again with it). Here too, it asks the mesh the same question
again, for the one highlighted row. It must be a chord, because this list filters as
you type, and each bare letter already has a use.
The footer names this keyboard key, and the F-key lane draws its ``Read`` chip live,
only on a setting row that can be read. They never do this on an action row, where
there is nothing to read.
It is a full-screen page, not a floating dialog. The full catalog of a node is a place
where the user works for some time, like the Device config editor that it copies. A box
that fitted its content, over the node picker, left most of the width of the frame to
the backdrop. The node picker still floats, and the value prompts, the confirm dialogs,
and the progress dialogs float over this page.
"""
floating = False
#: The setting keys on the page of this node that are not in the catalog. They are
#: refreshed with the rows.
extra_keys: frozenset[str] = frozenset()
def __init__(self, title: str, items: list, **kwargs: Any) -> None:
"""Build the list of the page. Its rows end at the edge, and ←→ does not scroll them.
This is the same as on the Device config page, on purpose. The Actions rows pin a
head block (:func:`~meshterm.ui.menus.menu_rows`). That alone turns on the ←→
scroll for the full list. Then a setting row, which pins nothing, moved its label
off the screen with its description, and the footer got a ``←→ scroll`` atom.
``hscroll=False`` keeps each row complete and cut with the ellipsis, like each other
editor lane.
"""
kwargs.setdefault("hscroll", False)
super().__init__(title, items, **kwargs)
def _row_key(self) -> str | None:
"""The highlighted row's setting key, or ``None`` on an action row."""
current = self._current_choice()
if current is None or not isinstance(current.value, str):
return None
return current.value
def _readable_key(self) -> str | None:
"""The setting key of the highlighted row, if the node can be asked for that setting."""
key = self._row_key()
if key is None:
return None
if key in self.extra_keys:
return key # a discovered row is a string setting, and can always be read again
spec = get_setting(key)
return spec.key if spec is not None and spec.readable else None
def _forgettable_key(self) -> str | None:
"""The setting key of the highlighted row, if it is a discovered row that Del removes."""
key = self._row_key()
return key if key is not None and key in self.extra_keys else None
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The list's own hint, plus the read atom while the highlight is on a readable setting."""
base = super().footer_hint
return splice_hint(base, READ_ONE_HINT) if self._readable_key() else base
@property
def picocalc_lyra_lane(self):
"""The list's lane, with ``Read`` on F3 and ``Forget`` behind it: dim where inactive.
F1/F2 are the section jumps of this grouped list, and F4/F5 are the pager. F3 is the
slot that a select list uses for its own verb. Read and Forget are the two halves of
the same slot, because they have the same subject (this row). They are never both
live: a catalog row can be read but not forgotten, and only a discovered row can be
removed. On an action row, they are dim, not empty: the two actions exist on this
screen, but not for the highlighted row.
"""
from .tui.fkeys import FPair
lane = list(super().picocalc_lyra_lane)
lane[2] = FPair(
"Read",
"retry",
opp_label="Forget",
opp_action="delete",
enabled=self._readable_key() is not None,
opp_enabled=self._forgettable_key() is not None,
)
return lane
def handle(self, action: str, data: str = "") -> None:
"""Ask for the value of the highlighted setting, or act as each select list does."""
if action == "retry":
key = self._readable_key()
if key is not None:
self.resolve(ReadOne(key))
return
super().handle(action, data)
async def open_repeater_admin(ctx: AppContext) -> dict[str, Any] | None:
"""Run the repeater-admin flow: select a node, log in, and administer it.
The node picker is a dialog: a question before the user goes in, which closes when it
is answered. Thus the admin page opens over the main menu, and Esc on the page goes
back there: the page is the full visit. If the node refuses the login (or never
answers), the picker asks again, with the same node highlighted. Thus a mistyped
password costs one Enter and a retry, not a new start of the flow. The list once
stayed pushed below the page as a hub. That looked like two places where there is only
one.
Args:
ctx: The shared application context (it must run the interactive TUI surface).
Returns:
A summary of the administered node (for the log of the tool), or ``None`` if the
user left the picker and never started a session.
Raises:
RuntimeError: If it is called outside the interactive menu (no full-screen
session).
"""
from .admin_picker import pick_admin_node
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the menu-only caller prevents this
raise RuntimeError("repeater admin is only available in the menu")
device = await ctx.device()
# Through the session cache: when this screen opens, it must not read the (slow)
# contacts table again if another screen already read it (refer to
# :class:`~meshterm.services.device_state.DeviceState`). The device handle above is
# still necessary for the admin login and for the CLI session that come after.
contacts = await ctx.devstate.contacts()
node: Contact | None = None
while True:
node = await pick_admin_node(
ctx,
contacts,
title="Repeater admin — node to manage",
prompt="The remote node to set up (you need its admin password):",
default=node,
)
if node is None:
return None
if await _login(ctx, device, node):
return await _admin_session(ctx, device, node)
async def _login(ctx: AppContext, device: Device, node: Contact) -> bool:
"""Log in to ``node``: with a remembered password silently, or else one floating prompt.
MeshTerm remembers a password that works. It forgets a rejected password, so that the
next try asks for a new one (the convention for remote admin in all the app). But it
forgets only a rejected password. A node that never answered said nothing about the
password, so when there is no answer, MeshTerm keeps the password. Before, if the user
administered a repeater that was down at that time, MeshTerm erased its credential.
"""
from ..core.models import LoginResult
session = ctx.ui.session
password = ctx.admin_store.get(node)
prompted = password is None
if password is None:
password = await session.text(
f"Admin password for {node.name}",
prompt="The node ignores admin commands without a login.",
password=True,
# The picker dialog is closed now, so this prompt is the only screen on the stack.
# The flag keeps it a box over a blank base, not a full-frame prompt.
floating=True,
)
if not password:
return False
async with ctx.ui.busy_overlay():
outcome = await device.admin_login(node, password)
ctx.admin_store.record(node, password, outcome)
if outcome is LoginResult.REFUSED:
await session.message_dialog(
Text(
f"{node.name!r} rejected the admin login (wrong password?). "
+ ("" if prompted else "The saved password was cleared; ")
+ "try again to enter a new one.",
style="err",
),
title="Admin login",
)
return False
if not outcome:
await session.message_dialog(
Text(
f"No reply from {node.name} — it may be out of reach, asleep, or busy. "
+ ("" if prompted else "The saved password was kept; ")
+ "try again when it answers.",
style="warn",
),
title="Admin login",
)
return False
return True
async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict[str, Any]:
"""Run the editor loop for one node that the user is logged in to.
One screen for the full session. Its rows refresh in place after each action: they
hold the ``current → new`` values of the cache, and the title counts the staged values.
Thus the content changes below a highlight that stays where the user put it, and the
typed filter stays too. Each sub-prompt floats over the screen, and Esc leaves the
node.
"""
from .tui import CANCEL
session = ctx.ui.session
pending: dict[str, str] = {} # setting key -> staged new value
applied = 0
cache = ctx.remote_store.settings(node)
title, items = _menu_items(node, cache, pending)
menu = AdminMenu(
title,
items,
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
delete_hint=FORGET_HINT,
)
menu.extra_keys = _extra_keys(cache)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
if choice is CANCEL:
choice = _CANCEL
if choice in (None, _CANCEL):
if pending and not await confirm_discard(ctx, len(pending), verb="sending"):
continue
return {"node": node.name, "applied": applied}
if isinstance(choice, ReadOne):
spec = _spec_for(choice.key, cache)
if spec is not None:
await read_settings(ctx, device, node, [spec])
elif isinstance(choice, DeleteRequest):
await _forget_discovered(ctx, node, str(choice.value), pending)
elif choice == _APPLY:
applied += await _apply(ctx, device, node, pending)
elif choice == _READ:
# The page of this node, not the catalog: the same sweep refreshes a
# discovered row and all the other rows, and the row shows n/a if the node
# stops answering.
await read_settings(ctx, device, node, _all_specs(cache))
elif choice == _LOCATION:
await _stage_location(ctx, cache, pending)
elif choice == _CLI:
await _command_line(ctx, device, node)
elif choice == _REGIONS:
from .region_editor import open_region_editor
await open_region_editor(ctx, device, node)
elif choice == _ADVERT:
await _simple_action(
ctx,
device,
node,
"advert",
title="Send advert",
prompt=f"Ask {node.name} to announce itself (flood) now?",
commit="Send",
)
elif choice == _CLOCK:
await _simple_action(
ctx,
device,
node,
"clock sync",
title="Sync clock",
prompt=f"Set {node.name}'s clock from this companion's time?",
commit="Sync",
)
elif choice == _PASSWORD:
await _change_password(ctx, device, node)
elif choice == _REBOOT:
await _simple_action(
ctx,
device,
node,
"reboot",
title="Reboot node",
prompt=f"Reboot {node.name} now? It drops off the mesh while booting.",
commit="Reboot",
danger=True,
)
else: # a setting key
spec = _spec_for(str(choice), cache)
if spec is not None and not spec.writable:
# A read-only fact has nothing to stage. Enter can only ask for it again.
await read_settings(ctx, device, node, [spec])
else:
await _stage_setting(ctx, node, str(choice), cache, pending)
# A read or an apply writes the cache that the rows come from again. Read the
# cache again, so that the values are the values that the action produced.
cache = ctx.remote_store.settings(node)
title, items = _menu_items(node, cache, pending)
menu.extra_keys = _extra_keys(cache)
menu.replace_items(items, title=title)
# --- the menu ------------------------------------------------------------------------
def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[str, list]:
"""Build the title and the rows of the editor menu from the cache and the staged values.
The lane layout is the same as on the Device config editor: setting, value (with any
staged ``→ new``), and description, below one header line. Thus the administration of
a remote node looks exactly like the edit of the settings of the local one.
"""
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:
# In the words of the node that the page is open on: on a room server, the
# guest password is the room password (RemoteSetting.for_node).
spec = spec.for_node(node.node_type)
if spec.key == "lat":
# A position selected on the map sets the two coordinates at the same time.
# Thus its row is first, above the pair that it fills: the same row, at the
# same place, as on the Device config page.
rows.append((PICK_LOCATION_LABEL, Text(), PICK_LOCATION_HELP, _LOCATION))
rows.append((spec.label, _value_text(spec, cache, pending), spec.help, spec.key))
sections.append((category, rows))
# What this node told us itself, at the end and in its own section. The section is not
# there on a node that told us nothing, which is each node until a user asks it
# something. Its values fit in the lane that the catalog rows already need. The value
# lane has one width for the full page. Thus a reply with no limit here would push the
# description of each setting to the right, off the edge of a 72-cell screen. It would
# also change the shape of a page that the user opened to read the catalog.
extra = _discovered_specs(cache)
if extra:
budget = max(
_EXTRA_VALUE_MIN,
max((cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)),
)
sections.append(
(
DISCOVERED_CATEGORY,
[(s.label, _value_text(s, cache, pending, budget), s.help, s.key) for s in extra],
)
)
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 same pinned header, which fits itself, as the header above the lanes of the local
# editor. It stays on the screen with the category headings for the full list. When a
# long cached value leaves no space for the last label, the header abbreviates, and it
# does not wrap (menus.lane_header).
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,
# Only a discovered row can be removed. A catalog row that goes away
# would come back at the next paint, because the catalog still names it.
deletable=category == DISCOVERED_CATEGORY,
)
)
# ↻ and ⌨ are one cell wide, but 📡 🕒 🔐 🔄 are two. Thus the icon lane is measured one
# time, and each icon is padded to that width. If not, the labels of Read settings and
# Command line would start one cell to the left of the rows below them.
actions = [
("↻", "Read settings", "Fetch every value from the node, one paced read", _READ),
("⌨", "Command line…", "Talk to the node's CLI directly", _CLI),
("🔖", "Regions…", "Which scoped floods it relays", _REGIONS),
("📡", "Send advert…", "Have the node announce itself now", _ADVERT),
("🕒", "Sync clock…", "Set the node's clock over the mesh", _CLOCK),
("🔐", "Admin password…", "Change the node's admin password", _PASSWORD),
("🔄", "Reboot node…", "Restart it remotely", _REBOOT),
]
lane = icon_lane(icon for icon, _, _, _ in actions)
items.append(section_heading("Actions"))
items.extend(
menu_rows(
(marked_label(icon, label, "", lane=lane), help_text, value)
for icon, label, help_text, value in actions
)
)
staged = len(pending)
items.extend(exit_rows(staged, apply_value=_APPLY, back_value=_CANCEL))
title = f"Repeater admin — {node.name}" + (f" · {staged} staged" if staged else "")
return title, items
def _value_text(
spec: RemoteSetting, cache: dict, pending: dict[str, str], width: int = _EXTRA_VALUE_MIN
) -> Text:
"""The VALUE lane of one setting: what the node said last, and any staged arrow.
There are five states, each with its own word:
- The value.
- ``empty``, for a string that the node holds blank.
- ``n/a``, when the node answered with something that cannot be the value of this
setting (its firmware has no such setting).
- ``?``, when the setting was never read (or the node never answered).
- ``write-only``, for a setting that the node cannot be asked for.
No age: the lane is the value, and a time stamp beside it only made the lane too full.
"""
cached = cache.get(spec.key)
if not spec.readable:
value = Text("write-only", style="faint")
elif cached is None:
value = Text("?", style="muted")
elif not cached.supported:
value = Text("n/a", style="muted")
elif cached.value == "":
value = Text("empty", style="muted")
elif spec.discovered:
value = Text(_extra_value(cached.value, width))
else:
value = Text(spec.display(cached.value))
if spec.key in pending:
staged = pending[spec.key]
shown = _extra_value(staged, width) if spec.discovered else spec.display(staged)
value.append(f" → {shown}", style="warn")
return value
# --- staging and applying ---------------------------------------------------------
async def _stage_setting(
ctx: AppContext, node: Contact, key: str, cache: dict, pending: dict[str, str]
) -> None:
"""Prompt for the new value of one setting, and stage it (MeshTerm sends nothing yet).
The prompt names the setting as its row does, in the words of the node that it is for.
"""
spec = _spec_for(key, cache)
if spec is None or not spec.writable: # pragma: no cover - the menu offers only real settings
return
spec = spec.for_node(node.node_type)
cached = cache.get(key)
known = cached.value if cached is not None and cached.supported else None
current = pending.get(key, known or "")
if spec.kind == "bool":
picked = await ctx.ui.dialog(
spec.help,
[("Off", "off"), ("On", "on")],
title=spec.label,
default=1 if current == "on" else 0,
keys={"0": "off", "1": "on", "n": "off", "y": "on"},
)
if picked is None:
return
value = str(picked)
elif spec.kind == "enum":
items = [
Choice(
title=option.label
+ (f" — {option.help}" if option.help else "")
+ (" (current)" if option.value == current else ""),
value=option.value,
)
for option in spec.options
]
picked = await ctx.ui.select(
spec.label,
items,
prompt=spec.help,
default=current if any(o.value == current for o in spec.options) else None,
)
if picked is None:
return
value = str(picked)
else:
raw = await ctx.ui.text(
spec.label,
prompt=spec.help,
default=str(current),
validate=lambda t: validate_value(spec, t),
help_text=range_hint(spec),
)
if raw is None:
return
value = normalize_value(spec, raw)
if value == known:
pending.pop(key, None) # the same as what the node said last, so nothing to send
else:
pending[key] = value
async def _forget_discovered(
ctx: AppContext, node: Contact, key: str, pending: dict[str, str]
) -> None:
"""Remove one discovered row from the page of this node, after a confirm dialog.
This is the only way to remove a discovered row. A read could remove one, because a
setting key that the node no longer answers can be real (after a reused identity moves
to a different board). But then one misread reply would silently delete the row: a
truncated line, a node that answers during a reboot, or a stray reply that MeshTerm
matched to the wrong command. When that occurs on a catalog row, the cost is an
``n/a``, and nothing is lost, because the catalog still names the setting key. But
only this page knows the setting key of a discovered row, and the user may not remember
it. Thus a read can only show the same ``n/a``, and a removal is a decision.
It is a red confirm dialog, like each other delete of one record, but nothing changes
on the node. What goes is what MeshTerm remembers. If the user asks for the setting key
again, the row comes back.
"""
from .tui import CANCEL
choice = await ctx.ui.dialog(
f"Stop showing {key} on {node.name}? Nothing on the node changes — "
"ask it for the setting again and the row comes back.",
[("Cancel", CANCEL), ("Forget", "forget")],
title="Forget setting",
default=1,
destructive=True,
)
if choice != "forget":
return
pending.pop(key, None)
ctx.remote_store.forget_setting(node, key)
async def _stage_location(ctx: AppContext, cache: dict, pending: dict[str, str]) -> None:
"""Select the advertised location of the node on the map, and stage the two coordinates.
This is the row of the Device config page, but it uses the CLI of this node. The map
opens on the staged position (or on the position that the node said last), or on the
mesh if there is no position. Each selected coordinate is put in the same format as a
read of it. Thus, if the user selects the position that the node already holds, the
staged value is removed, and no ``set`` that does nothing goes in the queue. MeshTerm
sends nothing until Apply.
"""
from .map_screen import coords_or_none, pick_location
def known(key: str) -> str | None:
cached = cache.get(key)
return cached.value if cached is not None and cached.supported else None
lat = pending.get("lat", known("lat"))
lon = pending.get("lon", known("lon"))
picked = await pick_location(ctx, initial=coords_or_none(lat, lon))
if picked is None:
return
for key, value in zip(("lat", "lon"), picked, strict=True):
spec = get_setting(key)
if spec is None: # pragma: no cover - both keys are in the catalog
continue
# Six decimals ≈ 0.1 m: more than the precision of the map, and enough for an advert.
text = normalize_value(spec, f"{value:.6f}")
if text == known(key):
pending.pop(key, None) # what the node already says, so nothing to send
else:
pending[key] = text
def _known_values(ctx: AppContext, node: Contact) -> dict[str, str]:
"""The last values read from the node, to state the unstaged fields of a composite again."""
return {
key: cached.value
for key, cached in ctx.remote_store.settings(node).items()
if cached.supported
}
def _remark(reply: str, value: str) -> str:
"""What the reply to a successful write adds after ``OK`` (``reboot to apply``), or ``""``."""
remark = re.sub(r"^ok\b[\s,:-]*", "", reply.strip(), flags=re.IGNORECASE)
return "" if remark.lower() in ("", value.lower()) else remark
async def _apply(ctx: AppContext, device: Device, node: Contact, pending: dict[str, str]) -> int:
"""Send all the staged values, paced, under a progress dialog that the user can abort.
MeshTerm sends the staged values in the groups of
:func:`~meshterm.core.remote_config.write_plan`: one command for each setting, but the
four fields of the radio go together as one ``set radio``. That command states each
field again. Thus, if a radio field is staged and its sibling fields were never read,
MeshTerm reads them first (one paced ``get radio``), and does not guess a frequency.
Each confirmed value goes immediately into the cache for the node, and MeshTerm forgets
each other spelling of the same firmware value. A write that the node rejects or does
not answer stays staged, so that the user can try it again (or unstage it). It is not
silently removed. The function stores one ``runs`` row for the batch.
Returns:
The number of settings that the node accepted.
"""
session = ctx.ui.session
run_id = ctx.repo.start_run(
"repeater-admin",
{"node": node.name, "mode": "apply", "settings": sorted(pending)},
ctx.profile_name,
)
outcomes: list[Text] = []
applied = 0
async def work(dialog: TracingDialog) -> None:
nonlocal applied
first = True
async def send(command: str, status: str) -> str | None:
nonlocal first
if not first: # each transmission after the first waits for the cooldown
await asyncio.sleep(ctx.preferences.trace_cooldown_s)
first = False
dialog.status = status
session.invalidate()
return await device.send_remote_command(node, command, timeout=_REPLY_TIMEOUT_S)
for command in composite_reads(pending, _known_values(ctx, node)):
fills = [s for s in REPEATER_SETTINGS if s.get_command == command]
reply = await send(command, command)
if reply is not None:
remember_reply(ctx, node, fills, reply)
writes = write_plan(pending, _known_values(ctx, node))
# The catalog, plus the discovered rows of this node. A staged setting key that only
# this node has is still a row, with a label to name in the outcome and a write to
# count.
specs = _all_specs(ctx.remote_store.settings(node))
for i, write in enumerate(writes, start=1):
staged = [s for s in specs if s.key in write.values and s.key in pending]
names = ", ".join(f"{s.label} = {s.display(write.values[s.key])}" for s in staged)
if write.missing:
unread = ", ".join(s.label for s in map(get_setting, write.missing) if s)
outcomes.append(
Text.assemble(
("✗ ", "err"), f"{names} — {unread} couldn't be read (still staged)"
)
)
continue
verb = " ".join(write.command.split()[:2]) # never the value of a secret on screen
reply = await send(write.command, f"{verb} · {i}/{len(writes)}")
if reply is not None and not reply_is_error(reply):
for key, value in write.values.items():
ctx.remote_store.remember_setting(node, key, value)
spec = get_setting(key)
for other in spec.overlaps if spec is not None else ():
ctx.remote_store.forget_setting(node, other)
for spec in staged:
pending.pop(spec.key, None)
applied += len(staged)
remark = _remark(reply, write.values[staged[0].key]) if staged else ""
outcomes.append(
Text.assemble(("✓ ", "ok"), names, (f" — {remark}" if remark else "", "muted"))
)
elif reply is None:
outcomes.append(Text.assemble(("? ", "warn"), f"{names} — no reply (still staged)"))
else:
outcomes.append(
Text.assemble(("✗ ", "err"), f"{names} — {reply.strip()} (still staged)")
)
aborted = await _run_under_dialog(ctx, f"Applying — {node.name}", work)
ctx.repo.finish_run(
run_id,
"error" if aborted else "ok",
{"applied": applied, "staged_left": len(pending)},
)
summary = Text.assemble(
(f"{applied}", "brand"), f" of {applied + len(pending)} settings applied"
)
if aborted:
summary.append(" (aborted — the rest stay staged)", style="warn")
await ctx.ui.session.message_dialog(
Text("\n").join([summary, Text(), *outcomes]) if outcomes else summary,
title=f"Apply — {node.name}",
)
return applied
def remember_reply(ctx: AppContext, node: Contact, fills: list[RemoteSetting], reply: str) -> int:
"""Put the reply of one read into the cache, for each setting that it answers.
A reply that cannot be the value of the setting means that the node has no such
setting. MeshTerm remembers it as that (the row shows ``n/a``). This applies to an error
(``??: key``, ``Error: unsupported``) and also to an answer to a different question. The
second type is how older firmware says it. ``get`` matches its setting keys by prefix.
Thus, on v1.15, a setting key that the firmware does not have goes into the branch of a
shorter sibling key: ``get radio.fem.rxgain`` answers with the ``> 910.525,62.5,7,5``
of ``get radio``. Also, ``gps`` on a board with no GPS receiver answers
``Can't find GPS``. Before, these rows stayed on ``?``, which means never asked. Now
only a read that got no reply at all (which this function does not get) keeps ``?``.
Returns:
The number of settings that got a value.
"""
got = 0
for spec in fills:
value = parse_reply_value(spec, reply) # None for an error reply too
if value is None:
ctx.remote_store.remember_unsupported(node, spec.key)
else:
ctx.remote_store.remember_setting(node, spec.key, value)
got += 1
return got
def learn_from_cli(ctx: AppContext, node: Contact, command: str, reply: str) -> None:
"""Put what one exchange on the command line proved about ``node`` into its page.
The command line is the only place where the user can reach a setting outside the
catalog. Thus it is also the only place where MeshTerm can find one. A ``get`` or
``set`` that this node answered proves that the setting key exists. The row that the
setting key gets costs no round trip that the user did not ask for. The catalog does not
grow: a setting key learned here belongs only to this node (refer to
:func:`~meshterm.core.remote_config.discovered_setting`), and the page of each other
node stays as it was.
There are three cases, in the order of the tests:
* The **own key of a composite** (``get radio``) fills all four of its fields, exactly
as a read of any one of them does.
* A **catalog key** goes into the cache in the same way as from the sweep or an Apply.
That is also why ``get tx`` on the command line no longer leaves the TX power row out
of date.
* Each other setting key is **discovered**, and the two verbs are not equally good
evidence. ``handleSetCmd`` matches a setting key with its trailing space, and refuses
an unknown setting key immediately. Thus an accepted write is proof. ``handleGetCmd``
matches on a bare prefix, and can answer a setting key that it does not have from the
branch of a shorter setting key. Thus a read is proof only when the reply cannot be
the reply for that shorter setting key
(:func:`~meshterm.core.remote_config.learnable_from_get`).
MeshTerm never stores ``prv.key``, however the user asked for it
(:data:`~meshterm.core.remote_config.NEVER_STORED`).
"""
parsed = parse_setting_command(command)
if parsed is None or parsed.key in NEVER_STORED:
return
members = composite_members(parsed.key)
spec = _spec_for(parsed.key, ctx.remote_store.settings(node))
if parsed.verb == "get":
if members:
remember_reply(ctx, node, members, reply)
elif spec is not None:
remember_reply(ctx, node, [spec], reply)
elif not reply_is_error(reply) and learnable_from_get(
parsed.key, reply, _known_values(ctx, node)
):
found = discovered_setting(parsed.key)
value = parse_reply_value(found, reply)
if value is not None:
ctx.remote_store.remember_discovered(node, found.key, value)
return
if reply_is_error(reply):
return # a refused write tells nothing about the setting key or its value
if members:
for member in members:
value = parse_reply_value(member, parsed.value)
if value is not None:
ctx.remote_store.remember_setting(node, member.key, value)
return
if spec is not None and not spec.discovered:
value = parse_reply_value(spec, parsed.value)
if value is None:
return # the catalog cannot read back these words, so keep the row as it was
ctx.remote_store.remember_setting(node, spec.key, value)
for other in spec.overlaps:
ctx.remote_store.forget_setting(node, other)
return
ctx.remote_store.remember_discovered(node, parsed.key, parsed.value)
async def read_settings(
ctx: AppContext, device: Device, node: Contact, specs: Iterable[RemoteSetting]
) -> None:
"""Refresh ``specs`` from the node, one paced read at a time.
The four radio fields share one ``get radio`` (refer to
:func:`~meshterm.core.remote_config.read_plan`), so a full read asks each question one
time. Values that parse go into the cache (and onto the screen). A setting that the
firmware does not have shows ``n/a``. An abort keeps all the values that were already
read. After the reads, a dialog lists the reads that the node never answered. Their
rows continue to show what they said last, and without the list, they would look new.
"""
session = ctx.ui.session
plan = read_plan(specs)
run_id = ctx.repo.start_run(
"repeater-admin", {"node": node.name, "mode": "read"}, ctx.profile_name
)
read = 0
unanswered: list[str] = []
async def work(dialog: TracingDialog) -> None:
nonlocal read
for i, (command, fills) in enumerate(plan, start=1):
dialog.status = f"{command} · {i}/{len(plan)}"
session.invalidate()
reply = await device.send_remote_command(node, command, timeout=_REPLY_TIMEOUT_S)
if reply is None:
unanswered.extend(spec.label for spec in fills)
else:
read += remember_reply(ctx, node, fills, reply)
if i < len(plan):
await asyncio.sleep(ctx.preferences.trace_cooldown_s)
aborted = await _run_under_dialog(ctx, f"Reading — {node.name}", work)
asked = sum(len(fills) for _command, fills in plan)
ctx.repo.finish_run(
run_id,
"error" if aborted else "ok",
{"read": read, "asked": asked, "unanswered": len(unanswered)},
)
if unanswered and not aborted:
await session.message_dialog(
Text(
f"No reply from {node.name} for: {', '.join(unanswered)}. "
"Those rows still show what they last said.",
style="warn",
),
title=f"Read — {node.name}",
)
async def _run_under_dialog(ctx: AppContext, title: str, work) -> bool:
"""Run an async remote batch under a floating spinner dialog with Abort.
Args:
ctx: The shared application context.
title: The heading of the dialog.
work: ``async work(dialog)``, which does the batch and updates ``dialog.status``.
Returns:
``True`` if the user aborted, ``False`` if the batch ran to the end.
"""
session = ctx.ui.session
spinner = Spinner()
dialog = TracingDialog(title, spinner=spinner, on_abort=lambda: None)
dialog.show_last = False
task = asyncio.ensure_future(work(dialog))
dialog.on_abort = task.cancel
async def animate() -> None:
while True:
await asyncio.sleep(spinner_interval())
spinner.tick()
session.invalidate()
ticker = asyncio.ensure_future(animate())
session.push(dialog)
aborted = False
try:
await task
except asyncio.CancelledError:
aborted = True
except Exception as exc: # noqa: BLE001 - show it in a dialog, and keep the screen
await session.message_dialog(Text(str(exc), style="err"), title=title)
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a small spinner fault must never stop the batch
pass
session.pop(dialog)
return aborted
# --- one-shot actions ------------------------------------------------------------
async def _simple_action(
ctx: AppContext,
device: Device,
node: Contact,
command: str,
*,
title: str,
prompt: str,
commit: str,
danger: bool = False,
) -> None:
"""Confirm and send one fixed CLI command, then show the reply of the node."""
choice = await ctx.ui.dialog(
prompt,
[("Cancel", None), (commit, "go")],
title=title,
default=1,
danger=danger,
)
if choice != "go":
return
async with ctx.ui.busy_overlay():
reply = await device.send_remote_command(node, command, timeout=_REPLY_TIMEOUT_S)
if reply is None:
body = Text("no reply — the command may still have landed", style="warn")
else:
body = Text(reply.strip(), style="err" if reply_is_error(reply) else "ok")
await ctx.ui.session.message_dialog(body, title=title)
async def _change_password(ctx: AppContext, device: Device, node: Contact) -> None:
"""Change the admin password of the node (and remember the new password if it succeeds)."""
new = await ctx.ui.text(
f"New admin password for {node.name}",
prompt="Sent over the mesh; the node applies it immediately.",
password=True,
)
if not new:
return
confirmed = await ctx.ui.dialog(
f"Change {node.name}'s admin password now? You will need the new one everywhere.",
[("Cancel", None), ("Change", "go")],
title="Admin password",
default=1,
danger=True,
)
if confirmed != "go":
return
async with ctx.ui.busy_overlay():
reply = await device.send_remote_command(node, f"password {new}", timeout=_REPLY_TIMEOUT_S)
if reply is not None and not reply_is_error(reply):
ctx.admin_store.remember(node, new) # the password that works is now the new one
body = Text("✓ password changed and remembered", style="ok")
elif reply is None:
body = Text(
"no reply — the change may or may not have landed; the old password "
"stays remembered until a login proves otherwise",
style="warn",
)
else:
body = Text(reply.strip(), style="err")
await ctx.ui.session.message_dialog(body, title="Admin password")
# --- the command line ---------------------------------------------------------------
async def _command_line(ctx: AppContext, device: Device, node: Contact) -> None:
"""Open the readline-style remote CLI for ``node`` (the history is stored for each node)."""
from .remote_cli import RemoteCliScreen
session = ctx.ui.session
worker: asyncio.Task | None = None
ticker: asyncio.Task | None = None
def send(command: str) -> None:
nonlocal worker
if worker is not None and not worker.done():
return # one command at a time, because each send is a transmission
screen.sent(command)
ctx.remote_store.append_history(node, command)
worker = asyncio.ensure_future(roundtrip(command))
async def roundtrip(command: str) -> None:
try:
reply = await device.send_remote_command(node, command, timeout=_REPLY_TIMEOUT_S)
except asyncio.CancelledError:
screen.failed("cancelled", error=False)
raise
except Exception as exc: # noqa: BLE001 - shown inline, and the screen stays open
screen.failed(str(exc), error=True)
return
if reply is None:
screen.failed(f"no reply within {_REPLY_TIMEOUT_S:.0f} s")
else:
learn_from_cli(ctx, node, command, reply)
screen.reply(reply)
screen = RemoteCliScreen(
node_label=node.name,
history=ctx.remote_store.history(node),
send=send,
session=session,
extra_keys=_extra_keys(ctx.remote_store.settings(node)),
)
async def animate() -> None:
while True:
await asyncio.sleep(spinner_interval())
if screen.busy:
screen.tick()
session.invalidate()
ticker = asyncio.ensure_future(animate())
try:
await session.run_screen(screen)
finally:
for task in (worker, ticker):
if task is not None and not task.done():
task.cancel()
for task in (worker, ticker):
if task is not None:
try:
await task
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - the screen is closed, so nothing to show
pass