The pairing PIN waits to be asked for

Device info is the page you open to read your radio out to somebody, the one that
gets screenshotted into a forum thread and left up while a colleague leans over
your shoulder. Every value on it is yours to give away except one: the BLE
pairing PIN is what lets another phone onto the radio, and it was sitting in the
Identity section in plain digits alongside the node name and the coordinates.

It now draws as the field rather than the value — six mask bullets, the same
glyph a password prompt types in, promoted to marks.MASK_MARK so the two can
never drift apart. Six is the width of the *field* (the setting's own bound is
999999), not of what is in it: masking per character is right while someone is
typing and counting their own digits, and wrong for a stored secret, where it
would say how many to guess. A device that reports no PIN keeps its "?" — an
absence is not a secret, and a "?" behind bullets would promise something to
uncover.

`p` uncovers it, and `p` puts it back. A bare letter is a read-only page's idiom
for its own verb (the Time Machine's `w`) and this page has no find-as-you-type
to compete for it; the footer names what the press would do *now* — `p show PIN`,
then `p hide PIN` — and on the console the same toggle is F3, `Reveal` then
`Hide`, since the lane is the only place that platform can learn the key exists.
A device with no PIN advertises neither: the atom drops and the slot stays empty,
because the action isn't a thing on that page at all.

The page is therefore its own screen now (ui/device_info_screen.py) rather than a
generic result window, and ScrollScreen grows `replace_content` — replace_items
for a read-only body — so the toggle swaps the page under a reader who keeps
their scroll position; nothing reflows, six cells become six. The CLI has no key
to press, so `meshterm info` and `config show` print it masked and
`config get device_pin` is the way to name it deliberately: one rule, which is
that the whole-device dump never carries the PIN. The editor one menu over still
lists it plainly — you go there to change it, a row at a time, and its letters
already belong to find-as-you-type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-05 15:13:45 -04:00
1 parent 1d951052bf
commit 42937ce411
8 files changed
+368 -10

No files matched your search

+25 -3
View File
@@ -41,15 +41,37 @@ class InfoTool(Tool):
Returns:
A :class:`ToolResult` summarizing the device name.
"""
from ..ui.config_editor import cached_snapshot, config_table
from rich.console import Group
from ..ui.config_editor import cached_snapshot, config_table, has_pin
device = await ctx.device()
snapshot = await cached_snapshot(ctx, device)
custom = await device.get_custom_vars()
# A live status panel (role, firmware, battery, clock, radio/packet statistics)
# above every current setting with a short explanation of each.
ctx.ui.show(await _status_panel(device, snapshot), Text(""), config_table(snapshot, custom))
# above every current setting with a short explanation of each. The panel is read
# once; only the table differs between the two states, and it is cheap to rebuild.
panel = await _status_panel(device, snapshot)
def page(reveal_pin: bool) -> Group:
return Group(panel, Text(""), config_table(snapshot, custom, reveal_pin=reveal_pin))
# In the menu the page is its own screen, because it has a key of its own: the
# pairing PIN is masked until ``p`` uncovers it. On the CLI there is no keyboard to
# press, so the dump prints as it always did — with the PIN masked there too, and
# ``meshterm config get device_pin`` as the way to name it deliberately.
session = getattr(ctx.ui, "session", None)
if session is None:
ctx.ui.show(page(False))
else:
from ..ui.device_info_screen import DeviceInfoScreen
await session.run_screen(
DeviceInfoScreen(
session, page, title=self.title, conceals=has_pin(snapshot)
)
)
return ToolResult(summary={"name": snapshot.get("name")})
+44 -4
View File
@@ -56,6 +56,7 @@ from ..core.device_config import (
settings_by_category,
)
from ..platforms import Platform, on_platform
from .marks import MASK_MARK
from .menus import (
confirm_discard,
exit_rows,
@@ -205,6 +206,8 @@ def config_table(
snapshot: dict,
custom: dict[str, str],
pending: Optional[dict[str, Any]] = None,
*,
reveal_pin: bool = False,
) -> Table:
"""Build the full configuration table, overlaying any staged changes.
@@ -212,6 +215,13 @@ def config_table(
snapshot: Device snapshot from ``build_snapshot``.
custom: Current custom variables.
pending: Optional staged changes (setting key -> new value).
reveal_pin: Whether to print the BLE pairing PIN. It is concealed by default —
this table is the whole-device dump, the thing that gets read over a
shoulder, screenshotted, and pasted into a bug report, and a pairing code is
the one value on it that lets someone else's phone onto the radio. The
Device info page reveals it on a keypress (see
:class:`~meshterm.ui.device_info_screen.DeviceInfoScreen`); the CLI names it
one value at a time with ``meshterm config get device_pin``.
Returns:
A Rich :class:`Table` of every setting's current (and staged) value plus custom
@@ -259,10 +269,10 @@ def config_table(
header.append("")
table.add_row(*row_of(header, ""))
for spec in specs:
row = [
_fold_label(spec.label, describe),
_fold_value(format_value(spec, spec.getter(snapshot)), describe),
]
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)
@@ -282,6 +292,36 @@ def config_table(
return table
#: The one setting this table holds back: the BLE pairing PIN (the firmware reports it as
#: ``ble_pin``; :data:`~meshterm.core.device_config.SETTINGS` keys it ``device_pin``).
PIN_KEY = "device_pin"
#: How wide a concealed PIN draws: the width of the *field*, taken from the setting's own
#: upper bound (999999), not the length of the value sitting in it. A typed password masks
#: per character because the typist needs to count what they have entered; a stored one
#: shows its field, so the row can't be read for how many digits to guess — and a PIN of
#: ``0`` behind a single bullet would have read as a stray dot rather than a covered value.
_PIN_WIDTH = len(str(get_spec(PIN_KEY).maximum))
def conceal(value: str) -> str:
"""``value`` as a row of :data:`_PIN_WIDTH` mask bullets.
A value the device could not report is not a secret, it is an absence: ``format_value``
already renders that as ``?``, and a ``?`` behind bullets would claim there is
something to reveal when there is nothing. So only a real value is concealed, and
:func:`has_pin` asks the same question the other way round — a page with nothing to
conceal advertises no key for it.
"""
return value if value == "?" else MASK_MARK * _PIN_WIDTH
def has_pin(snapshot: dict) -> bool:
"""Whether ``snapshot`` carries a PIN at all — i.e. whether concealing it means anything."""
return get_spec(PIN_KEY).getter(snapshot) is not None
#: Cell caps for the table's two narrow lanes, indent included. Sized to the widest label
#: and value the settings actually carry, less the few longest — the four that overflow
#: fold onto a second line, which costs nothing on rows whose description was wrapping
+117
View File
@@ -0,0 +1,117 @@
"""The Device info page: everything the companion reports, its pairing PIN concealed.
The page itself is the ``info`` tool's — a live status panel over the full configuration
table (see :mod:`meshterm.tools.info`). What lives here is the one thing that page does
*beyond* scrolling: it holds the BLE pairing PIN back behind a row of mask bullets until
the reader asks for it.
Why this page and not the editor: this is the whole-device dump, the screen someone opens
to read their radio out to somebody else, screenshots for a forum post, or leaves up while
a colleague looks over their shoulder — and the pairing PIN is the one value on it that
lets another phone onto the radio. The editor one menu over still lists it plainly: you go
there to *change* it, a row at a time, and its letters already belong to find-as-you-type
so there is no key left to reveal with.
"""
from __future__ import annotations
from typing import Callable
from rich.console import RenderableType
from .tui.screen import ScrollScreen
#: The key that reveals the PIN, and the word the footer names it by. A bare letter is the
#: idiom for a read-only page's own verb (the Time Machine's ``w``); ``p`` is the value it
#: uncovers, and nothing else on a scrolling body claims it — the page has no
#: find-as-you-type, and ``^P`` (paths) is a different key.
REVEAL_KEY = "p"
class DeviceInfoScreen(ScrollScreen):
"""The Device info result window, plus a keypress that uncovers the pairing PIN.
Everything else is :class:`~meshterm.ui.tui.screen.ScrollScreen`: the body scrolls,
Esc closes, the pager rides F4/F5. The reveal is a *toggle* on one key and one chip —
press it again and the bullets come back — because a reader who uncovered a secret to
read it out wants to put it away without leaving the page.
The body is rebuilt through the caller's ``build`` rather than patched in place, so
this screen never has to know what the page is made of. Both states are kept once
built: the two differ by six cells and nothing reflows, so the scroll position stays
exactly where the reader left it across a press.
"""
def __init__(
self,
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
build: Callable[[bool], RenderableType],
*,
title: str = "",
conceals: bool = True,
) -> None:
"""Wrap the page in its screen.
Args:
session: The running TUI session (repainted when the PIN is toggled).
build: Renders the page body for ``reveal_pin``; called once per state.
title: Heading for the window.
conceals: Whether there is a PIN to conceal at all. ``False`` — a device that
never reported one — leaves the page with nothing to reveal, so neither
the footer nor the lane offers a key that would do nothing.
"""
super().__init__(build(False), title=title)
self._session = session
self._build = build
self._conceals = conceals
self._revealed = False
self._bodies: dict[bool, RenderableType] = {False: self._renderable}
@property
def footer_hint(self) -> str: # type: ignore[override]
"""Scrolling, then the reveal, then Esc — the standard's order.
The reveal atom names what the press does *now* (``show``/``hide``), not what the
screen is showing, so it is never a restatement of the bullets already on screen.
"""
atoms = []
if self.content_overflows:
atoms.append("↑↓ PgUp/PgDn scroll")
if self._conceals:
atoms.append(f"{REVEAL_KEY} {'hide' if self._revealed else 'show'} PIN")
atoms.append("Esc close" if self.floating else "Esc back")
return " · ".join(atoms)
@property
def fkey_lane(self):
"""The shared pager, plus the reveal on F3 — this screen's own verb slot.
The chip is the only place the PicoCalc can learn the key exists, and it names the
action rather than its object: ``Reveal`` while the PIN is masked, ``Hide`` once it
is out, both inside the six cells a chip has. A device with no PIN leaves the slot
*empty* rather than dim — the action isn't a thing on this page at all.
"""
from .tui.fkeys import FPair, default_lane
lane = list(default_lane(nav=self.content_overflows))
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 scroll/dismiss as any result window does.
The key and the lane's chip dispatch into the same branch — one behaviour, two ways
in, so the chip is not a second implementation of the letter.
"""
if action == "reveal" or (action == "text" and data.lower() == REVEAL_KEY):
if not self._conceals:
return
self._revealed = not self._revealed
body = self._bodies.get(self._revealed)
if body is None:
body = self._bodies[self._revealed] = self._build(self._revealed)
self.replace_content(body)
self._session.invalidate()
return
super().handle(action, data)
+7
View File
@@ -38,6 +38,13 @@ REPEATER_MARK = ("▲", "#a78bfa")
NODE_MARK = ("●", "#f472b6")
UNKNOWN_MARK = ("○", "node.unknown")
#: THE concealed character: one bullet stands in for one character of something the screen
#: is holding back — a password being typed (``ui/tui/prompt.py``), the BLE pairing PIN on
#: the Device info page. One glyph for the concept, so a row of them reads the same way
#: wherever it appears, and it is deliberately *not* the ``●`` of an unread message or a
#: node: those are marks that mean something, this one means "not shown".
MASK_MARK = "•"
#: Sentinels naming a path's endpoints on the route graph. ``\x00`` never appears in a
#: node hash, so they can share the node-id namespace without colliding with one.
SRC_NODE = "\x00src"
+3 -2
View File
@@ -13,6 +13,7 @@ from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from ..marks import MASK_MARK
from .render import render_lines, right_aligned_tail
from .screen import Screen
from .spinner import Spinner
@@ -189,7 +190,7 @@ class _LineEditor:
"""
if slots is not None:
return self._render_slots(slots)
shown = "•" * len(self.text) if mask else self.text
shown = MASK_MARK * len(self.text) if mask else self.text
text = Text("› ", style="accent")
for i, ch in enumerate(shown):
over = overflow_at is not None and i >= overflow_at
@@ -213,7 +214,7 @@ class _LineEditor:
for i in range(slots):
if i:
text.append(" ")
glyph = "•" if i < filled else "·"
glyph = MASK_MARK if i < filled else "·"
if i == self.cursor:
text.append(glyph, style="reverse") # the active slot
elif i < filled:
+12
View File
@@ -641,6 +641,18 @@ class ScrollScreen(Screen):
verb = "Esc close" if self.floating else "Esc back"
return f"↑↓ PgUp/PgDn scroll · {verb}" if self.content_overflows else verb
def replace_content(self, renderable: RenderableType) -> None:
"""Swap the body in place, keeping the reader where they were.
The select list's :meth:`~meshterm.ui.tui.select.SelectScreen.replace_items` for a
read-only body: the same page with something in it changed — a value uncovered, a
figure refreshed — is still the page the reader was part-way down, so the scroll
offset stands. (It is clamped on the next scroll anyway, so a body that did change
height cannot strand the viewport past its end.) Rebuild the screen instead when
what is on it is genuinely a different subject.
"""
self._renderable = renderable
def render_body(self, width: int) -> list[str]:
"""Render the wrapped content to ANSI lines and remember the total count."""
lines = render_lines(self._renderable, width)
+113 -1
View File
@@ -27,11 +27,14 @@ from meshterm.ui.config_editor import (
_parse_coords,
_valid_coords,
config_table,
has_pin,
contact_share_url,
device_actions,
edit_config,
send_advert,
)
from meshterm.ui.device_info_screen import DeviceInfoScreen
from meshterm.ui.marks import MASK_MARK
from meshterm.ui.theme import active_theme
from meshterm.ui.tui.prompt import TypedConfirmDialog
from meshterm.ui.tui.screen import CANCEL
@@ -65,7 +68,7 @@ _SNAPSHOT = {
"name": "Homestead-Hub",
"adv_lat": 45.5017,
"adv_lon": -73.5673,
"pin_code": 123456,
"ble_pin": 123456,
"radio_freq": 869525,
"radio_bw": 250,
"radio_sf": 11,
@@ -146,6 +149,115 @@ def test_config_table_drops_the_description_lane_beside_a_staged_column() -> Non
assert "STAGED" in header and "DESCRIPTION" not in header
# -- the concealed pairing PIN --------------------------------------------------
class _StubSession:
"""A minimal session: the screen only ever asks it to repaint."""
def __init__(self) -> None:
self.repaints = 0
def invalidate(self) -> None:
self.repaints += 1
def _pin_row(rendered) -> str: # noqa: ANN001 - lines or an already-joined render
"""The Device PIN row out of a rendered table or screen body."""
return next(line for line in _plain(rendered).split("\n") if "Device PIN" in line)
def _info_screen(snapshot: dict) -> DeviceInfoScreen:
return DeviceInfoScreen(
_StubSession(),
lambda reveal: config_table(snapshot, {}, reveal_pin=reveal),
title="Device info",
conceals=has_pin(snapshot),
)
def test_the_pin_is_bullets_until_it_is_asked_for() -> None:
# One bullet per digit — the app's own mask glyph, the one a password prompt types in.
masked = _pin_row(_table_lines(72))
assert f"{MASK_MARK * 6}" in masked
assert "123456" not in masked
assert "123456" in _pin_row(_table_lines(72, reveal_pin=True))
def test_the_mask_shows_the_field_not_the_pins_length() -> None:
# A stored secret draws its field: a one-digit PIN behind one bullet would have told
# you how many digits to guess (and read as a stray dot besides).
short = {**_SNAPSHOT, "ble_pin": 0}
console = Console(
width=72, file=io.StringIO(), theme=active_theme(), legacy_windows=False
)
with console.capture() as capture:
console.print(config_table(short, {}))
assert MASK_MARK * 6 in _pin_row(capture.get())
def test_a_device_with_no_pin_has_nothing_to_conceal() -> None:
# "?" is an absence, not a secret: masking it would claim there is something behind it.
snapshot = {k: v for k, v in _SNAPSHOT.items() if k != "ble_pin"}
console = Console(
width=72, file=io.StringIO(), theme=active_theme(), legacy_windows=False
)
with console.capture() as capture:
console.print(config_table(snapshot, {}))
assert "?" in _pin_row(_plain(capture.get()).split("\n"))
assert not has_pin(snapshot)
def test_p_uncovers_the_pin_and_puts_it_back() -> None:
screen = _info_screen(_SNAPSHOT)
assert MASK_MARK in _pin_row(screen.render_body(72))
screen.handle("text", "p")
assert "123456" in _pin_row(screen.render_body(72))
screen.handle("text", "p") # the same key puts it away again
assert MASK_MARK in _pin_row(screen.render_body(72))
def test_the_lane_chip_and_the_key_are_one_behaviour() -> None:
# The PicoCalc's chip dispatches the action the letter does, not a second copy of it.
screen = _info_screen(_SNAPSHOT)
assert screen.fkey_lane[2].label == "Reveal"
screen.handle("reveal")
assert "123456" in _pin_row(screen.render_body(72))
assert screen.fkey_lane[2].label == "Hide"
def test_the_footer_names_what_the_press_would_do_now() -> None:
screen = _info_screen(_SNAPSHOT)
assert "p show PIN" in screen.footer_hint
screen.handle("text", "p")
assert "p hide PIN" in screen.footer_hint
def test_a_page_with_no_pin_advertises_no_key_for_it() -> None:
# The standing rule: a footer never names a key that would do nothing, and a lane slot
# for an action this screen doesn't have stays empty rather than dim.
screen = _info_screen({k: v for k, v in _SNAPSHOT.items() if k != "ble_pin"})
assert "PIN" not in screen.footer_hint
assert screen.fkey_lane[2] is None
screen.handle("text", "p")
assert screen.fkey_lane[2] is None
def test_uncovering_the_pin_keeps_the_reader_where_they_were() -> None:
# Nothing reflows — six cells become six cells — so the scroll must not jump.
screen = _info_screen(_SNAPSHOT)
screen.note_viewport(10)
screen.render_body(72)
screen.scroll_lines(8)
where = screen.scroll
screen.handle("text", "p")
assert screen.scroll == where
assert len(screen.render_body(72)) == len(_table_lines(72))
# -- contact share URL ----------------------------------------------------------
+47
View File
@@ -62,10 +62,12 @@ from meshterm.ui.about import (
support_project,
)
from meshterm.ui.chat import ChatScreen
from meshterm.ui.config_editor import config_table, has_pin
from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
from meshterm.ui.contacts_screen import ContactsScreen
from meshterm.ui.courier_screen import CourierOutboxScreen
from meshterm.ui.dashboard_screen import DashboardScreen
from meshterm.ui.device_info_screen import DeviceInfoScreen
from meshterm.ui.livefeed_screen import LiveFeedScreen
from meshterm.ui.map_render import MapMarker
from meshterm.ui.map_screen import MapScreen
@@ -486,6 +488,49 @@ def _tx_sweep(cols: int, rows: int) -> Screen:
return screen
#: A device snapshot shaped like a companion's, for the Device info page. Every field the
#: config table draws, including the pairing PIN it conceals.
_DEVICE_SNAPSHOT = {
"name": "Homestead-Hub",
"adv_lat": 45.5017,
"adv_lon": -73.5673,
"ble_pin": 123456,
"radio_freq": 869525,
"radio_bw": 250,
"radio_sf": 11,
"radio_cr": 5,
"tx_power": 22,
"max_tx_power": 30,
"airtime_factor": 1.0,
"rx_delay": 0.0,
"manual_add_contacts": 0,
"autoadd_config": 0,
"flood_scope": "",
"adv_loc_policy": 1,
"multi_acks": 0,
"telemetry_mode_base": 1,
"telemetry_mode_loc": 0,
"telemetry_mode_env": 0,
"path_hash_mode": 1,
}
def _device_info(cols: int, rows: int) -> Screen:
return DeviceInfoScreen(
_GallerySession(cols, rows),
lambda reveal: config_table(_DEVICE_SNAPSHOT, {}, reveal_pin=reveal),
title="Device info",
conceals=has_pin(_DEVICE_SNAPSHOT),
)
def _device_info_revealed(cols: int, rows: int) -> Screen:
# The same page after ``p``: the widest state of the row the other case masks.
screen = _device_info(cols, rows)
screen.handle("text", "p")
return screen
def _about_meshterm(cols: int, rows: int) -> Screen:
return AboutPage("About MeshTerm", about_meshterm())
@@ -522,6 +567,8 @@ _ENTRIES: list[_Entry] = [
_Entry("packet_viewer", _packet_viewer),
_Entry("trace", _trace),
_Entry("tx_sweep", _tx_sweep),
_Entry("device_info", _device_info),
_Entry("device_info_revealed", _device_info_revealed),
_Entry("about_meshterm", _about_meshterm),
_Entry("about_author", _about_author),
_Entry("join_discord", _join_discord),