diff --git a/platformio.ini b/platformio.ini index 5dd3970..567b99f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,6 +25,9 @@ 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 flags ─────────────────────────────────────────────────────────────── build_flags = -DARDUINO_Seeed_XIAO_nRF52840_Sense diff --git a/scripts/git_hash.py b/scripts/git_hash.py new file mode 100644 index 0000000..7a0df77 --- /dev/null +++ b/scripts/git_hash.py @@ -0,0 +1,40 @@ +""" +PlatformIO pre-build script: injects the short git commit hash into firmware +as GIT_HASH (a C string literal), and writes web/version.js so the web UI +can compare against it. + +Usage: referenced from platformio.ini as: + extra_scripts = pre:scripts/git_hash.py +""" +import subprocess, os, re + +Import("env") # noqa: F821 — PlatformIO injects this + +def get_git_hash(): + try: + h = subprocess.check_output( + ["git", "rev-parse", "--short=7", "HEAD"], + cwd=env.subst("$PROJECT_DIR"), # noqa: F821 + stderr=subprocess.DEVNULL, + ).decode().strip() + # Verify it looks like a hex hash (safety check) + if re.fullmatch(r"[0-9a-f]{4,12}", h): + return h + except Exception: + pass + return "unknown" + +git_hash = get_git_hash() +print(f"[git_hash] short hash = {git_hash}") + +# ── Inject into firmware build ──────────────────────────────────────────────── +env.Append(CPPDEFINES=[("GIT_HASH", f'\\"{git_hash}\\"')]) # noqa: F821 + +# ── Write web/version.js ────────────────────────────────────────────────────── +web_dir = os.path.join(env.subst("$PROJECT_DIR"), "web") # noqa: F821 +ver_file = os.path.join(web_dir, "version.js") +os.makedirs(web_dir, exist_ok=True) +with open(ver_file, "w") as f: + f.write(f"// Auto-generated by scripts/git_hash.py — do not edit\n") + f.write(f"const FIRMWARE_BUILD_HASH = '{git_hash}';\n") +print(f"[git_hash] wrote {ver_file}") diff --git a/source/ble_config.cpp b/source/ble_config.cpp index b0c9dee..d0da28e 100644 --- a/source/ble_config.cpp +++ b/source/ble_config.cpp @@ -7,10 +7,15 @@ using namespace Adafruit_LittleFS_Namespace; extern File cfgFile; // ─── BLE Config Service objects ─────────────────────────────────────────────── +#ifndef GIT_HASH + #define GIT_HASH "unknown" +#endif + #ifdef FEATURE_CONFIG_SERVICE BLEService cfgService (0x1234); BLECharacteristic cfgBlob (0x1235); // ConfigBlob R/W 20 bytes BLECharacteristic cfgCommand (0x1236); // Command W 1 byte +BLECharacteristic cfgGitHash (0x1239); // GitHash R 8 bytes (7-char hash + NUL) #ifdef FEATURE_TELEMETRY BLECharacteristic cfgTelemetry(0x1237); // Telemetry R/N 24 bytes #endif @@ -148,6 +153,14 @@ void setupConfigService() { cfgCommand.setWriteCallback(onCommandWrite); cfgCommand.begin(); + // Git hash — 8-byte fixed field (7 hex chars + NUL), read-only + cfgGitHash.setProperties(CHR_PROPS_READ); + cfgGitHash.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); + cfgGitHash.setFixedLen(8); + cfgGitHash.begin(); + { uint8_t buf[8] = {}; strncpy((char*)buf, GIT_HASH, 7); cfgGitHash.write(buf, 8); } + Serial.print("[BUILD] git="); Serial.println(GIT_HASH); + #ifdef FEATURE_TELEMETRY cfgTelemetry.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY); cfgTelemetry.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); diff --git a/source/ble_config.h b/source/ble_config.h index 98cedf7..2740da2 100644 --- a/source/ble_config.h +++ b/source/ble_config.h @@ -7,6 +7,7 @@ extern BLEService cfgService; extern BLECharacteristic cfgBlob; extern BLECharacteristic cfgCommand; +extern BLECharacteristic cfgGitHash; #ifdef FEATURE_TELEMETRY extern BLECharacteristic cfgTelemetry; #endif diff --git a/source/config.h b/source/config.h index 9dfcc48..dca06ba 100644 --- a/source/config.h +++ b/source/config.h @@ -17,7 +17,7 @@ // ─── ATT table size ─────────────────────────────────────────────────────────── #define _ATT_BASE 900 #ifdef FEATURE_CONFIG_SERVICE - #define _ATT_CFG 80 + #define _ATT_CFG 100 // +20 for cfgGitHash characteristic #else #define _ATT_CFG 0 #endif diff --git a/web/app.js b/web/app.js index f97d7a4..6bb95c3 100644 --- a/web/app.js +++ b/web/app.js @@ -2,10 +2,11 @@ // v3.3: 4 characteristics instead of 10 const SVC_UUID = '00001234-0000-1000-8000-00805f9b34fb'; const CHR = { - configBlob: '00001235-0000-1000-8000-00805f9b34fb', // ConfigBlob R/W 16 bytes + configBlob: '00001235-0000-1000-8000-00805f9b34fb', // ConfigBlob R/W 20 bytes command: '00001236-0000-1000-8000-00805f9b34fb', // Command W 1 byte telemetry: '00001237-0000-1000-8000-00805f9b34fb', // Telemetry R/N 24 bytes imuStream: '00001238-0000-1000-8000-00805f9b34fb', // ImuStream N 14 bytes + gitHash: '00001239-0000-1000-8000-00805f9b34fb', // GitHash R 8 bytes }; // Local shadow of the current config (kept in sync with device) @@ -113,10 +114,14 @@ async function discoverServices() { chars.command = await svc.getCharacteristic(CHR.command); chars.telemetry = await svc.getCharacteristic(CHR.telemetry); chars.imuStream = await svc.getCharacteristic(CHR.imuStream); + chars.gitHash = await svc.getCharacteristic(CHR.gitHash); // Read config blob and populate UI await readConfigBlob(); + // Read firmware git hash and check against web build hash + await checkHashMatch(); + // Telemetry notify (1 Hz) — also carries chargeStatus chars.telemetry.addEventListener('characteristicvaluechanged', e => parseTelemetry(e.target.value)); await chars.telemetry.startNotifications(); @@ -149,6 +154,46 @@ async function discoverServices() { } catch(e) { log('Battery service unavailable','warn'); } } +// ── Firmware / web hash mismatch banner ────────────────────────────────────── +async function checkHashMatch() { + const banner = document.getElementById('hashMismatchBanner'); + if (!chars.gitHash) return; + let fwHash = 'unknown'; + try { + const dv = await chars.gitHash.readValue(); + const bytes = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength); + // Find NUL terminator or use full length + let end = bytes.indexOf(0); + if (end === -1) end = bytes.length; + fwHash = new TextDecoder().decode(bytes.subarray(0, end)); + } catch(e) { log(`Hash read failed: ${e.message}`, 'warn'); } + + // FIRMWARE_BUILD_HASH comes from web/version.js (written by scripts/git_hash.py at build time) + const webHash = (typeof FIRMWARE_BUILD_HASH !== 'undefined') ? FIRMWARE_BUILD_HASH : 'unknown'; + + log(`Firmware hash: ${fwHash} · Web hash: ${webHash}`, fwHash === webHash ? 'ok' : 'warn'); + + if (fwHash === 'unknown' || webHash === 'unknown' || fwHash === webHash) { + banner.style.display = 'none'; + return; + } + + banner.style.cssText = [ + 'display:flex', 'align-items:center', 'justify-content:center', 'gap:12px', + 'background:#7a2020', 'color:#ffd0d0', 'font-family:var(--mono)', + 'font-size:11px', 'padding:6px 16px', 'border-bottom:1px solid #c04040', + 'position:sticky', 'top:0', 'z-index:100', + ].join(';'); + banner.innerHTML = + `⚠` + + `FIRMWARE / WEB MISMATCH — ` + + `firmware ${fwHash} · web ${webHash} — ` + + `flash firmware or reload the page after a pio run` + + ``; +} + // ── ConfigBlob read / write ────────────────────────────────────────────────── // ConfigBlob layout (20 bytes LE): // float sensitivity [0], float deadZone [4], float accelStrength [8] @@ -419,6 +464,7 @@ function onDisconnected() { document.getElementById('badgeCharging').classList.remove('show'); document.getElementById('badgeFull').classList.remove('show'); imuSubscribed = false; vizPaused = true; vizUpdateIndicator(); streamDiagReset(); + document.getElementById('hashMismatchBanner').style.display = 'none'; clearTelemetry(); if (!userDisconnected && document.getElementById('autoReconnect').checked && savedDevice) { log('Auto-reconnecting…','info'); diff --git a/web/index.html b/web/index.html index f21ffff..b3945e0 100644 --- a/web/index.html +++ b/web/index.html @@ -7,9 +7,12 @@ + + +
diff --git a/web/version.js b/web/version.js new file mode 100644 index 0000000..62ff7ae --- /dev/null +++ b/web/version.js @@ -0,0 +1,2 @@ +// Auto-generated by scripts/git_hash.py — do not edit +const FIRMWARE_BUILD_HASH = 'a3b5425';