In late March we added a Zephyr build of our collar firmware for the Seeed XIAO nRF52840 Sense. On this board's bootloader it crashed on boot, and even after fixes its BLE crashed. By 8 April we had moved the collar to an Arduino NimBLE firmware instead.
What we tried
The Zephyr firmware, Sense-OS, was built with the nRF Connect SDK v3.2.3 for the standard board target.
west build -b xiao_ble/nrf52840/sense
The architecture was a state machine moving from idle to a 2-second sampling window, with hybrid RAM ring buffers and LittleFS on the 2 MB QSPI flash. We flashed the Zephyr build through the board's Adafruit UF2 bootloader. We also tried copying UF2 files to the mounted drive with the macOS command-line tools dd and cp.
What actually happened
The bootloader accepted the UF2 file, but the Zephyr firmware crashed on boot. The heap size was 0, the stack was too small, the FPU was disabled, and there was a USB CDC conflict. Even with those fixed, Zephyr's BLE crashed.
The flashing process was its own trap. dd and cp on macOS write to the operating system's cache, not to the USB device, so the bootloader never sees the data. Finder drag-and-drop and Arduino IDE Upload were the reliable options. Finder may report error -36; that means the board rebooted mid-transfer, which is success.
The fix
We moved to the Arduino n-able core with NimBLE 2.5.0 and the Seeed Arduino LSM6DS3 library. That firmware, device/nimble-fast-prototype, is the one running on the collar today. It builds with:
cd device/nimble-fast-prototype && arduino-cli compile --fqbn n-able-Arduino:arm-ble:seeed52840sense --output-dir build .
The bugs NimBLE did not fix
NimBLE had a crash of its own. We ran the sync inside a BLE callback, and NimBLE callbacks have a limited stack, so heavy I/O crashes the BLE stack. Now the callback only sets syncRequested = true, and the sync runs in the main loop().
The packet buffer pktBuf[244] was too small for a 322-byte window, so ringReadNext returned 0. It is now pktBuf[WINDOW_COMPRESSED_SIZE].
On the phone, the Swift parser expects exactly 322 bytes per window, so 9-byte sleep/wake markers in the data stream shifted the byte offsets and broke parsing. Markers no longer go into the ring buffer; sleep is derived from gaps between windows. The Swift manager also reads the window count from a cached DeviceInfo, so if the firmware updated DeviceInfo after draining the ring, Swift parsed 0 windows.
The Swift manager relies on Beta Metrics notifications every 5 seconds to retrigger a sync. Without that characteristic, sync fired once on connect, hit the cooldown and never retried, so we added it to the NimBLE firmware.
Finally, the IMU maths. readRawAccelX() returns a raw int16, and our isqrt32 integer maths had rounding errors: a stationary device showed a mean deviation of 751 mg instead of about 0. The fix was floats with 4.0f / 32768.0f scaling, as our earlier sense-os-v2 firmware already did.
What's still open
The Swift BLE connector is 870 lines of state machine designed for the older 3-tier protocol. It needs a clean rewrite: connect, accumulate 322-byte windows and batch upload them to the backend.
Collar configuration keeps disappearing. PUT /collar/config returns 400 because of a wrong request body, and the Swift native module does not call it at all; only the JS sync manager does. Tapping "Forget Device" deletes the config. The Swift module should call PUT /collar/config after each successful sync. Proper float-based gravity subtraction for motion detection is also still on the list.
If you're building something similar
- Do not use
ddorcpto flash UF2 files on macOS. Use Finder drag-and-drop (error -36 is fine) or the Arduino IDE. - Never perform heavy I/O inside BLE callbacks. Set a flag and handle the work in your main loop.
- Check your integer maths against a stationary device. If your MCU has an FPU, floats are simpler.
- If your iOS app relies on a heartbeat notification to resync, make sure every firmware build sends it.