diff --git a/.gitignore b/.gitignore index 4f9ea0b..7a8533f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/scripts/xiao-radio/README.md b/scripts/xiao-radio/README.md index 1776fa2..5e2cc10 100644 --- a/scripts/xiao-radio/README.md +++ b/scripts/xiao-radio/README.md @@ -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 +``` + +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. --- diff --git a/scripts/xiao-radio/build-firmware.sh b/scripts/xiao-radio/build-firmware.sh index 3b64cda..0ec8437 100644 --- a/scripts/xiao-radio/build-firmware.sh +++ b/scripts/xiao-radio/build-firmware.sh @@ -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 || { diff --git a/scripts/xiao-radio/flash.py b/scripts/xiao-radio/flash.py index d7c921b..75f2ea5 100644 --- a/scripts/xiao-radio/flash.py +++ b/scripts/xiao-radio/flash.py @@ -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() diff --git a/scripts/xiao-radio/meshcore-uart1.patch b/scripts/xiao-radio/meshcore-uart1.patch index b5ca61a..7504f99 100644 --- a/scripts/xiao-radio/meshcore-uart1.patch +++ b/scripts/xiao-radio/meshcore-uart1.patch @@ -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 + 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