`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>
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>
`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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
- .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>
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>
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>
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>
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>
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>
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>