`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>
PicoCalc mesh radio — XIAO nRF52840 over UART
Give a PicoCalc (Luckfox Lyra, Calculinux) a real MeshCore LoRa radio by wiring a Seeed XIAO nRF52840 + Wio‑SX1262 to the Lyra's hardware UART and running MeshTerm against it. No BLE, no USB host, no SD‑slot sacrifice, and — with the firmware fixed the right way — no re‑soldering dead ends.
MeshTerm ──serial──> /dev/ttyS1 ──UART1 (GP4/GP5)──> XIAO Serial1 (D6/D7) ──> SX1262 ──))) mesh
(on the Lyra) 115200 8N1 matrix‑IO mux companion fw
Three steps: build firmware → flash the XIAO → set up the Lyra. Each has a script.
1. Hardware
Bill of materials
- Seeed XIAO nRF52840 (plain or Sense) stacked with the Wio‑SX1262 LoRa module (the SX1262 uses XIAO pins D1–D5, D8–D10 — already wired inside the kit; nothing to do).
- Luckfox Lyra in Pico form factor (RK3506, SD‑boot), running Calculinux inside a ClockworkPi PicoCalc.
- Four wires.
Wiring — four connections
The XIAO's free UART pins go to the PicoCalc's Pico‑style header. Crossed TX/RX:
| XIAO pad | → | Lyra header | Physical pin | Carries |
|---|---|---|---|---|
| D7 (Serial1 RX) | → | GP4 | pin 6 | Lyra UART1 TX → XIAO RX |
| D6 (Serial1 TX) | → | GP5 | pin 7 | XIAO TX → Lyra UART1 RX |
| GND | → | GND | pin 8 | common ground |
| 3V3 | → | 3V3 OUT | pin 36 | power (so it runs without USB) |
⚠️ The Lyra does not use Raspberry Pi Pico GPIO numbering. The 40‑pin header is physically Pico‑compatible, but each pad's function is Luckfox's own RK3506 mapping. The pins above are verified: GP4 =
gpio0-0, GP5 =gpio0-1. Don't trust a Pico pinout for anything else here.
All signals are 3.3 V — no level shifter. There is no 5 V rail on this board; power the XIAO from 3V3 only.
2. Build the firmware (on your dev machine)
Needs git and PlatformIO (pip install platformio).
cd scripts/xiao-radio
sh build-firmware.sh
This clones MeshCore, applies meshcore-uart1.patch, builds the
Xiao_nrf52_companion_radio_serial environment, and writes meshcore-xiao-radio.uf2.
The patch makes two changes that are both required (see Why):
- teaches MeshCore's existing
SERIAL_RXcompanion interface to build on nRF52 — it was written for ESP32 and declares aHardwareSerialthe Adafruit nRF52 core doesn't have; - adds the
Xiao_nrf52_companion_radio_serialenv, which puts the companion on D6/D7 and moves the I²C bus off those pads (to internal pins 16/17) so it can't steal them.
Both changes are upstream-shaped and have been submitted to MeshCore. If they land, this patch step disappears: the env ships with the firmware and you just build it.
3. Flash the XIAO (on your dev machine)
Plug the XIAO's own USB‑C into your computer (it may stay wired to the Lyra).
python flash.py
It pulses the XIAO's app port at 1200 baud to drop it into the bootloader, checks that the bootloader it found really is a XIAO, and copies the firmware. If it can't get there, double‑tap the XIAO's reset button and re‑run.
If no UF2 drive appears, that isn't necessarily a fault: on our board the bootloader came
up as a serial port only, with no mass‑storage interface at all, so there is nothing to
copy a file onto. flash.py detects that and prints the serial‑DFU command to run from your
MeshCore checkout instead:
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
That is the route that worked here — it ends in Device programmed. and the XIAO reboots
itself into the radio firmware.
⚠️ Check which board you are flashing. A drive letter is not an identity — when one board leaves the USB bus another can inherit its letter, so the volume you found may not be the one you just touched.
flash.pyreadsINFO_UF2.TXTand refuses anything that isn't a XIAO, or that carries SoftDevice S140 6.1.1 (which wants the app at0x26000, while this firmware links for S140 v7 at0x27000— it would flash and then not boot). Override with--forceonly if you know better than it does.
Sanity check: after flashing, the XIAO's USB serial port goes silent to the companion protocol — because the companion now lives on D6/D7, not USB. That silence is correct.
4. Set up the Lyra (on the device, as root)
Copy scripts/ to the Lyra and run:
sh scripts/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
It installs a systemd service that routes UART1 → GP4/GP5 on every boot, adds your user
to dialout, and writes a default MeshTerm serial profile for /dev/ttyS1.
5. Run it
meshterm
With the default profile in place, plain meshterm connects straight to the radio. You
should see your node come up (ours reports as Johnputer-Pico).
Why it works (the short version)
Everything below was the hard part; the scripts encode the answers so you don't repeat it.
- MeshCore already had companion‑over‑UART; nRF52 was just never wired into it. The
SERIAL_RX/SERIAL_TXdefines and the per‑board*_companion_radio_serialenvs have shipped for a while —Xiao_S3_WIO_companion_radio_serialuses the same D6/D7 pads on the ESP32‑S3 twin of this board. The nRF52 build just doesn't compile: the shared code declaresHardwareSerial companion_serial(1)and callssetPins(), and on the Adafruit nRF52 coreHardwareSerialis abstract with no numbered constructor.Serial1is the concreteUartthe core always defines, andUart::setPins(pin_rx, pin_tx)takes the same arguments in the same order — so one reference fixes it and the call site is untouched. - Calculinux never wires UART1 to a pad. The controller is enabled (so
/dev/ttyS1exists) but reaches no pin. The RK3506 matrix IO can carry UART1 on almost any pad, souart1-mux.pypokes two register groups over/dev/memto put UART1‑TX ongpio0-0(GP4) and UART1‑RX ongpio0-1(GP5). No SD slot is sacrificed (the stock UART1 pads are the front SD‑card slot — we avoid them entirely). - MeshCore maps I²C onto D6/D7 on the XIAO.
XiaoNrf52Board::begin()andsensors.begin()callWireon those exact pins, so the I²C peripheral seizes them and the UART goes silent — even with perfect wiring. The patch moves I²C to the internal IMU pins (16/17). This was the single reason the link stayed dead through every other fix. - MeshTerm liveness for soldered UARTs. pyserial's
comports()doesn't enumerate platform‑bus ports like/dev/ttyS1, so MeshTerm's liveness poll would falsely report the radio unplugged. MeshTerm now treats an existing/devcharacter device as present (serial_port_presentinmeshterm/core/connection.py) — so use a MeshTerm build that includes that fix.
Troubleshooting
| Symptom | Check |
|---|---|
meshterm can't open the port |
Is MT_USER in dialout? (groups) — needs a fresh login after setup. |
| Port opens but no node / silence | Re‑flash with the _serial env (not _ble/_usb); only that one defines SERIAL_RX/SERIAL_TX. Confirm the XIAO's USB serial is silent to companion frames. |
| Still silent, wiring confirmed | Confirm the firmware has the I²C‑remap (PIN_WIRE_SCL=16, PIN_WIRE_SDA=17). |
| Flashed fine, board never comes back | Wrong board, or wrong SoftDevice — check INFO_UF2.TXT said a XIAO and S140 v7, not 6.1.1. flash.py refuses both unless you passed --force. |
flash.py finds no UF2 drive |
Expected on a bootloader that exposes CDC only. Use the pio … -t upload --upload-port line it prints. |
Nothing on /dev/ttyS1 after reboot |
systemctl status uart1-radio-mux — the mux must run each boot. |
| Verify the raw link, as root | Route the mux, open /dev/ttyS1 @115200, send an APP_START frame (3c 0e 00 01 + 7×00 + name); a good radio replies with a >‑framed 05 (SELF_INFO). |
Updating MeshCore
build-firmware.sh pins a tested commit on MeshCore's dev branch (that's where work lands
first). To track upstream, run it with MESHCORE_COMMIT=dev; if the patch no longer applies
cleanly, re‑apply the two edits in meshcore-uart1.patch by hand —
they're two hunks.
This patch is upstream in review. If it merges, drop the git apply line from
build-firmware.sh and delete the patch: Xiao_nrf52_companion_radio_serial becomes a stock
env and there is nothing left to carry.
Optional: reach the radio from other machines (TCP)
The direct‑serial path above is for MeshTerm on the PicoCalc itself. If you also want the
radio on your LAN (meshterm --tcp <lyra-ip>:5000 from a laptop), add a UART↔TCP pump in
front of /dev/ttyS1. That's a separate, optional piece and is not required for on‑device use.