38 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 839da9ab8f A doctor for the one failure the map has no words for
The map is the only networked part of MeshTerm, and every failure on that path
is logged at DEBUG while log_level defaults to WARNING: a reader whose tiles
never arrive gets a blank ground and no explanation anywhere. On macOS that is
routinely a Python with no certificate store at all, which no amount of looking
at the app can reveal.

scripts/basemap-doctor.py is the explanation. It reports the interpreter, the
trust store, the tile source and the cached-tile count, then fetches the
TileJSON and one real tile the way BasemapSource does, and turns the failure
into one verdict with its fix.

The verdict is an A/B rather than a reading: curl verifies against the OS trust
store and Python against its own, so curl succeeding where Python fails is the
app's store and nothing else, while both refusing the same certificate is TLS
interception or a genuinely bad server — one exception, opposite causes,
opposite fixes. Collapsing curl's exit codes into "failed" had it blaming the
store and the network in the same breath.

It also answers the other way a map looks wrong, since it costs nothing in the
same round trip: an unset COLORTERM is 256 colours, where the basemap's greens
and blues collide.

Started under the wrong Python it finds the installed meshterm command, reads
its shebang and hands itself over, so nobody has to know which of their three
Pythons the app went into. By subprocess, not execve, which on Windows is not a
real exec and segfaults under a redirect — and stdout is flushed first, or the
banner loses the report it introduces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:05:07 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 12941b8f3c scripts/ is sorted by handheld: picocalc/ and uconsole/, indexed from the hardware manual
The Calculinux bring-up, both console-font builds, the GPL-2.0 text that belongs
beside the 6x8 one, and the xiao-radio firmware kit move under scripts/picocalc/;
the SPI bridge is scripts/uconsole/meshterm-spi-bridge. The folder's own README is
gone: its per-file index is now a section of docs/hardware.md, next to the table
that says which manual is yours. Every path in the manuals, NOTICE, CLAUDE.md, the
tests, the package docstrings and the scripts' own headers follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 22:36:49 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c75ad9a5ed hardware.md is the page that says which manual is yours; the manuals stand alone
docs/hardware.md shrinks to a router: which guide for which device, and the
two platform flavours. Everything the guides were still reaching into it for
moved home — the bridge's environment knobs into uconsole.md, and the
verified/unverified lists into each manual's closing section, split by
device (the PicoCalc's gains the MX1.25 pin order and the 5 V path, both
inferred rather than measured on a Lyra).

The PicoCalc manual gets the Wi-Fi kick back — it works and stays; only the
join script is going. The uConsole manual opens on the same kind of
disclaimer as the PicoCalc's, minus the hardware-handling parts: a LoRa
transmitter, a Linux shell, the guide may be wrong or stale, your call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:21:09 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 49a609fabf The 6x12 console-font script carries its size in its name, like the 6x8 one
scripts/calculinux-console-font.sh is now calculinux-console-font-6x12.sh.
Every reference follows: the setup script's hand-off, the 6x8 script's
SHARED default and comments, the theme16 test that parses it, the module
docstrings, the two manuals, the scripts index and CLAUDE.md. The font files
it builds keep their names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:00:46 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8533d9a9e8 MeshTerm on the PicoCalc is a manual from the shopping list to a working radio
docs/picocalc.md starts from a stock PicoCalc with its Pico and ends with a
Linux handheld running MeshTerm with a LoRa radio soldered inside: a priced
shopping list, the Lyra swap, writing Calculinux, the serial console, Wi-Fi,
the setup script phase by phase, then PlatformIO, the firmware build with the
patch explained hunk by hunk and a by-hand route for when it stops applying,
flashing, the four wires, the Lyra setup, day-to-day use and troubleshooting.

The README gains a Documentation table and points the handheld paragraph at
the two manuals; the script indexes and the changelog follow. hardware.md
stays as the reference behind both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:38:21 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 f16a7c4738 The shared font generator is offered under GPL-2.0-only too, so the 6x8 script may run it
The GPL-2.0-only 6x8 script executes the generator block out of the Apache-2.0 6x12 script, and GPLv2 and Apache-2.0 do not mix inside one program. Both files are the author's, so the block between the BEGIN/END markers is additionally available under GPL-2.0-only for that use; both headers and the hardware manual say so. No code changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 d078e2379f The XIAO firmware patch carries MeshCore's MIT notice beside it
meshcore-uart1.patch quotes upstream lines and build-firmware.sh produces a MeshCore binary, so the licence those copies travel under now sits beside them as scripts/xiao-radio/LICENSE.MeshCore, verbatim from upstream, and is named from the bench card and the hardware manual.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:17 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 ec21d03c03 The SPI bridge drives openhop_core, and stands its shims down where the library caught up
pymc_core was renamed openhop_core in 2026 and the current meshcore-uconsole
package depends on the new name, so a bridge that only knew the old one failed
its own preflight on a fresh install. It now finds either runtime, newest first
(an interpreter with openhop_core beats an earlier candidate with pymc_core;
MESHTERM_PYMC_PYTHON still overrides), imports through the name it found, and
lets two of its three compat shims stand down under openhop_core, which accepts
long ACK payloads and pushes completed trace replies itself; pushing a trace
twice was the alternative. The raw RX-log push is still the bridge's.

What the port found on the device shaped the rest. The 1.12.0 .deb is built for
trixie against Python 3.13 and cannot run on a bookworm uConsole, so the bridge
looks first in a venv of its own (~/.local/share/meshterm-spi-bridge/venv, made
with openhop-core[hardware]) and, without the GUI's helpers there, configures
the radio itself with the uConsole AIO preset rather than the runtime's
Waveshare defaults (TX-enable on pin 6, no DIO2 switch, no TCXO -- deaf on the
AIO), every MESHCORE_* knob the GUI reads honoured and passed only where the
constructor accepts it. It also loads the GUI's identity key first, so a bridge
in its own venv is still the same node. Verified on the uConsole: openhop-core
1.1.3, radio up, 159 contacts restored, info and contacts answered over TCP,
and the boot service now runs from that venv. The manual and the index say all
of this instead of the rename warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 01:29:28 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 3b67c6813e The PicoCalc's console font is a preference, and the shell gets its own back
A Linux VT loads one font for the whole console, so asking for the 6x8 build
(53x40, fourteen rows more) changes what the shell is drawn in too. The new
console_font preference (6x12 default, 6x8 the alternative, offered on the
PicoCalc only, round-tripping through preferences.yaml on every platform)
therefore comes with services/consolefont.py: remember the font the shell had
with `setfont -O`, apply the one the preference names, and restore it on every
way out -- the menu's finally and an atexit hook, since ^Q and an unhandled
error leave by other doors. Verified on the device that setfont -O captures
the current font with its unicode table and that feeding it back reproduces it
exactly, with no sudo, because the VT is the process's own controlling
terminal. Only the interactive menu switches; a scripted run never touches the
console it was invited into. Changing the preference on its page applies live.

Shipping both fonts needed the licence untangled: the 6x8 base is the Linux
kernel's font_6x8 bitmap, GPL-2.0, so its build moves into
calculinux-console-font-6x8.sh under that licence with the GPLv2 text beside it
in scripts/LICENSE.GPL-2.0. The shared generator stays in the 6x12 script
between BEGIN/END markers and the 6x8 script executes that block, so the marks,
donors and aliases the font contract pins exist in one place. The produced PSF
is a data file the kernel console reads; MeshTerm never links or bundles it, and
NOTICE and the hardware manual say so. PrefSpec gains a platforms gate for the
page; the labels use an ASCII x because U+00D7 is not in the console font.
The 6x8 base has no Cyrillic; the manual says so and the owner accepts it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:52:54 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 8d3f5d8ed3 MeshTerm on hardware: one manual for the PicoCalc, the XIAO radio and the SPI bridge
docs/hardware.md is the single place a reader goes to put MeshTerm on a
PicoCalc or a uConsole, written in the shape of the command-line manual: what
the three targets are and which exclude each other, why the console font is
built on the device, every phase of the Calculinux bring-up and its knobs, the
XIAO UART radio from parts to a verified link, the SPI bridge with an honest
note that it was written against pymc_core 1.0.x while today's uConsole stack
depends on the renamed openhop-core, and a closing list of what has only ever
been proven on one bench. scripts/README.md and xiao-radio/README.md become
short indexes pointing at it; the old ones were stale in four places (trace
through the bridge works, the firmware env is _serial, two scripts were
unlisted). README links the manual, gains the two dependency rows it lacked
(markdown-it-py, PyYAML) and a fixed fonts anchor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 19:02:02 -04:00
Jean-Pierre MartineauandClaude Fable 5.1 9ebc4bd2be The device scripts say what they do, and the bring-up no longer needs a deploy key
A read-through of scripts/ ahead of the public release, applying only what could
be verified from the code. The setup script clones over public HTTPS when no
deploy key sits at KEY_PATH and keeps the SSH path when one does, and its
timezone is a TIMEZONE knob rather than a hardcoded city. The flasher recognises
the plain XIAO nRF52840's bootloader (PID 0x0044) as well as the Sense's
(0x0045), both from Adafruit's board.h. The mux unit was After= the very target
that WantedBy= pulls it into, an ordering cycle; it now orders on basic.target,
which still wants a check on the device. The console-font header stopped
claiming a default run installs /etc/vtrgb (it removes the custom remap), and
states that the font it builds is an OFL-1.1 Terminus derivative that must not
be called Terminus and that the optional 6x8 candidate embeds a GPL-2.0 kernel
bitmap. The radio-bridge header describes the four phases the script actually
runs instead of a pin-routing phase it never contained. Personal remarks in
comments are reworded impersonally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 19:02:02 -04:00
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 4545173c65 Every public callable says what it is for
The 46 missing docstrings, written rather than generated.

Most of the test ones already carried the explanation — as a comment sitting on
the first line of the body. That prose is the docstring; it was only in the
wrong place, so the config editor's PIN and lane tests keep their own words and
lose the duplicate comment. The rest are new: the two `repo` fixtures, the
counting `FakeDevice` in `test_device_state` (each method now says what it
answers *and* that it counts the call), the PicoCalc chrome and 16-slot theme
tests, and three one-offs.

In the package: `PathLine` and `FastRenderer`'s constructors point at the class
docstring that already documents their arguments rather than repeating it,
`SelectItem.__post_init__` and `courier_screen`'s opener say what they cache and
why, and `emoji_width.create_content` explains the memo it wraps the line lookup
in. `flash.py` and `uart1-mux.py` get theirs too, and `tests/__init__.py`
finally says what the package is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:28:01 -04:00
Jean-Pierre MartineauandClaude Opus 5 b5006cc415 Every line fits the hundred columns it was configured for
`E501` across 69 lines, wrapped rather than exempted.

The prose ones — module docstrings in `menu`, `node_detail_screen`,
`pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are
reflowed as paragraphs, so the block keeps its shape instead of gaining one
short orphan line. Nothing they say changed.

The code ones mostly gained a line break inside a call. Where a break would
have read worse than a name, the sub-expression got one instead: `reach` in
`topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the
trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip`
helper in two tests that were comparing or matching the same shape twice on one
line. Four docstring summaries lost a word they were not using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:22:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 e9a4f644d8 A line variable is called a line, and a format string is one string
The mechanical tail of the ruff pass, all of it behaviour-preserving.

`E741`: 29 uses of the ambiguous `l`, which reads as a `1` in most fonts.
Twenty-four of them held a rendered line and are now `ln`; three held an MVT
`Layer` and are now `layer`; two were a map label sorted by rank, now `lab`.
Renaming only the flagged line and then re-running `F821` was the check — a
half-renamed comprehension leaves a name nothing defines, so the linter finds
what a careful read might not.

`UP031`: 15 `%`-format strings become `.format` calls. `UP007`: the `CellStyle`
and `LabelT` aliases become `X | Y`; both are annotation-only, so the runtime
value never mattered. `E702`: four semicolon-chained `await` lines in
`test_device_state` get a line each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:14:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 8a03cadc4e The deploy scripts default to a user anyone can be
DEPLOY_USER and MT_USER were overridable from the environment since the commits
that introduced them -- that part was always right, and is why they never moved
to .dev.env: they run on the target device, where a gitignored file on someone
else's workstation does not exist.

Only the fallback was wrong: it named one person, so a stranger who runs the
setup script unmodified gets a Linux account named after the maintainer. It is
`meshterm` now, and the comments describing it say the knob rather than the
value.

Nothing changes for the Lyra provisioned under the old name: these are one-time
setup scripts, and re-running one takes DEPLOY_USER set to the old name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:45:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 892aefa11b The machine you develop on stops being written into the repo
Every device address, login, key path, password and local venv path in the
.claude/ skills moves to dev.env at the repo root: gitignored, with a tracked
dev.env.example showing the shape. The skills now name $DEV_* keys and say to
read dev.env first, so they still run -- against whoever's hardware.

Two of the values were not secrets so much as a wrong assumption. The device
paths under that home directory are now $HOME-relative in run_instrumented.sh,
instrument.py and drive_console.py, and the deploy docs point at $DEPLOY_USER
and $KEY_PATH, which those scripts already parameterised. Hardcoding a home
directory was only ever correct for one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:33:38 -04:00
Jean-Pierre Martineau eb8ecb46c8 Updated splash screen (ANSI art, CP437) 2026-08-25 01:31:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 c439dec289 Wordmark: an .ans is CP437, and its newlines are not its rows
The extension is .ans now, not .ansi — the software that draws this stuff is
old and particular about 8.3 names, and .ans is what it expects to open.

The wide mark is a real ANSI-art export rather than something colour-logo.py
painted, which brings two DOS habits the loader had never met. Its blocks and
box-drawing are single bytes in codepage 437, so reading it as UTF-8 raised
instead of drawing; we try UTF-8 first and fall back, and the 53-column mark
keeps its own encoding either way. And an art editor drops the line break on a
row that fills the canvas, trusting the terminal to wrap it — so the file has
nine newlines for sixteen rows. The canvas width comes from the SAUCE record,
which is trimmed off in the same breath, being metadata and not art.

Re-breaking a wrapped row has to restate the colour across the seam: each row
becomes its own Text.from_ansi, so nothing carries state the way a terminal
does when it never stopped reading the one stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 01:04:53 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 16532ea14b Bridge: forward the RX packet log so the live feed isn't advert-only
MeshTerm's live feed is built from RX_LOG_DATA events — every overheard
frame with its SNR/RSSI. pymc_core's frame server has push_rx_raw (the
0x88 push) but its _setup_push_callbacks never registers on_rx_log_data,
so it is never called: the promiscuous log is dropped and only adverts —
which carry their own wired advert_received push — reach the feed. The
radio was plainly hearing requests and channel text too; none of it showed.

Subscribe to the dispatcher's raw-packet feed directly and push each frame.
Routing through the dispatcher rather than on_rx_log_data keeps it clear of
the frame server's per-connection clear_push_callbacks(), and that feed
fires before dedup, so the client sees every repeat — what a live feed wants.

Same missing-wiring shape as the trace push; upstream fix is likewise to
register on_rx_log_data -> push_rx_raw in _setup_push_callbacks. Confirmed
against a live mesh: the feed now shows the full packet mix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 15:22:27 -04:00
Jean-Pierre Martineau 3a02a14be7 XIAO radio: build upstream's serial env, not our own Serial1 patch
MeshCore has carried companion-over-hardware-UART for a long time — the defines
SERIAL_RX/SERIAL_TX, packaged per board as a *_companion_radio_serial env, and the
ESP32-S3 twin of this very board already uses the same D6/D7 pads. Our patch was
reimplementing it for the one platform it had never been wired into: at our old pin the
SERIAL_RX branches lived inside the ESP32 and RP2040 blocks and nRF52 fell through to
Serial. So the patch is now the two things actually missing, both upstream-shaped and
submitted: a Serial1 reference so the shared block compiles on nRF52, and the
Xiao_nrf52_companion_radio_serial env carrying the I2C escape in build_unflags rather
than editing the flags every XIAO env inherits.

Pin moves to a dev commit, since dev is the branch MeshCore takes work on.

flash.py grew the check that was missing when it mattered: a drive letter is not an
identity — when one board leaves the USB bus another inherits its letter — so it now
reads INFO_UF2.TXT and refuses a bootloader that isn't a XIAO, or that carries S140
6.1.1 and would take this S140-v7 image at the wrong address and never boot. It also
stops pretending copy-to-drive is the only route: our bootloader exposes a serial port
and no mass storage at all, so it prints the serial-DFU command instead of a dead end.

Verified from a clean clone at the new pin: env builds, uf2 converts to the same 811008
bytes at 0x27000 that is now running on the radio, and both Xiao_nrf52_companion_radio_usb
and Xiao_S3_WIO_companion_radio_serial still build, so neither side of the new #if moved.
2026-08-13 00:58:54 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e5c756995e Bridge: only push completed trace replies, with real per-hop SNRs
The first cut pushed TRACE_DATA for any received trace packet, so every
trace read as an instant success: the dispatcher is promiscuous and fires
the packet-received callback for strangers' traces and for our own
outbound echoed back by the first repeater (same tag, still in flight).

Two corrections:

- Correlate against traces we originated. Wrap send_trace_path_raw (the one
  send seam the frame server calls) to remember each tag and its route, and
  push only a received trace whose tag we sent. Entries expire after 90s so a
  never-answered trace can't match a recycled tag.

- Read the SNRs from the right place. A TRACE keeps its route (hop hashes)
  in the payload unchanged; the per-hop SNRs ride in the packet *header* path
  field, one signed byte (SNR*4) per hop. The earlier code waited for SNRs to
  appear in the payload, so it never fired. Now completeness is "the header
  has accumulated an SNR for every route hop", and that's the only thing we
  push — the finished reply, with genuine per-hop SNRs.

Pending on-air verification (RF conditions poor at commit time); leaves
verbose per-trace INFO logging in place for that test, to be trimmed once a
clean multi-hop trace is confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:47:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 744e021fb0 Bridge: push TRACE_DATA for over-the-air trace replies
pymc_core never forwards a received trace to the companion client: its
frame server registers every push callback except on_trace_received, and
nothing in the library ever fires the trace_received event, so the whole
path is dead code — the only push_trace_data call is the self-hop
shortcut in _cmd_send_trace_path. A trace sent from MeshTerm therefore
returns RESP_CODE_SENT and then times out, because the node parses the
reply and drops it.

Shim it at the dispatcher's packet-received callback (where CompanionRadio
already watches every RX packet with its measured SNR): wrap it, and for a
TRACE payload re-slice the on-air bytes into the hash/SNR blocks
push_trace_data wants, emitting the standard 0x89 frame the meshcore client
decodes. Verified byte-exact end to end. Handles both the SNR-per-hop
layout (final SNR from our reception) and firmware that trails the final
SNR in the payload.

Upstream fix (no local pymc_core checkout to carry it): mirror every other
push — fire trace_received from _on_packet_received and register
on_trace_received in frame_server._setup_push_callbacks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 23:18:14 -04:00
Jean-Pierre MartineauandClaude Opus 5 eaafc87403 Narrow wordmark for narrow screens; move the art out of the code
JP drew a 53-cell MeshTerm at the original's height. It gets the same
colouring: the vertical ramp — white, yellow, light red, red, light
black — with every box-drawing character, shadow row and letter bevel
alike, in the light black.

The art moves to meshterm/assets/. It was sitting in meshterm/ui/ among
the modules, which is the wrong place for a file nothing imports; it
still ships inside the package, since the splash reads it at runtime and
an installed venv has no repo to read from. scripts/colour-logo.py paints
a .txt into its .ansi, so the palette is stated once in code instead of
retyped into escape sequences by hand, and re-styling stays a text edit.

Which mark is drawn is a question about the *screen*, not the platform: a
53-column console and a desktop terminal dragged narrow have the same
problem. load_logo(cols) answers it — widest that fits — and
compose_startup asks, since it is the only place that knows the width.
Below the smallest mark it returns nothing at all; no banner beats a torn
one. A test now draws the real wordmark at each platform's width, which
is the gate the splash never had (it is the one screen the gallery
doesn't cover, which is how a 71-cell mark shipped to a 53-cell console).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:53:28 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 16f0101b74 Add wifi-set: interactive Wi-Fi join script for Calculinux
Writes the PSK straight to /var/lib/iwd/<SSID>.psk (same pattern
calculinux-setup.sh uses for first-boot provisioning) then asks iwd to
join right away. Self-elevates via sudo since that path is root-only.
Deployed to that user's ~/bin on the Lyra device (PATH updated in ~/.profile).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 12:49:43 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 c8ce24340e Calculinux: set Eastern timezone at bring-up
The board has no notion of local time -- glibc defaults to UTC -- so
astimezone() calls across the UI rendered heard-ages and timestamps in
UTC instead of JP's Eastern time. Adds a setup step that tries
timedatectl + tzdata's opkg feed first, falling back to a POSIX TZ
rule (America/Toronto and America/Montreal share the same US/Canada
Eastern DST rule since 2007) written to /etc/environment and that user's
~/.profile when the feed is unreachable, as it currently is.

Verified live on the deployed Lyra: UTC 03:17 now reads EDT 23:17
correctly through both a login shell and /etc/environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 23:18:47 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 4bae4f1faf Calculinux: sync clock at boot once network is up
The Lyra has no battery-backed RTC, so every cold boot starts at the
kernel's build-time epoch until something sets it -- wrong for
heard-node ages, TTL, and the SQLite observation log until then.

Adds time-sync.service alongside wifi-kick.service: a oneshot unit
that waits for a real default route, then steps the clock via
whichever NTP client the image ships (chronyd/ntpd/sntp), falling
back to an HTTPS response's Date header if none is present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:04:57 -04:00
Jean-Pierre MartineauandClaude Fable 5 2f8f77be49 Review round: the standard palette, by JP's call
The console keeps its stock kernel palette - black background, classic
PC hues - instead of the custom setvtrgb remap. MESH_THEME_16 is
re-tuned against the stock slots, which hands back the map's own
language for free: clients wear bright pink (13), repeaters purple (5),
rooms cyan (6), sensors brown (3). The cost is one dark grey - muted,
faint and track all collapse onto slot 8. Heat quantizes on plain human
units (15 min / 1 h / 1 day). The custom design is archived, not
deleted: theme._VT_SLOTS_CUSTOM + MESHTERM_CUSTOM_PALETTE=1 on the font
script reinstalls it, with the parity test keeping the two in sync.

Undoing the remap on the device exposed a trap: setvtrgb overwrites the
kernel's DEFAULT colormap, so the OSC palette-reset "resets" straight
back to the custom values - the script's default mode now programs the
stock palette explicitly (verified live; specimen screenshot in
.claude/plans/p3-specimen-stock.png).

The header also follows JP's spec: the handheld brands the app -
"MeshTerm vX" - instead of naming a soldered port that never changes,
and the activity pulse takes whatever room remains.

Also root-causes the intermittent suite flake: Rich memoizes a parsed
Style's ANSI codes on first render (Style.parse is globally cached), so
a raw hex style first rendered by a truecolor console replays 38;2
verbatim on a standard console later in the process. The specimen's
nodes row now wears the type.* theme styles - which is also just
correct: the marks now match the names row. Six consecutive clean
full-suite runs.

Deferred at JP's request: the glyph substitution map (he will redo it),
the F-key lane design (awaits his explicit spec), the CLI fold gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:53:31 -04:00
Jean-Pierre MartineauandClaude Fable 5 89eef7f4d2 P5: device integration - host battery, the 6x8 A/B font, deploy updates
- BatteryService grows the host provider (Platform.battery="host"): the
  PicoCalc's own pack through the kernel power_supply driver - a true
  percent and charging flag from sysfs, no voltage-curve estimation, no
  radio connection needed. Unreadable supply = absent, gauge hidden.
- The console-font script now builds TWO 512-glyph PSF2 fonts: the 6x12
  Terminus-based default (53x26) and a 6x8 A/B candidate (53x40) based
  on the kernel's own font_6x8 (CP437 coverage - documented as having
  no Cyrillic), with the braille block at 2px dot pitch and all 18
  marks redrawn for the shorter cell. Built and verified live on the
  Lyra: stty reports 53x40 under meshterm8, specimen legible
  (.claude/plans/p5-specimen-6x8.png); flip with setfont, persist the
  winner in vconsole.conf. JP judges the A/B by eye.
- calculinux-setup.sh puts the deploy user in input/dialout/video
  (input is the modifier watch's /dev/input access).
- theme's platform binding now drops the fold table and cache on every
  switch: its emoji pads bake in cell_len at build time, which the
  regular platform's emoji-width calibration can re-measure - a stale
  table survived platform swaps in randomized test runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:08:17 -04:00
Jean-Pierre MartineauandClaude Fable 5 84caae53f8 P3 rework: the PicoCalc visual language, done properly
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:

- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
  families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
  rewritten against color(0..15) with the VT's bold-as-brightness and
  dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
  install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
  so theme, rasterizer and widgets swap implementations once per switch
  and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
  (NFKD accent folds - the Terminus base has no accented Latin - plus
  symbol stand-ins and the width-padded emoji map), an embedded-SGR
  quantizer for canvas truecolor, and a ?-at-own-width catch-all that
  makes the no-wide-glyph short-circuit true by construction. Applied at
  the only two places every visible byte passes: render_to_ansi and
  MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
  the gallery now asserts rendered picocalc ANSI carries no truecolor/
  256-colour SGR and no character outside it - the parity gate the
  first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
  gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
  donor misses now fatal and a keeper canary (donating pi once erased
  Cyrillic pe - shared slot). Table re-dumped on-device: identical to
  fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
  no longer import ui), fed from monitor observations, heard_nodes and
  merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
  drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
  funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
  marks, not the banned emoji pair.

Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:48:07 -04:00
Jean-Pierre MartineauandClaude Haiku 4.5 66d728a3b3 Untracked: planning and work-in-progress notes
- .claude/plans/picocalc-platform.md: master plan for dual-platform work (P0–P7)
- .claude/housekeeping.md: parking lot for deferred tasks
- scripts/calculinux-radio-bridge.sh: WIP UART bridge setup (P0 blocker on pinmux)

Keep these checked in so context persists across work sessions.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-01 11:37:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 077742868a scripts/xiao-radio: PicoCalc XIAO nRF52840 UART radio setup
Complete, reproducible bring-up for giving a PicoCalc (Luckfox Lyra, Calculinux) a
MeshCore radio via a Seeed XIAO nRF52840 + Wio-SX1262 on the Lyra's hardware UART1:

  - README.md    -- parts, wiring, build -> flash -> setup, the why, troubleshooting
  - build-firmware.sh  -- clone + patch MeshCore, build the _usb env, emit the .uf2
  - flash.py           -- flash the .uf2 via the XIAO UF2 bootloader (Win/Linux/Mac)
  - lyra-setup.sh      -- boot-persistent UART1->GP4/GP5 mux service, dialout, profile
  - uart1-mux.py       -- the RK3506 matrix-IO register poke that routes UART1
  - meshcore-uart1.patch -- the two firmware edits (Serial1 bind; I2C off D6/D7)

The two firmware edits are both required: bind the companion to hardware UART1
(Serial1 = D6/D7) instead of USB, and move I2C off D6/D7 (MeshCore maps it there and
sensors.begin() would otherwise seize the UART pins). Also documented in the top-level
scripts/README.md alongside the Calculinux and uConsole-SPI targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 02:02:48 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2f75bb597c Add Calculinux one-time setup script
Sequences the full Lyra/PicoCalc bring-up: opkg python packages, the
rtl8xxxu Wi-Fi boot-scan kick (installed verbatim from the working device),
the deploy user, the read-only SSH clone, the venv + editable install with
TMPDIR off the RAM tmpfs, the login PATH, and the console font. Idempotent;
secrets (deploy key, Wi-Fi PSK) are checked for or read from the environment,
never embedded. README documents both scripts and their run order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:18:13 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b11027ec3d Add Calculinux console-font setup script
Builds, applies, and persists the 512-glyph "meshterm" PSF: Terminus base
+ 256 braille + node/status marks and the list cursor in donor slots +
rounded panel corners aliased onto the square corners. Idempotent; runs
on-device as root against the stock ter-u12n font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:17:12 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e4d0b5d964 Bridge: persist the node's contacts across restarts
pymc_core's ContactStore is memory-only, so every bridge restart forgot
every contact and DMs failed with ERR_CODE_NOT_FOUND until the peer was
heard advertising again. Snapshot the store to contacts.json after every
mutation and reload it at startup, alongside the identity the bridge
already persists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:18:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1b8c84f2dd Bridge: accept modern MeshCore ACKs with extra bytes after the CRC
Newer MeshCore firmware sends discrete ACK payloads longer than 4 bytes
(4-byte CRC first, then extras). pymc_core's AckHandler requires exactly
4 bytes and silently drops them, so DMs sent through the SPI bridge never
show delivery confirmation. Patch process_discrete_ack at bridge startup
to take the leading 4 bytes as the CRC, matching pymc_core's own
path-bundled ACK parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:07:25 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 22f85e3d51 scripts: add the SPI-radio bridge helper
Expose an SPI-attached MeshCore radio (uConsole AIO, Waveshare HATs) to
MeshTerm over the companion protocol on local TCP. A single stdlib-only
file that is both a plain-text setup menu and the bridge process: it finds
the pymc_core runtime and re-execs the bridge under it, offers run-now /
install-as-a-service / uninstall, and can add a [profiles.bridge] block so
the endpoint shows up in MeshTerm's device picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 03:32:31 -04:00