Implement BLE OTA
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,4 +5,5 @@
|
||||
*.claude
|
||||
*.vscode
|
||||
web/version.js
|
||||
samples/
|
||||
samples/
|
||||
firmware_dfu.zip
|
||||
|
||||
@@ -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 =
|
||||
|
||||
53
scripts/generate_dfu.py
Normal file
53
scripts/generate_dfu.py
Normal file
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
web/app.js
32
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,29 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label" style="margin-top:8px">Firmware Update (OTA)</div>
|
||||
<div class="card ota-card" id="otaCard">
|
||||
<div class="ota-notice">
|
||||
<div class="ota-notice-icon">⚠</div>
|
||||
<div>
|
||||
<div class="ota-notice-title">Browser OTA not available</div>
|
||||
<div class="ota-notice-body">Chrome blocks the Nordic Legacy DFU service UUID used by this bootloader. Use <strong>nRF Connect</strong> (mobile or desktop) to upload firmware instead.</div>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="ota-steps">
|
||||
<li>Build firmware: <code>pio run</code> → produces <code>firmware_dfu.zip</code></li>
|
||||
<li>Click <strong>Enter DFU Mode</strong> below — device reboots as <em>XIAO_DFU</em></li>
|
||||
<li>Open nRF Connect → connect to <em>XIAO_DFU</em> → DFU → select <code>firmware_dfu.zip</code></li>
|
||||
</ol>
|
||||
<div class="ota-btn-row" style="grid-template-columns:1fr">
|
||||
<button class="cmd-btn ota-trigger" id="btnOTA" onclick="sendOTATrigger()" disabled>
|
||||
<span class="cmd-icon">⟳</span><span>Enter DFU Mode</span>
|
||||
<span class="cmd-desc">Reboots device into XIAO_DFU so nRF Connect can upload firmware.</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ota-status-row"><div class="ota-status" id="otaStatus"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="section-label" style="margin-top:8px">Event Log</div>
|
||||
<div class="console" id="console"></div>
|
||||
|
||||
@@ -249,6 +272,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<div class="overlay" id="overlay">
|
||||
<div class="modal">
|
||||
<h3>⚠ Factory Reset</h3>
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
.no-ble p { font-size:13px; color:var(--label); line-height:1.8; }
|
||||
|
||||
body.disconnected .card { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
|
||||
body.disconnected .card.ota-card { opacity:1; pointer-events:auto; } /* OTA works when disconnected too */
|
||||
body.disconnected .cmd-grid { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
|
||||
|
||||
.tap-key-row { display:flex; align-items:center; gap:10px; padding-top:12px; flex-wrap:wrap; }
|
||||
@@ -325,3 +326,21 @@
|
||||
.tap-flash.right { background:radial-gradient(circle at center, var(--tap-right) 0%, transparent 70%); }
|
||||
.tap-flash.show { opacity:1; }
|
||||
.viz-wrap { position:relative; }
|
||||
|
||||
/* ── OTA Firmware Update ── */
|
||||
.ota-card { display:flex; flex-direction:column; gap:14px; }
|
||||
.ota-notice { display:flex; gap:12px; align-items:flex-start; padding:10px 12px; background:color-mix(in srgb, var(--warn) 8%, var(--bg)); border-left:3px solid var(--warn); }
|
||||
.ota-notice-icon { font-size:16px; color:var(--warn); flex-shrink:0; line-height:1.4; }
|
||||
.ota-notice-title { font-family:var(--sans); font-size:11px; font-weight:700; color:var(--warn); letter-spacing:0.08em; text-transform:uppercase; margin-bottom:4px; }
|
||||
.ota-notice-body { font-family:var(--mono); font-size:10px; color:var(--label); line-height:1.6; }
|
||||
.ota-steps { font-family:var(--mono); font-size:10px; color:var(--label); line-height:1.9; margin:0; padding-left:18px; }
|
||||
.ota-steps code { color:var(--text); }
|
||||
.ota-steps strong { color:var(--text); }
|
||||
.ota-steps em { color:var(--accent); font-style:normal; }
|
||||
.ota-btn-row { display:grid; gap:8px; }
|
||||
.ota-status-row { min-height:14px; }
|
||||
.ota-status { font-family:var(--mono); font-size:10px; color:var(--label); }
|
||||
.ota-status.ota-ok { color:var(--ok); }
|
||||
.ota-status.ota-err { color:var(--accent2); }
|
||||
.cmd-btn.ota-trigger::before { background:var(--accent); }
|
||||
.cmd-btn.ota-trigger:hover { border-color:var(--accent); }
|
||||
|
||||
Reference in New Issue
Block a user