From 395fd9b839d7342fa42ef5d770d1df4a505f65cb Mon Sep 17 00:00:00 2001 From: Nik Rozman Date: Thu, 19 Mar 2026 22:11:45 +0100 Subject: [PATCH] Implement BLE OTA --- .gitignore | 3 ++- platformio.ini | 6 +++-- scripts/generate_dfu.py | 53 +++++++++++++++++++++++++++++++++++++++++ source/ble_config.cpp | 5 +++- source/config.h | 4 ++++ source/main.cpp | 21 ++++++++++++++++ web/app.js | 32 +++++++++++++++++++++++++ web/index.html | 24 +++++++++++++++++++ web/style.css | 19 +++++++++++++++ 9 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 scripts/generate_dfu.py diff --git a/.gitignore b/.gitignore index c55b8b0..5bde698 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ *.claude *.vscode web/version.js -samples/ \ No newline at end of file +samples/ +firmware_dfu.zip diff --git a/platformio.ini b/platformio.ini index cc8410d..5d46f56 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,8 +25,10 @@ upload_speed = 115200 ; Uncomment and set the correct port if auto-detect fails: ; upload_port = COM3 -; Build scripts -extra_scripts = pre:scripts/git_hash.py +; Build scripts +; git_hash.py - injects short git hash into firmware + web/version.js +; generate_dfu.py - generates firmware_dfu.zip for OTA upload (requires adafruit-nrfutil) +extra_scripts = pre:scripts/git_hash.py, post:scripts/generate_dfu.py ; Build flags build_flags = diff --git a/scripts/generate_dfu.py b/scripts/generate_dfu.py new file mode 100644 index 0000000..90d15df --- /dev/null +++ b/scripts/generate_dfu.py @@ -0,0 +1,53 @@ +""" +PlatformIO post-build script: generates a Nordic DFU package (.zip) from the +built firmware .hex using adafruit-nrfutil. + +The resulting firmware_dfu.zip can be uploaded to the device via: + - nRF Connect mobile app (iOS / Android) after triggering OTA mode + - nRF Connect for Desktop + - adafruit-nrfutil over BLE (advanced) + +Trigger OTA mode on the device: + - Send BLE command 0x02 to cfgCommand (0x1236), OR + - Type 'o' in the serial monitor + +Usage: referenced from platformio.ini as: + extra_scripts = pre:scripts/git_hash.py, post:scripts/generate_dfu.py +""" +import subprocess, os + +Import("env") # noqa: F821 - PlatformIO injects this + +def generate_dfu_package(source, target, env): + build_dir = env.subst("$BUILD_DIR") + project_dir = env.subst("$PROJECT_DIR") + hex_path = os.path.join(build_dir, "firmware.hex") + + if not os.path.exists(hex_path): + print(f"[generate_dfu] firmware.hex not found at {hex_path}, skipping") + return + + out_path = os.path.join(project_dir, "firmware_dfu.zip") + + try: + result = subprocess.run( + [ + "adafruit-nrfutil", "dfu", "genpkg", + "--dev-type", "0x0052", # nRF52840 + "--application", hex_path, + out_path, + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: + size_kb = os.path.getsize(out_path) / 1024 + print(f"[generate_dfu] DFU package ready: firmware_dfu.zip ({size_kb:.1f} KB)") + print(f"[generate_dfu] Upload with nRF Connect after sending OTA command (0x02) via BLE") + else: + print(f"[generate_dfu] adafruit-nrfutil error: {result.stderr.strip()}") + except FileNotFoundError: + print("[generate_dfu] adafruit-nrfutil not found - skipping DFU package generation") + print("[generate_dfu] Install with: pip install adafruit-nrfutil") + +env.AddPostAction("$BUILD_DIR/firmware.hex", generate_dfu_package) # noqa: F821 diff --git a/source/ble_config.cpp b/source/ble_config.cpp index 690f968..706fee0 100644 --- a/source/ble_config.cpp +++ b/source/ble_config.cpp @@ -133,6 +133,9 @@ void onCommandWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) { if (l < 1) return; if (d[0] == 0x01) pendingCal = true; if (d[0] == 0xFF) pendingReset = true; + #ifdef FEATURE_OTA + if (d[0] == 0x02) pendingOTA = true; + #endif } #ifdef FEATURE_IMU_STREAM @@ -153,7 +156,7 @@ void setupConfigService() { cfgBlob.begin(); pushConfigBlob(); - cfgCommand.setProperties(CHR_PROPS_WRITE); + cfgCommand.setProperties(CHR_PROPS_WRITE | CHR_PROPS_WRITE_WO_RESP); cfgCommand.setPermission(SECMODE_OPEN, SECMODE_OPEN); cfgCommand.setFixedLen(1); cfgCommand.setWriteCallback(onCommandWrite); diff --git a/source/config.h b/source/config.h index 8ee2bb7..7058c34 100644 --- a/source/config.h +++ b/source/config.h @@ -11,6 +11,7 @@ #define FEATURE_BATTERY_MONITOR #define FEATURE_BOOT_LOOP_DETECT #define FEATURE_PHYSICAL_BUTTONS +#define FEATURE_OTA // Debug // #define DEBUG @@ -205,6 +206,9 @@ extern float cachedTempC; extern bool pendingCal; extern bool pendingReset; +#ifdef FEATURE_OTA + extern bool pendingOTA; +#endif extern ChargeStatus lastChargeStatus; extern int idleFrames; extern unsigned long idleStartMs; diff --git a/source/main.cpp b/source/main.cpp index 98fc830..722bd37 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -117,6 +117,9 @@ float cachedTempC = 25.0f; uint32_t loopStalls = 0; // loop iterations where dt > 20ms (behind schedule) bool pendingCal = false; bool pendingReset = false; +#ifdef FEATURE_OTA + bool pendingOTA = false; +#endif // Jerk-based shock detection - freeze cursor during tap impacts, doesn't work well yet! unsigned long shockFreezeUntil = 0; @@ -299,10 +302,28 @@ void loop() { char cmd = Serial.read(); if (cmd == 'c') { Serial.println("[SERIAL] Calibrate"); pendingCal = true; } if (cmd == 'r') { Serial.println("[SERIAL] Reset"); pendingReset = true; } + #ifdef FEATURE_OTA + if (cmd == 'o') { Serial.println("[SERIAL] OTA DFU"); pendingOTA = true; } + #endif } if (pendingCal) { pendingCal = false; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); } if (pendingReset) { pendingReset = false; factoryReset(); } + #ifdef FEATURE_OTA + if (pendingOTA) { + pendingOTA = false; + Serial.println("[OTA] Disconnecting BLE and entering bootloader DFU mode..."); + Serial.flush(); + // Gracefully close the BLE connection first so the host can detect the + // disconnect and be ready to see DfuTarg advertise after the reboot. + if (Bluefruit.connected()) { + Bluefruit.disconnect(0); + delay(300); + } + delay(200); + enterOTADfu(); // Adafruit nRF52 core: sets GPREGRET correctly and resets into bootloader OTA mode + } + #endif // Heartbeat LED if (now - lastHeartbeat >= HEARTBEAT_MS) { diff --git a/web/app.js b/web/app.js index 3f369ab..0f46706 100644 --- a/web/app.js +++ b/web/app.js @@ -1015,3 +1015,35 @@ if (!navigator.bluetooth) { } else { log('Web Bluetooth ready. Click CONNECT to pair your IMU Mouse.','info'); } + +// ───────────────────────────────────────────────────────────────────────────── +// OTA firmware update +// +// The OTAFIX bootloader uses Nordic Legacy DFU (service 00001530-...) which is +// blocklisted in Chrome's Web Bluetooth implementation. Browser-side upload is +// therefore not possible without special flags or a native app wrapper. +// +// What the UI does instead: +// • "Enter DFU Mode" sends command 0x02 via BLE → device reboots as XIAO_DFU +// • User then uploads firmware_dfu.zip via nRF Connect (mobile or desktop) +// ───────────────────────────────────────────────────────────────────────────── + +function otaLog(msg, type = 'info') { + log('[OTA] ' + msg, type); + const el = document.getElementById('otaStatus'); + if (el) { el.textContent = msg; el.className = 'ota-status' + (type !== 'info' ? ' ota-' + type : ''); } +} + +// Send command 0x02 → firmware reboots into XIAO_DFU bootloader mode. +// User then uploads firmware_dfu.zip via nRF Connect. +async function sendOTATrigger() { + if (!chars.command) { otaLog('Not connected', 'err'); return; } + document.getElementById('btnOTA').disabled = true; + try { + await chars.command.writeValueWithResponse(new Uint8Array([0x02])); + otaLog('Device rebooting into DFU mode — connect to XIAO_DFU in nRF Connect', 'ok'); + } catch (e) { + otaLog('Failed: ' + e.message, 'err'); + document.getElementById('btnOTA').disabled = false; + } +} diff --git a/web/index.html b/web/index.html index 2e00201..ce448a9 100644 --- a/web/index.html +++ b/web/index.html @@ -192,6 +192,29 @@ +
Firmware Update (OTA)
+
+
+
+
+
Browser OTA not available
+
Chrome blocks the Nordic Legacy DFU service UUID used by this bootloader. Use nRF Connect (mobile or desktop) to upload firmware instead.
+
+
+
    +
  1. Build firmware: pio run → produces firmware_dfu.zip
  2. +
  3. Click Enter DFU Mode below — device reboots as XIAO_DFU
  4. +
  5. Open nRF Connect → connect to XIAO_DFU → DFU → select firmware_dfu.zip
  6. +
+
+ +
+
+
+
Event Log
@@ -249,6 +272,7 @@ +