Files
MeshTerm/meshterm/platforms.py
T
Jean-Pierre MartineauandClaude Opus 5 63cd4e5f96 Performance: SQLite WAL, lighter startup, per-platform tick rates
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).

The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.

Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.

Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.

That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.

The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.

tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:40:42 -04:00

301 lines
14 KiB
Python
Raw 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.
"""The platform seam: a frozen spec every consumer binds to once, at boot.
MeshTerm runs two flavours from one codebase — the regular desktop terminal and the
PicoCalc's 53-column framebuffer console — with no forked screens and no runtime layer.
A :class:`Platform` is resolved once, at process start, and held in a module-level
singleton; everything downstream (the theme, the frame compositor, the header, the
session's width handling) reads it at *its own* construction/call time and binds its
behaviour accordingly, so the render loop itself never branches on platform.
Read the active platform through :func:`get_platform` (or the module-qualified
``platforms.PLATFORM``) — never ``from meshterm.platforms import PLATFORM`` at a
module's top level. That statement runs once, at import time, and copies whatever
:data:`PLATFORM` pointed to *then*; :func:`set_platform` rebinding the module global
afterwards would never reach that stale copy. A function call always re-reads the
current value, so it has no such trap.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass(frozen=True, slots=True)
class Platform:
"""One flavour's complete rendering/behaviour spec.
Every field is data a consumer binds to at its own construction/call time (see the
module docstring) — there is no code path that branches on ``platform.name`` itself
outside this module and the small set of binding points documented at each field.
Attributes:
name: Stable identifier — ``"regular"`` or ``"picocalc"`` — used for
``--platform``/``MESHTERM_PLATFORM`` matching and diagnostics.
readable_cols: The readability standard (CLAUDE.md's "screens stay readable at
N columns") and the dual-platform gallery's test width.
readable_rows: The row *floor* a screen designs to. PicoCalc's live console is
53×26 (custom 6×12 font); a 6×8 font rebuild (P5) gives 53×40. Screens design
to this floor and exploit extra rows fluidly; the gallery renders picocalc at
both 26 and 40 rows.
frame_border: Whether the base screen draws inside a bordered ``Panel``. ``False``
swaps it for a title-bar row instead (+4 cols, +1 row reclaimed) — a PicoCalc
chrome saving, wired in P4.
header_atoms: Which segments compose the persistent header, in the vocabulary
``"version"``, ``"device"``, ``"badges"``, ``"pulse"``, ``"battery"``. Wired
into :func:`~meshterm.ui.menu._header` in P4; unconsumed until then.
footer_fkeys: Whether the footer is the fixed F1–F5 hint lane (with F6–F10 as
their paired opposites) instead of each screen's own ``footer_hint`` string.
Wired in P4.
width_reclaim: Whether the session may report one extra terminal column (see
:class:`~meshterm.ui.tui.session._WidthExtendedOutput`). Correctness-critical
on PicoCalc's exact-width console: a phantom extra column tears the frame, so
this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override
on top of this default.
emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes
through a compact single-BMP-glyph table instead (P3), and the emoji-width
calibration probe (:func:`~meshterm.ui.tui.emoji_width.calibrate`) is skipped
outright — there is nothing to calibrate once no emoji are ever drawn, and the
probe writes escape sequences the console font can't shape anyway.
ascii_fold: Whether names and message bodies are NFKD-folded (accents stripped) at
the render boundary before display. Storage is never touched. Wired in P3.
truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects
a 16-slot palette theme instead (the console's real ceiling — no per-cell RGB).
Wired in P3.
name_colour: ``"key"`` (today's per-node hash-derived hue spectrum) or ``"type"``
(colour by node type — client/repeater/room/sensor — because PicoCalc's 16
colour slots can't also afford a hash spectrum without collisions). Wired into
:func:`~meshterm.ui.theme.name_style` in P3.
effects: Whether animated/decorative rendering runs at all: corner glow, the
header battery blink, the braille spinner. ``False`` drops to static chrome
and a ``LINE`` spinner fallback — cheaper, and the glow already no-ops on a
non-truecolor theme, so this just makes that explicit. Wired in P2/P3.
tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval — the
*idle* repaint cadence only, since every screen pushes its own repaint through
``TuiSession.invalidate`` when its data actually moves. Composing one frame was
measured at 74 ms on the PicoCalc at 53×26 (110 ms at 53×40), so a 1 Hz idle
tick alone costs 7–11% of a core doing nothing; it ticks at half that rate
there. What drifts is cosmetic — the header's per-minute pulse and its packet
counter — and none of it is legible at one-second resolution anyway.
spinner_tick_s: Seconds between frames of a "working" spinner. The single source for
every animated wait in the app. The desktop's 0.12 s is not a rate this hardware
can hold: a PicoCalc frame would claim 62% of a core at 53×26, and at 53×40 its
110 ms of compose would leave under 10 ms of each interval for the work actually
being waited on — and before this phase's savings it did not fit at all, at
152 ms against a 120 ms budget. It slows to a cadence the hardware can hold
comfortably while still reading as alive.
battery: Which source feeds the header's battery gauge — ``"companion"`` (read
from the connected MeshCore device) or ``"host"`` (the PicoCalc's own sysfs
``power_supply`` driver, confirmed present in P0). Wired in P5.
modifier_watch: Whether the optional keyboard-lib/evdev Shift-state watcher may
engage, flipping the displayed F-key lane live while Shift is held. An
experiment gated to PicoCalc only — desktop terminals have no such lane to
flip, and the watcher needs ``/dev/input`` access this platform is known to
have (the deploy user in the ``input`` group). Always optional and lazy; wired in P4.
"""
name: str
readable_cols: int
readable_rows: int
frame_border: bool
header_atoms: tuple[str, ...]
footer_fkeys: bool
width_reclaim: bool
emoji: bool
ascii_fold: bool
truecolor: bool
name_colour: str
effects: bool
tick_s: float
spinner_tick_s: float
battery: str
modifier_watch: bool
#: Exactly today's behaviour — the desktop/ssh terminal, unchanged by this seam's arrival.
REGULAR = Platform(
name="regular",
readable_cols=72,
readable_rows=24,
frame_border=True,
header_atoms=("version", "device", "badges", "pulse", "battery"),
footer_fkeys=False,
width_reclaim=True,
emoji=True,
ascii_fold=False,
truecolor=True,
name_colour="key",
effects=True,
tick_s=1.0,
spinner_tick_s=0.12,
battery="companion",
modifier_watch=False,
)
#: The PicoCalc/Lyra/Calculinux framebuffer console. Field values not yet consumed by a
#: binding point (header_atoms, ascii_fold, truecolor, name_colour, effects beyond the
#: two P1 bindings, tick_s, battery, modifier_watch) are P2–P5's targets, recorded here
#: now so the seam exists before the flavour work lands — see .claude/plans/picocalc-platform.md.
PICOCALC = Platform(
name="picocalc",
readable_cols=53,
readable_rows=26,
frame_border=False,
# P4 decides "keep pulse" once it can measure whether it still earns its cells at 53;
# conservatively excluded until then. Version is dropped outright (plan decision).
header_atoms=("device", "badges", "battery"),
footer_fkeys=True,
width_reclaim=False,
emoji=False,
ascii_fold=True,
truecolor=False,
name_colour="type",
effects=False,
tick_s=2.0,
spinner_tick_s=0.5,
battery="host",
modifier_watch=True,
)
_BY_NAME = {p.name: p for p in (REGULAR, PICOCALC)}
#: The active platform. Do not import this name directly (see the module docstring) —
#: read it through :func:`get_platform`, and change it only through :func:`set_platform`.
PLATFORM = REGULAR
def get_platform() -> Platform:
"""Return the active :class:`Platform`.
Safe to call from anywhere, at any time — always reflects the most recent
:func:`set_platform` call, however that module was imported.
"""
return PLATFORM
def set_platform(platform: Platform) -> None:
"""Install ``platform`` as the active one.
Called exactly once by the CLI callback, before :func:`~meshterm.ui.theme.make_console`
— every other read of :func:`get_platform` in the same process happens after this.
Idempotent (setting the same platform twice is a no-op in effect) and reversible, so
tests can swap in a platform for one test and restore the previous one afterwards
(see the ``_reset_platform`` autouse fixture in ``tests/conftest.py``).
Args:
platform: The platform to make active.
"""
global PLATFORM
PLATFORM = platform
@dataclass(frozen=True, slots=True)
class Resolution:
"""What :func:`resolve` looked at and what it landed on.
Exists so the ``meshterm platform`` diagnostic can report every input the resolution
considered, not just the final answer.
Attributes:
flag: The ``--platform`` value passed on the command line, if any.
env: The ``MESHTERM_PLATFORM`` environment variable's value, if set.
detected_model: The ``/proc/device-tree/model`` contents read during resolution
(trailing NUL/space stripped), or ``None`` when the file doesn't exist (every
non-Lyra machine, including every desktop and CI runner).
source: Which input decided the outcome — ``"--platform flag"``,
``"MESHTERM_PLATFORM env var"``, ``"auto-detect"``, or ``"default"``.
platform: The resolved :class:`Platform`.
"""
flag: Optional[str]
env: Optional[str]
detected_model: Optional[str]
source: str
platform: Platform
#: Where the Lyra reports itself in the device tree. P0 (2026-08-01) recorded a trailing
#: space before the NUL; re-verified live on-device during P1 (2026-08-01) as exactly
#: ``b"Luckfox Lyra\x00"`` — no trailing space. :func:`_read_device_tree_model` strips
#: whatever surrounds it either way, so this is a documentation correction, not a behaviour
#: change — flagged here in case a different unit/firmware revision does carry one.
_LYRA_MODEL = "Luckfox Lyra"
_DEVICE_TREE_MODEL_PATH = Path("/proc/device-tree/model")
def _read_device_tree_model() -> Optional[str]:
"""Read and normalize ``/proc/device-tree/model``, or ``None`` when it doesn't exist.
Absent on every machine that isn't running a device-tree-based Linux kernel — every
desktop, every CI runner, and (today) every ``ssh`` session onto one of those. This is
deliberately the *only* signal auto-detection consults (no terminal-size guessing), so
a small window on the desktop is never mistaken for the device.
"""
try:
raw = _DEVICE_TREE_MODEL_PATH.read_bytes()
except OSError:
return None
return raw.decode("utf-8", "replace").rstrip("\x00").strip()
def _lookup(name: str, *, source: str) -> Platform:
"""Resolve a platform name to its :class:`Platform`, or raise a clear ``ValueError``."""
try:
return _BY_NAME[name.strip().lower()]
except KeyError:
choices = ", ".join(sorted(_BY_NAME))
# Plain ASCII: this can surface through Typer/Click's own error path (raised
# before make_console() has reconfigured stdout/stderr to UTF-8), which mangles
# non-ASCII on a legacy Windows console the way make_console's docstring warns of.
raise ValueError(
f"unknown platform {name!r} (from {source}) - choose from: {choices}"
) from None
def resolve(flag: Optional[str] = None) -> Resolution:
"""Decide which platform is active, and record what the decision looked at.
Resolution order: the explicit ``--platform`` flag, then the ``MESHTERM_PLATFORM``
environment variable, then device-tree auto-detection, then :data:`REGULAR`. Only the
input that actually decides the outcome is name-validated — an env var typo is
reported when the flag isn't set, but never blocks a session that passed an explicit
(correct) flag.
Args:
flag: The ``--platform`` command-line value, if given.
Returns:
The full :class:`Resolution`, for both immediate use (``.platform``) and the
``meshterm platform`` diagnostic's report.
Raises:
ValueError: The deciding input (flag or env var) named an unknown platform.
"""
env = os.environ.get("MESHTERM_PLATFORM") or None
model = _read_device_tree_model()
if flag:
return Resolution(flag, env, model, "--platform flag", _lookup(flag, source="--platform"))
if env:
return Resolution(
flag, env, model, "MESHTERM_PLATFORM env var",
_lookup(env, source="MESHTERM_PLATFORM"),
)
if model == _LYRA_MODEL:
return Resolution(flag, env, model, "auto-detect", PICOCALC)
return Resolution(flag, env, model, "default", REGULAR)
__all__ = [
"Platform",
"REGULAR",
"PICOCALC",
"PLATFORM",
"get_platform",
"set_platform",
"Resolution",
"resolve",
]