One frozen Platform spec (REGULAR/PICOCALC), resolved once at boot (--platform flag > MESHTERM_PLATFORM env > /proc/device-tree/model auto-detect > REGULAR) and read via get_platform()/set_platform() - never a stale `from platforms import PLATFORM`, which the module docstring calls out. `meshterm platform` reports the resolution and everything it looked at. Wires the two correctness bindings that matter before any flavour work lands: width_reclaim off on PicoCalc's exact-width console (session._reclaim_last_column, env override still wins), and the emoji-width calibration probe skipped outright when a platform never draws emoji. tests/test_gallery.py renders all 16 screens the interactive menu opens against REGULAR (72x24) and PICOCALC (53x26 live floor, 53x40 lux case), asserting no line overflows its platform - 35 passing cases, 13 xfailed as a real P6 worklist (contacts, map, walk, livefeed, remote_cli, path_composer). Also caught and left alone: the map screen's footer hint already overflowed the *existing* 72-col standard before this seam existed, unrelated to picocalc - xfailed rather than fixed as a drive-by. tests/test_platforms.py unit-tests the module directly; conftest.py gained an autouse fixture resetting the platform between tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
287 lines
13 KiB
Python
287 lines
13 KiB
Python
"""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.
|
||
Both platforms start at today's 1.0s; PicoCalc's is revisited in P2 once
|
||
on-device CPU numbers exist to tune against.
|
||
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
|
||
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,
|
||
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=1.0,
|
||
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",
|
||
]
|