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 @@
+