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.
This commit is contained in:
Jean-Pierre Martineau committed 2026-08-13 00:58:54 -04:00
1 parent b015091ae0
commit 3a02a14be7
5 files changed
+191 -41

No files matched your search

+4
View File
@@ -17,3 +17,7 @@ venv/
# MeshTerm runtime data
*.db
output/
# Built radio firmware (scripts/xiao-radio/build-firmware.sh writes it here)
scripts/xiao-radio/*.uf2
scripts/xiao-radio/_meshcore-build/
+50 -10
View File
@@ -53,11 +53,16 @@ sh build-firmware.sh
```
This clones MeshCore, applies [`meshcore-uart1.patch`](meshcore-uart1.patch), builds the
`Xiao_nrf52_companion_radio_usb` environment, and writes **`meshcore-xiao-radio.uf2`**.
`Xiao_nrf52_companion_radio_serial` environment, and writes **`meshcore-xiao-radio.uf2`**.
The patch makes two changes that are *both* required (see [Why](#why-it-works)):
- binds the companion protocol to **hardware UART1** (`Serial1`, D6/D7) instead of USB;
- moves the **I²C bus off D6/D7** (to internal pins 16/17) so it can't steal the UART pins.
- teaches MeshCore's existing **`SERIAL_RX` companion interface** to build on nRF52 — it was
written for ESP32 and declares a `HardwareSerial` the Adafruit nRF52 core doesn't have;
- adds the **`Xiao_nrf52_companion_radio_serial` env**, 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.
---
@@ -69,8 +74,28 @@ Plug the **XIAO's own USB‑C** into your computer (it may stay wired to the Lyr
python flash.py
```
It triggers the bootloader and copies the firmware. If it can't, **double‑tap the XIAO's
reset button** (it mounts as the `XIAO-SENSE` drive) and re‑run.
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:
```bash
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.py` reads `INFO_UF2.TXT` and refuses anything that isn't a
> XIAO, or that carries **SoftDevice S140 6.1.1** (which wants the app at `0x26000`, while
> this firmware links for S140 v7 at `0x27000` — it would flash and then not boot). Override
> with `--force` only 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.
@@ -105,6 +130,14 @@ should see your node come up (ours reports as `Johnputer-Pico`).
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_TX` defines and the per‑board `*_companion_radio_serial` envs have
shipped for a while — `Xiao_S3_WIO_companion_radio_serial` uses *the same D6/D7 pads* on the
ESP32‑S3 twin of this board. The nRF52 build just doesn't compile: the shared code declares
`HardwareSerial companion_serial(1)` and calls `setPins()`, and on the Adafruit nRF52 core
`HardwareSerial` is abstract with no numbered constructor. `Serial1` is the concrete `Uart`
the core always defines, and `Uart::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/ttyS1`
exists) but reaches no pin. The RK3506 *matrix IO* can carry UART1 on almost any pad, so
[`uart1-mux.py`](uart1-mux.py) pokes two register groups over `/dev/mem` to put UART1‑TX on
@@ -127,15 +160,22 @@ Everything below was the hard part; the scripts encode the answers so you don't
| 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 **`_usb`** firmware (not `_ble`); the BLE build ignores Serial1. Confirm the XIAO's USB serial is *silent* to companion frames. |
| Still silent, wiring confirmed | Confirm the firmware has the **I²C‑remap** patch (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`). |
| 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. To track upstream, run it with
`MESHCORE_COMMIT=main`; if the patch no longer applies cleanly, re‑apply the two edits in
[`meshcore-uart1.patch`](meshcore-uart1.patch) by hand — they're two hunks.
`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`](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.
---
+13 -7
View File
@@ -6,11 +6,16 @@
# What it does:
# 1. clones MeshCore (pinned to a tested commit) if you don't already have it
# 2. applies meshcore-uart1.patch -- the two changes that make this work:
# * bind the companion protocol to hardware UART1 (Serial1 = D6/D7) instead of USB
# * move the I2C bus OFF D6/D7 (to internal pins 16/17) so it can't seize the UART pins
# 3. builds the companion_radio_usb environment
# * teach the existing SERIAL_RX companion interface to build on nRF52 (upstream
# declares an ESP32-only HardwareSerial; nRF52 wants Serial1)
# * add a Xiao_nrf52_companion_radio_serial env: companion on D6/D7, and I2C moved
# off those pads (to internal pins 16/17) so it can't seize them
# 3. builds that environment
# 4. converts the .hex to .uf2
#
# The patch is upstream-shaped and has been submitted to MeshCore. Once it lands, delete
# the `git apply` step below -- the env ships with the firmware and nothing needs patching.
#
# Prerequisites: git, and PlatformIO CLI on PATH (`pio`). Install pio with:
# pip install platformio
#
@@ -23,10 +28,11 @@ set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PATCH="$HERE/meshcore-uart1.patch"
MC="${MESHCORE_DIR:-$HERE/_meshcore-build}"
ENV=Xiao_nrf52_companion_radio_usb
# Commit this patch was cut against. Newer MeshCore may need the two edits re-applied by hand
# (see README "Updating"). Override with MESHCORE_COMMIT= to track upstream.
PIN="${MESHCORE_COMMIT:-03b6ef4}"
ENV=Xiao_nrf52_companion_radio_serial
# Commit this patch was cut against (a `dev` commit -- that is the branch MeshCore takes work
# on). Newer MeshCore may need the edits re-applied by hand (see README "Updating").
# Override with MESHCORE_COMMIT= to track upstream.
PIN="${MESHCORE_COMMIT:-e9edfc8e}"
command -v git >/dev/null 2>&1 || { echo "ERROR: git not found"; exit 1; }
command -v pio >/dev/null 2>&1 || command -v platformio >/dev/null 2>&1 || {
+81 -4
View File
@@ -41,7 +41,13 @@ def find_uf2_drive():
def touch_1200():
"""Best-effort: pulse any Seeed XIAO app serial port at 1200 baud to reset to bootloader."""
"""Best-effort: pulse any Seeed XIAO app serial port at 1200 baud to reset to bootloader.
Matched on Seeed's USB vendor id (2886) *only*. Deliberately not widened to Adafruit's
239A even though nRF52840 boards commonly use it: other nRF52840 gear on the same bench
(a LilyGo T-Echo, say) answers to 239A, and knocking somebody else's radio into its
bootloader is a rude way to find out you grabbed the wrong board.
"""
try:
import serial
import serial.tools.list_ports as lp
@@ -57,13 +63,70 @@ def touch_1200():
pass
def bootloader_port():
"""Return the COM/tty device of a XIAO sitting in its bootloader, or None."""
try:
import serial.tools.list_ports as lp
except Exception:
return None
for p in lp.comports():
hwid = (p.hwid or "").upper()
if "2886" in hwid and "0045" in hwid: # Seeed VID + bootloader PID
return p.device
return None
def read_uf2_info(drive):
"""Parse INFO_UF2.TXT on ``drive`` into a dict of its ``Key: value`` lines."""
info = {}
try:
with open(os.path.join(drive, "INFO_UF2.TXT")) as fh:
for line in fh:
if ":" in line:
k, _, v = line.partition(":")
info[k.strip()] = v.strip()
except OSError:
pass
return info
def check_drive_is_ours(drive, force=False):
"""Refuse to write to a bootloader that isn't the board this firmware was built for.
Two different mistakes are cheap to make and expensive to debug. A drive letter is not an
identity -- when one board leaves the bus another can inherit its letter, so the volume you
found may not be the one you just touched. And this firmware links for SoftDevice S140 v7
(app at 0x27000); a board carrying S140 6.1.1 wants it at 0x26000 and would simply not boot.
"""
info = read_uf2_info(drive)
model = info.get("Model", "?")
softdev = info.get("SoftDevice", "?")
print(" %s reports: %s / %s" % (drive, model, softdev))
problems = []
if "xiao" not in model.lower():
problems.append("this is a %r bootloader, not a XIAO" % model)
if "6." in softdev:
problems.append("%s expects the app at 0x26000; this build is linked for S140 v7" % softdev)
if problems and not force:
sys.exit(
"ERROR: refusing to flash %s --\n - %s\n"
"Unplug the other board, or re-run with --force if you are sure."
% (drive, "\n - ".join(problems))
)
return True
def main():
uf2 = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "meshcore-xiao-radio.uf2")
args = [a for a in sys.argv[1:] if a != "--force"]
force = "--force" in sys.argv[1:]
uf2 = args[0] if args else os.path.join(HERE, "meshcore-xiao-radio.uf2")
if not os.path.exists(uf2):
sys.exit("ERROR: firmware not found: %s\n(run build-firmware.sh first)" % uf2)
print(">> looking for XIAO bootloader drive ...")
drive = find_uf2_drive()
before = drive
if not drive:
touch_1200()
for _ in range(30):
@@ -71,12 +134,26 @@ def main():
if drive:
break
time.sleep(1)
if drive and drive == before:
# The letter was already there before we touched anything, so it may belong to some
# other board entirely -- check what it says rather than trusting the letter.
print(" note: %s was already mounted before the touch" % drive)
if not drive:
port = bootloader_port()
if port:
sys.exit(
"ERROR: the XIAO is in its bootloader on %s but exposes no UF2 drive.\n"
"This bootloader presents a serial port only, so copy-to-drive can't work.\n"
"Flash it over serial DFU instead, from your MeshCore checkout:\n"
" pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port %s"
% (port, port)
)
sys.exit(
"ERROR: no UF2 bootloader drive found.\n"
"Double-tap the XIAO's reset button (it mounts as XIAO-SENSE), then re-run."
"ERROR: no UF2 bootloader drive and no XIAO bootloader serial port found.\n"
"Double-tap the XIAO's reset button, then re-run."
)
check_drive_is_ours(drive, force=force)
print(">> flashing %s -> %s" % (os.path.basename(uf2), drive))
with open(uf2, "rb") as src:
data = src.read()
+43 -20
View File
@@ -1,29 +1,52 @@
diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp
index ef9b6bf..020c746 100644
index 89f0e6cb..cf30f9ad 100644
--- a/examples/companion_radio/main.cpp
+++ b/examples/companion_radio/main.cpp
@@ -159,7 +159,8 @@ void setup() {
#ifdef BLE_PIN_CODE
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
#else
- serial_interface.begin(Serial);
+ Serial1.begin(115200); // companion protocol over hardware UART1 (D6=TX, D7=RX)
+ serial_interface.begin(Serial1);
@@ -61,7 +61,13 @@ MultiSerialInterface interface_manager;
#if defined(SERIAL_RX)
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface hardware_serial_interface;
- HardwareSerial companion_serial(1);
+ #if defined(NRF52_PLATFORM)
+ // nRF52 has no numbered-constructor HardwareSerial; Serial1 is the concrete UARTE0
+ // instance the core always defines. setPins() below moves it to the requested pads.
+ Uart& companion_serial = Serial1;
+ #else
+ HardwareSerial companion_serial(1);
+ #endif
#endif
the_mesh.startInterface(serial_interface);
#elif defined(RP2040_PLATFORM)
// platform file system
diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini
index a085433..065b617 100644
index 1076bd8d..1eb216d1 100644
--- a/variants/xiao_nrf52/platformio.ini
+++ b/variants/xiao_nrf52/platformio.ini
@@ -26,8 +26,8 @@ build_flags = ${nrf52_base.build_flags}
-D SX126X_DIO3_TCXO_VOLTAGE=1.8
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
- -D PIN_WIRE_SCL=D6
- -D PIN_WIRE_SDA=D7
@@ -119,3 +119,28 @@ build_src_filter = ${Xiao_nrf52.build_src_filter}
+<../examples/kiss_modem/*.cpp>
lib_deps =
${Xiao_nrf52.lib_deps}
+
+[env:Xiao_nrf52_companion_radio_serial]
+extends = Xiao_nrf52
+board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
+board_upload.maximum_size = 708608
+build_flags =
+ ${Xiao_nrf52.build_flags}
+ -I examples/companion_radio/ui-orig
+ -D MAX_CONTACTS=350
+ -D MAX_GROUP_CHANNELS=40
+ -D OFFLINE_QUEUE_SIZE=256
+ -D QSPIFLASH=1
+ -D SERIAL_TX=D6
+ -D SERIAL_RX=D7
+ -D PIN_WIRE_SCL=16
+ -D PIN_WIRE_SDA=17
-D PIN_USER_BTN=PIN_BUTTON1
-D DISPLAY_CLASS=NullDisplayDriver
build_src_filter = ${nrf52_base.build_src_filter}
+build_unflags =
+ -D PIN_WIRE_SCL=D6
+ -D PIN_WIRE_SDA=D7
+build_src_filter = ${Xiao_nrf52.build_src_filter}
+ +<../examples/companion_radio/*.cpp>
+ +<../examples/companion_radio/ui-orig/*.cpp>
+lib_deps =
+ ${Xiao_nrf52.lib_deps}
+ densaugeo/base64 @ ~1.4.0