Compare commits
6 Commits
9786d83ab0
...
b3cc319d5e
| Author | SHA1 | Date | |
|---|---|---|---|
| b3cc319d5e | |||
| 9e481096be | |||
| 11baa814c9 | |||
| 1bd2ecc339 | |||
| 8cb92f9914 | |||
| a3b5425d0f |
+2
-1
@@ -3,4 +3,5 @@
|
||||
*.3mf
|
||||
*.pio
|
||||
*.claude
|
||||
*.vscode
|
||||
*.vscode
|
||||
web/version.js
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
+6
-2
@@ -31,7 +31,9 @@ void updateBattery() {
|
||||
float v = readBatteryVoltage(); int pct = batteryPercent(v);
|
||||
bool chg = (digitalRead(PIN_CHG) == LOW);
|
||||
ChargeStatus status = chg ? (pct >= 99 ? CHGSTAT_FULL : CHGSTAT_CHARGING) : CHGSTAT_DISCHARGING;
|
||||
blebas.write(pct);
|
||||
// Only write BLE Battery Service when connected — blebas.write() blocks on the
|
||||
// SoftDevice ATT layer and causes 30-40ms loop stalls when called during advertising.
|
||||
if (Bluefruit.connected()) blebas.write(pct);
|
||||
lastChargeStatus = status;
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
telem.chargeStatus = (uint8_t)status;
|
||||
@@ -39,7 +41,9 @@ void updateBattery() {
|
||||
const char* st[] = {"discharging","charging","full"};
|
||||
Serial.print("[BATT] "); Serial.print(v,2); Serial.print("V ");
|
||||
Serial.print(pct); Serial.print("% "); Serial.println(st[status]);
|
||||
if (status == CHGSTAT_DISCHARGING && v < BATT_CRITICAL)
|
||||
// Critical battery alert — only blink when not connected to avoid blocking BLE scheduler.
|
||||
// 6 × 160ms = 960ms hard block; skip during active connection.
|
||||
if (status == CHGSTAT_DISCHARGING && v < BATT_CRITICAL && !Bluefruit.connected())
|
||||
for (int i=0; i<6; i++) { digitalWrite(LED_RED,LOW); delay(80); digitalWrite(LED_RED,HIGH); delay(80); }
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -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
|
||||
@@ -45,10 +50,13 @@ void loadConfig() {
|
||||
}
|
||||
|
||||
void saveConfig() {
|
||||
unsigned long t0 = millis();
|
||||
InternalFS.remove(CONFIG_FILENAME);
|
||||
cfgFile.open(CONFIG_FILENAME, FILE_O_WRITE);
|
||||
if (cfgFile) { cfgFile.write((uint8_t*)&cfg, sizeof(cfg)); cfgFile.close(); Serial.println("[CFG] Saved"); }
|
||||
else { Serial.println("[CFG] ERROR: write failed"); }
|
||||
if (cfgFile) { cfgFile.write((uint8_t*)&cfg, sizeof(cfg)); cfgFile.close(); }
|
||||
unsigned long elapsed = millis() - t0;
|
||||
if (elapsed > 5) { Serial.print("[CFG] Saved ("); Serial.print(elapsed); Serial.println("ms — flash block)"); }
|
||||
else { Serial.println("[CFG] Saved"); }
|
||||
}
|
||||
|
||||
// ─── ConfigBlob push ─────────────────────────────────────────────────────────
|
||||
@@ -145,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);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
extern BLEService cfgService;
|
||||
extern BLECharacteristic cfgBlob;
|
||||
extern BLECharacteristic cfgCommand;
|
||||
extern BLECharacteristic cfgGitHash;
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
extern BLECharacteristic cfgTelemetry;
|
||||
#endif
|
||||
|
||||
+3
-3
@@ -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
|
||||
@@ -122,8 +122,8 @@ extern TelemetryPacket telem;
|
||||
// ─── ImuPacket (14 bytes) ─────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
struct __attribute__((packed)) ImuPacket {
|
||||
int16_t gyroY_mDPS; // [0]
|
||||
int16_t gyroZ_mDPS; // [2]
|
||||
int16_t gyroX_mDPS; // [0] pitch axis (nod up/down → cursor Y)
|
||||
int16_t gyroZ_mDPS; // [2] yaw axis (pan left/right → cursor X)
|
||||
int16_t accelX_mg; // [4]
|
||||
int16_t accelY_mg; // [6]
|
||||
int16_t accelZ_mg; // [8]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "imu.h"
|
||||
#include "Wire.h"
|
||||
#include <math.h>
|
||||
|
||||
LSM6DS3 imu(I2C_MODE, 0x6A);
|
||||
|
||||
|
||||
+64
-30
@@ -15,7 +15,7 @@
|
||||
* ── Feature flag index ───────────────────────────────────────────
|
||||
* FEATURE_CONFIG_SERVICE Custom GATT service (ConfigBlob + Command)
|
||||
* FEATURE_TELEMETRY +24-byte notify characteristic, 1 Hz
|
||||
* FEATURE_IMU_STREAM +14-byte notify characteristic, ~100 Hz
|
||||
* FEATURE_IMU_STREAM +14-byte notify characteristic, ~10 Hz
|
||||
* FEATURE_TAP_DETECTION LSM6DS3 hardware tap engine → L/R clicks
|
||||
* FEATURE_TEMP_COMPENSATION Gyro drift correction by temperature delta
|
||||
* FEATURE_AUTO_RECAL Recalibrate after AUTO_RECAL_MS idle
|
||||
@@ -78,7 +78,7 @@ const unsigned long HEARTBEAT_MS = 10000;
|
||||
const int HEARTBEAT_DUR = 30;
|
||||
const unsigned long BOOT_SAFE_MS = 5000;
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
const unsigned long IMU_STREAM_RATE_MS = 50;
|
||||
const unsigned long IMU_STREAM_RATE_MS = 100;
|
||||
#endif
|
||||
const float BATT_FULL = 4.20f;
|
||||
const float BATT_EMPTY = 3.00f;
|
||||
@@ -110,8 +110,19 @@ float cachedTempC = 25.0f;
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
bool imuStreamEnabled = false;
|
||||
uint32_t streamNotifyFails = 0;
|
||||
uint32_t streamNotifyOk = 0;
|
||||
unsigned long lastStreamDiag = 0;
|
||||
// Back-off state: after STREAM_BACKOFF_THRESH consecutive fails, skip notifies
|
||||
// for STREAM_BACKOFF_MS to let the SoftDevice HVN TX semaphore drain.
|
||||
// Without this, every notify() blocks for BLE_GENERIC_TIMEOUT (100ms).
|
||||
uint8_t streamConsecFails = 0;
|
||||
unsigned long streamBackoffUntil = 0;
|
||||
const uint8_t STREAM_BACKOFF_THRESH = 2; // fails before backing off
|
||||
const unsigned long STREAM_BACKOFF_MS = 500; // cooldown window
|
||||
#endif
|
||||
|
||||
uint32_t loopStalls = 0; // loop iterations where dt > 20ms (behind schedule)
|
||||
bool pendingCal = false;
|
||||
bool pendingReset = false;
|
||||
|
||||
@@ -189,7 +200,7 @@ void setup() {
|
||||
Bluefruit.begin(1, 0);
|
||||
Bluefruit.setTxPower(4);
|
||||
Bluefruit.setName(safeMode ? "IMU Mouse (safe)" : "IMU Mouse");
|
||||
Bluefruit.Periph.setConnInterval(12, 24); // 15-30ms — less aggressive, prevents stream disconnect
|
||||
Bluefruit.Periph.setConnInterval(16, 32); // 20-40ms — wider interval reduces SoftDevice TX stalls
|
||||
|
||||
Wire1.begin(); // LSM6DS3 is on internal I2C bus (Wire1), must init before imu.begin()
|
||||
if (imu.begin() != 0) {
|
||||
@@ -303,6 +314,9 @@ void loop() {
|
||||
float dt = (now - lastTime) / 1000.0f;
|
||||
lastTime = now;
|
||||
if (dt <= 0.0f || dt > 0.5f) return;
|
||||
// Threshold 50ms: intentional heartbeat blink (30ms) won't false-trigger;
|
||||
// real SoftDevice stalls (100ms+) and unexpected delays still get flagged.
|
||||
if (dt > 0.050f) { loopStalls++; Serial.print("[STALL] dt="); Serial.print(dt*1000.f,1); Serial.print("ms stalls="); Serial.println(loopStalls); }
|
||||
|
||||
cachedTempC = readIMUTemp();
|
||||
|
||||
@@ -313,15 +327,15 @@ void loop() {
|
||||
#endif
|
||||
|
||||
// Gyro reads with optional temperature compensation
|
||||
float gx, gy, gz;
|
||||
float gx, gz;
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
float correction = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC);
|
||||
gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f);
|
||||
gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f);
|
||||
(void)(imu.readFloatGyroY()); // GY unused — read to keep SPI/I2C sequence consistent
|
||||
gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f);
|
||||
#else
|
||||
gx = (imu.readFloatGyroX() - biasGX) * (PI/180.0f);
|
||||
gy = (imu.readFloatGyroY() - biasGY) * (PI/180.0f);
|
||||
(void)(imu.readFloatGyroY()); // GY unused — read to keep SPI/I2C sequence consistent
|
||||
gz = (imu.readFloatGyroZ() - biasGZ) * (PI/180.0f);
|
||||
#endif
|
||||
|
||||
@@ -329,14 +343,16 @@ void loop() {
|
||||
float ay = imu.readFloatAccelY();
|
||||
float az = imu.readFloatAccelZ();
|
||||
|
||||
// Complementary filter
|
||||
// Complementary filter — gx=pitch axis, gz=yaw axis on this board layout
|
||||
angleX = ALPHA*(angleX + gx*dt) + (1.0f - ALPHA)*atan2f(ax, sqrtf(ay*ay + az*az));
|
||||
angleY = ALPHA*(angleY + gy*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az));
|
||||
angleY = ALPHA*(angleY + gz*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az));
|
||||
|
||||
float fGy = (fabsf(gy) > cfg.deadZone) ? gy : 0.0f;
|
||||
// Pan (cursor X) = gyro Z (yaw). Nod (cursor Y) = gyro X (pitch).
|
||||
// Confirmed by serial diagnostics: GZ fires on left/right pan, GX fires on up/down nod.
|
||||
float fGx = (fabsf(gx) > cfg.deadZone) ? gx : 0.0f;
|
||||
float fGz = (fabsf(gz) > cfg.deadZone) ? gz : 0.0f;
|
||||
|
||||
bool moving = (fGy != 0.0f || fGz != 0.0f);
|
||||
bool moving = (fGx != 0.0f || fGz != 0.0f);
|
||||
if (moving) { idleFrames = 0; idleStartMs = 0; }
|
||||
else { idleFrames++; if (idleStartMs == 0) idleStartMs = now; }
|
||||
bool idle = (idleFrames >= IDLE_FRAMES);
|
||||
@@ -356,7 +372,7 @@ void loop() {
|
||||
flags |= 0x01;
|
||||
} else {
|
||||
float rawX = applyAcceleration(applyCurve(-fGz * cfg.sensitivity * dt));
|
||||
float rawY = applyAcceleration(applyCurve(-fGy * cfg.sensitivity * dt));
|
||||
float rawY = applyAcceleration(applyCurve(-fGx * cfg.sensitivity * dt));
|
||||
if (cfg.axisFlip & 0x01) rawX = -rawX;
|
||||
if (cfg.axisFlip & 0x02) rawY = -rawY;
|
||||
accumX += rawX; accumY += rawY;
|
||||
@@ -370,25 +386,43 @@ void loop() {
|
||||
if (!safeMode && imuStreamEnabled && Bluefruit.connected()
|
||||
&& (now - lastImuStream >= IMU_STREAM_RATE_MS)) {
|
||||
lastImuStream = now;
|
||||
ImuPacket pkt;
|
||||
pkt.gyroY_mDPS = (int16_t)constrain(gy*(180.f/PI)*1000.f, -32000, 32000);
|
||||
pkt.gyroZ_mDPS = (int16_t)constrain(gz*(180.f/PI)*1000.f, -32000, 32000);
|
||||
pkt.accelX_mg = (int16_t)constrain(ax*1000.f, -32000, 32000);
|
||||
pkt.accelY_mg = (int16_t)constrain(ay*1000.f, -32000, 32000);
|
||||
pkt.accelZ_mg = (int16_t)constrain(az*1000.f, -32000, 32000);
|
||||
pkt.moveX = moveX;
|
||||
pkt.moveY = moveY;
|
||||
pkt.flags = flags;
|
||||
pkt._pad = 0;
|
||||
cfgImuStream.notify((uint8_t*)&pkt, sizeof(pkt));
|
||||
|
||||
if (now < streamBackoffUntil) {
|
||||
// Backing off — host TX buffer congested, skip to avoid 100ms block
|
||||
} else {
|
||||
ImuPacket pkt;
|
||||
pkt.gyroX_mDPS = (int16_t)constrain(gx*(180.f/PI)*1000.f, -32000, 32000);
|
||||
pkt.gyroZ_mDPS = (int16_t)constrain(gz*(180.f/PI)*1000.f, -32000, 32000);
|
||||
pkt.accelX_mg = (int16_t)constrain(ax*1000.f, -32000, 32000);
|
||||
pkt.accelY_mg = (int16_t)constrain(ay*1000.f, -32000, 32000);
|
||||
pkt.accelZ_mg = (int16_t)constrain(az*1000.f, -32000, 32000);
|
||||
pkt.moveX = moveX;
|
||||
pkt.moveY = moveY;
|
||||
pkt.flags = flags;
|
||||
pkt._pad = 0;
|
||||
if (cfgImuStream.notify((uint8_t*)&pkt, sizeof(pkt))) {
|
||||
streamNotifyOk++;
|
||||
streamConsecFails = 0;
|
||||
} else {
|
||||
streamNotifyFails++;
|
||||
streamConsecFails++;
|
||||
if (streamConsecFails >= STREAM_BACKOFF_THRESH) {
|
||||
streamBackoffUntil = now + STREAM_BACKOFF_MS;
|
||||
streamConsecFails = 0;
|
||||
Serial.print("[STREAM] TX congested — backing off ");
|
||||
Serial.print(STREAM_BACKOFF_MS); Serial.println("ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic stream health report every 10 seconds
|
||||
if (now - lastStreamDiag >= 10000) {
|
||||
lastStreamDiag = now;
|
||||
Serial.print("[STREAM] ok="); Serial.print(streamNotifyOk);
|
||||
Serial.print(" fail="); Serial.print(streamNotifyFails);
|
||||
Serial.print(" rate="); Serial.print((streamNotifyOk * 1000UL) / 10000); Serial.println("pkt/s");
|
||||
streamNotifyOk = 0; streamNotifyFails = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef DEBUG
|
||||
Serial.print("T="); Serial.print(cachedTempC,1);
|
||||
Serial.print(" gy="); Serial.print(gy,3);
|
||||
Serial.print(" gz="); Serial.print(gz,3);
|
||||
Serial.print(" mx="); Serial.print(moveX);
|
||||
Serial.print(" my="); Serial.println(moveY);
|
||||
#endif
|
||||
}
|
||||
|
||||
+257
-18
@@ -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)
|
||||
@@ -16,16 +17,40 @@ let device=null, server=null, chars={}, userDisconnected=false;
|
||||
let currentChargeStatus=0, currentBattPct=null;
|
||||
|
||||
// ── GATT write queue (prevents "operation already in progress") ───────────────
|
||||
// Serialises all GATT writes. Features:
|
||||
// • Per-operation 3s timeout — hangs don't block the queue forever
|
||||
// • Max depth of 2 pending ops — drops excess writes when device goes silent
|
||||
// • gattQueueReset() flushes on disconnect so a reconnect starts clean
|
||||
const GATT_TIMEOUT_MS = 3000;
|
||||
const GATT_MAX_DEPTH = 2;
|
||||
let _gattQueue = Promise.resolve();
|
||||
function gattWrite(char, value) {
|
||||
const p = _gattQueue.then(() => char.writeValueWithResponse(value));
|
||||
_gattQueue = p.catch(() => {});
|
||||
let _gattDepth = 0;
|
||||
|
||||
function _withTimeout(promise, ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(`GATT timeout (${ms}ms)`)), ms);
|
||||
promise.then(v => { clearTimeout(t); resolve(v); },
|
||||
e => { clearTimeout(t); reject(e); });
|
||||
});
|
||||
}
|
||||
|
||||
function _enqueue(fn) {
|
||||
if (_gattDepth >= GATT_MAX_DEPTH) {
|
||||
return Promise.reject(new Error('GATT queue full — device unreachable?'));
|
||||
}
|
||||
_gattDepth++;
|
||||
const p = _gattQueue.then(() => _withTimeout(fn(), GATT_TIMEOUT_MS));
|
||||
_gattQueue = p.catch(() => {}).finally(() => { _gattDepth = Math.max(0, _gattDepth - 1); });
|
||||
return p;
|
||||
}
|
||||
function gattCmd(char, value) {
|
||||
const p = _gattQueue.then(() => char.writeValueWithoutResponse(value));
|
||||
_gattQueue = p.catch(() => {});
|
||||
return p;
|
||||
|
||||
function gattWrite(char, value) { return _enqueue(() => char.writeValueWithResponse(value)); }
|
||||
function gattCmd (char, value) { return _enqueue(() => char.writeValueWithoutResponse(value)); }
|
||||
|
||||
function gattQueueReset() {
|
||||
// Drain the chain so a reconnect starts with a fresh resolved promise
|
||||
_gattQueue = Promise.resolve();
|
||||
_gattDepth = 0;
|
||||
}
|
||||
|
||||
// ── Logging ──────────────────────────────────────────────────────────────────
|
||||
@@ -89,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();
|
||||
@@ -125,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 =
|
||||
`<span style="font-size:14px">⚠</span>` +
|
||||
`<span>FIRMWARE / WEB MISMATCH — ` +
|
||||
`firmware <b>${fwHash}</b> · web <b>${webHash}</b> — ` +
|
||||
`flash firmware or reload the page after a <code>pio run</code></span>` +
|
||||
`<button onclick="document.getElementById('hashMismatchBanner').style.display='none'" ` +
|
||||
`style="margin-left:8px;background:none;border:1px solid #c04040;color:#ffd0d0;` +
|
||||
`cursor:pointer;padding:2px 8px;font-family:var(--mono);font-size:10px">✕</button>`;
|
||||
}
|
||||
|
||||
// ── ConfigBlob read / write ──────────────────────────────────────────────────
|
||||
// ConfigBlob layout (20 bytes LE):
|
||||
// float sensitivity [0], float deadZone [4], float accelStrength [8]
|
||||
@@ -388,12 +457,15 @@ function setStatus(state) {
|
||||
function onDisconnected() {
|
||||
log('Device disconnected','warn');
|
||||
const savedDevice = device;
|
||||
gattQueueReset();
|
||||
chars={}; device=null; server=null;
|
||||
setStatus('disconnected');
|
||||
document.getElementById('battBar').style.display='none';
|
||||
document.getElementById('badgeCharging').classList.remove('show');
|
||||
document.getElementById('badgeFull').classList.remove('show');
|
||||
imuSubscribed = false; vizPaused = true; vizUpdateIndicator();
|
||||
imuSubscribed = false; vizPaused = true; vizUpdateIndicator(); streamDiagReset();
|
||||
document.getElementById('orientLabel').textContent = '— not streaming —';
|
||||
document.getElementById('hashMismatchBanner').style.display = 'none';
|
||||
clearTelemetry();
|
||||
if (!userDisconnected && document.getElementById('autoReconnect').checked && savedDevice) {
|
||||
log('Auto-reconnecting…','info');
|
||||
@@ -416,7 +488,7 @@ function onDisconnected() {
|
||||
|
||||
// ── IMU Stream + Visualiser ──────────────────────────────────────────────────
|
||||
// ImuPacket (14 bytes LE):
|
||||
// int16 gyroY_mDPS [0], int16 gyroZ_mDPS [2]
|
||||
// int16 gyroX_mDPS [0], int16 gyroZ_mDPS [2]
|
||||
// int16 accelX_mg [4], int16 accelY_mg [6], int16 accelZ_mg [8]
|
||||
// int8 moveX [10], int8 moveY [11], uint8 flags [12], uint8 pad [13]
|
||||
const canvas = document.getElementById('vizCanvas');
|
||||
@@ -426,6 +498,52 @@ let cursorX = canvas.width/2, cursorY = canvas.height/2, trail = [];
|
||||
let vizPaused = true;
|
||||
let imuSubscribed = false;
|
||||
|
||||
// ── Stream diagnostics ────────────────────────────────────────────────────────
|
||||
let streamPktCount = 0; // packets received this second
|
||||
let streamPktTotal = 0; // lifetime packet count
|
||||
let streamLastPktT = 0; // timestamp of last packet (for gap detection)
|
||||
let streamLastRateT = 0; // timestamp of last rate log
|
||||
let streamFreezeTimer = null; // fires if no packet for >1s while subscribed
|
||||
|
||||
function streamDiagReset() {
|
||||
streamPktCount = streamPktTotal = streamLastPktT = streamLastRateT = 0;
|
||||
if (streamFreezeTimer) { clearTimeout(streamFreezeTimer); streamFreezeTimer = null; }
|
||||
}
|
||||
|
||||
function streamDiagPkt() {
|
||||
const now = Date.now();
|
||||
|
||||
// Gap detection — warn if >300ms since last packet while streaming
|
||||
if (streamLastPktT) {
|
||||
const gap = now - streamLastPktT;
|
||||
if (gap > 300) log(`[STREAM] gap ${gap}ms (pkt #${streamPktTotal})`, 'warn');
|
||||
}
|
||||
streamLastPktT = now;
|
||||
streamPktCount++;
|
||||
streamPktTotal++;
|
||||
|
||||
// Reset freeze watchdog — 1.5s without a packet = freeze
|
||||
if (streamFreezeTimer) clearTimeout(streamFreezeTimer);
|
||||
streamFreezeTimer = setTimeout(() => {
|
||||
log(`[STREAM] FROZEN — no packet for 1.5s (total rx: ${streamPktTotal})`, 'err');
|
||||
streamFreezeTimer = null;
|
||||
}, 1500);
|
||||
|
||||
// Log rate every 5s
|
||||
if (streamLastRateT === 0) streamLastRateT = now;
|
||||
if (now - streamLastRateT >= 5000) {
|
||||
const rate = (streamPktCount / ((now - streamLastRateT) / 1000)).toFixed(1);
|
||||
log(`[STREAM] ${rate} pkt/s · total ${streamPktTotal}`, 'info');
|
||||
streamPktCount = 0;
|
||||
streamLastRateT = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Roll compensation is done entirely in firmware (calibrateGyroBias computes
|
||||
// rollSin/rollCos from boot-pose accel and applies the rotation before moveX/moveY).
|
||||
// The web visualiser just uses moveX/moveY directly — no re-rotation needed here.
|
||||
function resetOrient() {} // kept for call-site compatibility
|
||||
|
||||
function vizUpdateIndicator() {
|
||||
const el = document.getElementById('vizLive');
|
||||
if (!imuSubscribed || vizPaused) {
|
||||
@@ -447,12 +565,15 @@ async function vizSetPaused(paused) {
|
||||
try {
|
||||
await chars.imuStream.startNotifications();
|
||||
imuSubscribed = true;
|
||||
document.getElementById('orientLabel').textContent = 'roll correction active (firmware)';
|
||||
log('IMU stream subscribed','ok');
|
||||
} catch(e) { log(`IMU stream start failed: ${e.message}`,'err'); vizPaused = true; }
|
||||
} else if (paused && imuSubscribed) {
|
||||
try {
|
||||
await chars.imuStream.stopNotifications();
|
||||
imuSubscribed = false;
|
||||
streamDiagReset();
|
||||
document.getElementById('orientLabel').textContent = '— not streaming —';
|
||||
} catch(e) { log(`IMU stream stop failed: ${e.message}`,'err'); }
|
||||
}
|
||||
vizUpdateIndicator();
|
||||
@@ -472,31 +593,40 @@ function parseImuStream(dv) {
|
||||
return;
|
||||
}
|
||||
|
||||
let gyroY, gyroZ, moveX, moveY, flags;
|
||||
let gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags;
|
||||
try {
|
||||
gyroY = view.getInt16(0, true);
|
||||
gyroZ = view.getInt16(2, true);
|
||||
moveX = view.getInt8(10);
|
||||
moveY = view.getInt8(11);
|
||||
flags = view.getUint8(12);
|
||||
gyroX = view.getInt16(0, true); // GX = pitch axis (nod → cursor Y)
|
||||
gyroZ = view.getInt16(2, true); // GZ = yaw axis (pan → cursor X)
|
||||
accelX = view.getInt16(4, true);
|
||||
accelY = view.getInt16(6, true);
|
||||
accelZ = view.getInt16(8, true);
|
||||
moveX = view.getInt8(10);
|
||||
moveY = view.getInt8(11);
|
||||
flags = view.getUint8(12);
|
||||
} catch(e) { log(`parseImuStream: parse error — ${e.message}`,'err'); return; }
|
||||
const idle = !!(flags & 0x01);
|
||||
const single = !!(flags & 0x02);
|
||||
const dbl = !!(flags & 0x04);
|
||||
|
||||
updateAxisBar('gy', gyroY, 30000);
|
||||
updateAxisBar('gz', gyroZ, 30000);
|
||||
// Axis bars: show raw gyro (firmware convention: Z→screen-X, X→screen-Y)
|
||||
updateAxisBar('gy', -gyroZ, 30000);
|
||||
updateAxisBar('gz', -gyroX, 30000);
|
||||
|
||||
if (!idle) {
|
||||
// moveX/moveY are already roll-corrected by firmware — use them directly
|
||||
cursorX = Math.max(4, Math.min(canvas.width - 4, cursorX + moveX * 1.5));
|
||||
cursorY = Math.max(4, Math.min(canvas.height - 4, cursorY + moveY * 1.5));
|
||||
}
|
||||
trail.push({x:cursorX, y:cursorY, t:Date.now(), idle});
|
||||
if (trail.length > TRAIL_LEN) trail.shift();
|
||||
|
||||
streamDiagPkt();
|
||||
|
||||
if (single) flashTap('Left');
|
||||
if (dbl) flashTap('Right');
|
||||
drawViz(idle);
|
||||
|
||||
orientFeedIMU(accelX, accelY, accelZ, gyroX, gyroZ);
|
||||
}
|
||||
|
||||
function updateAxisBar(axis, val, max) {
|
||||
@@ -551,6 +681,113 @@ function drawInitState() {
|
||||
ctx.fillStyle=cssVar('--canvas-idle-text');ctx.font='10px Share Tech Mono,monospace';
|
||||
ctx.textAlign='center';ctx.fillText('connect to activate stream',W/2,H/2+4);ctx.textAlign='left';
|
||||
}
|
||||
// ── 3D Orientation Viewer ─────────────────────────────────────────────────────
|
||||
// Device box: L=115mm (X), W=36mm (Y), H=20mm (Z)
|
||||
// Complementary filter mirrors firmware: α=0.96, dt from packet rate (~50ms)
|
||||
const ORIENT_ALPHA = 0.96;
|
||||
const DEVICE_L = 1.15, DEVICE_W = 0.36, DEVICE_H = 0.20; // metres (Three.js units)
|
||||
|
||||
let orientScene, orientCamera, orientRenderer, orientMesh, orientEdges;
|
||||
let orientQ = new THREE.Quaternion(); // current estimated orientation
|
||||
let orientLastT = 0;
|
||||
|
||||
function initOrientViewer() {
|
||||
const el = document.getElementById('orientCanvas');
|
||||
const W = el.clientWidth || 340, H = 160;
|
||||
el.width = W; el.height = H;
|
||||
|
||||
orientScene = new THREE.Scene();
|
||||
orientCamera = new THREE.PerspectiveCamera(40, W / H, 0.01, 10);
|
||||
orientCamera.position.set(0.6, 0.5, 0.9);
|
||||
orientCamera.lookAt(0, 0, 0);
|
||||
|
||||
orientRenderer = new THREE.WebGLRenderer({ canvas: el, antialias: true, alpha: true });
|
||||
orientRenderer.setSize(W, H);
|
||||
orientRenderer.setClearColor(0x000000, 0);
|
||||
|
||||
// Box geometry
|
||||
const geo = new THREE.BoxGeometry(DEVICE_L, DEVICE_H, DEVICE_W);
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
color: 0x1a2230, emissive: 0x050a10, specular: 0x00e5ff,
|
||||
shininess: 60, transparent: true, opacity: 0.85,
|
||||
});
|
||||
orientMesh = new THREE.Mesh(geo, mat);
|
||||
orientScene.add(orientMesh);
|
||||
|
||||
// Wireframe edges
|
||||
const edgeMat = new THREE.LineBasicMaterial({ color: 0x00e5ff, linewidth: 1 });
|
||||
orientEdges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMat);
|
||||
orientMesh.add(orientEdges);
|
||||
|
||||
// "Front" face marker — small arrow along +X (length axis)
|
||||
const arrowGeo = new THREE.ConeGeometry(0.02, 0.07, 6);
|
||||
arrowGeo.rotateZ(-Math.PI / 2);
|
||||
arrowGeo.translate(DEVICE_L / 2 + 0.04, 0, 0);
|
||||
const arrowMesh = new THREE.Mesh(arrowGeo,
|
||||
new THREE.MeshBasicMaterial({ color: 0x00e5ff }));
|
||||
orientMesh.add(arrowMesh);
|
||||
|
||||
// Lighting
|
||||
orientScene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||
const dlight = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||
dlight.position.set(1, 2, 2);
|
||||
orientScene.add(dlight);
|
||||
|
||||
orientRenderer.render(orientScene, orientCamera);
|
||||
}
|
||||
|
||||
function orientUpdateColors() {
|
||||
// Re-read CSS variables so it adapts to theme changes
|
||||
const accent = cssVar('--accent').replace('#','');
|
||||
const c = parseInt(accent, 16);
|
||||
if (orientEdges) orientEdges.material.color.setHex(c);
|
||||
}
|
||||
|
||||
function orientFeedIMU(ax, ay, az, gyX_mDPS, gyZ_mDPS) {
|
||||
if (!orientRenderer) return;
|
||||
const now = Date.now();
|
||||
const dt = orientLastT ? Math.min((now - orientLastT) / 1000, 0.1) : 0.05;
|
||||
orientLastT = now;
|
||||
|
||||
// Accel in g (packet is in mg)
|
||||
const axG = ax / 1000, ayG = ay / 1000, azG = az / 1000;
|
||||
const aNorm = Math.sqrt(axG*axG + ayG*ayG + azG*azG);
|
||||
|
||||
// Accel-derived quaternion (gravity reference). LSM6DS3 axes:
|
||||
// device flat, face up: az ≈ +1g
|
||||
// Pitch (tilt front up): ay changes; Roll (tilt right side up): ax changes
|
||||
let qAccel = new THREE.Quaternion();
|
||||
if (aNorm > 0.5 && aNorm < 2.0) {
|
||||
// gravity unit vector in device frame
|
||||
const gx = axG / aNorm, gy = ayG / aNorm, gz = azG / aNorm;
|
||||
// Align device -Z (down face) with gravity
|
||||
const up = new THREE.Vector3(0, 1, 0); // Three.js world up
|
||||
const gVec = new THREE.Vector3(-gx, -gz, gy); // map device→Three axes
|
||||
gVec.normalize();
|
||||
qAccel.setFromUnitVectors(gVec, up);
|
||||
} else {
|
||||
qAccel.copy(orientQ);
|
||||
}
|
||||
|
||||
// Gyro integration — firmware sends gyroX (pitch) and gyroZ (yaw), mDPS
|
||||
// Map to Three.js axes: gyroZ→world Y, gyroX→world X
|
||||
const gyRad = gyX_mDPS * (Math.PI / 180) / 1000;
|
||||
const gzRad = gyZ_mDPS * (Math.PI / 180) / 1000;
|
||||
const dq = new THREE.Quaternion(
|
||||
gyRad * dt * 0.5, // x
|
||||
-gzRad * dt * 0.5, // y
|
||||
0, 1
|
||||
).normalize();
|
||||
const qGyro = orientQ.clone().multiply(dq);
|
||||
|
||||
// Complementary filter
|
||||
orientQ.copy(qGyro).slerp(qAccel, 1 - ORIENT_ALPHA);
|
||||
orientQ.normalize();
|
||||
|
||||
orientMesh.quaternion.copy(orientQ);
|
||||
orientRenderer.render(orientScene, orientCamera);
|
||||
}
|
||||
|
||||
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||||
const THEMES = ['auto','dark','light'];
|
||||
const THEME_LABELS = {auto:'AUTO',dark:'DARK',light:'LIGHT'};
|
||||
@@ -567,11 +804,13 @@ function applyTheme(t) {
|
||||
document.getElementById('themeBtn').querySelector('span').textContent = THEME_LABELS[t];
|
||||
localStorage.setItem('theme', t);
|
||||
if (!chars.imuStream) drawInitState();
|
||||
orientUpdateColors();
|
||||
}
|
||||
(function(){
|
||||
const saved = localStorage.getItem('theme') ?? 'auto';
|
||||
themeIdx = Math.max(0, THEMES.indexOf(saved));
|
||||
applyTheme(saved);
|
||||
initOrientViewer();
|
||||
})();
|
||||
|
||||
if (!navigator.bluetooth) {
|
||||
|
||||
+12
-2
@@ -6,9 +6,13 @@
|
||||
<title>IMU Mouse // Config Terminal</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
|
||||
<script src="version.js"></script>
|
||||
</head>
|
||||
<body class="disconnected">
|
||||
|
||||
<div id="hashMismatchBanner" style="display:none"></div>
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<div class="logo">IMU<span>·</span>Mouse</div>
|
||||
@@ -171,11 +175,11 @@
|
||||
</div>
|
||||
<div class="viz-axes">
|
||||
<div class="axis-bar-wrap">
|
||||
<div class="axis-bar-label"><span>GY (up/down)</span><span id="gyVal">0</span></div>
|
||||
<div class="axis-bar-label"><span>X (left/right)</span><span id="gyVal">0</span></div>
|
||||
<div class="axis-bar-track"><div class="axis-bar-fill" id="gyBar"></div><div class="axis-bar-center"></div></div>
|
||||
</div>
|
||||
<div class="axis-bar-wrap">
|
||||
<div class="axis-bar-label"><span>GZ (left/right)</span><span id="gzVal">0</span></div>
|
||||
<div class="axis-bar-label"><span>Y (up/down)</span><span id="gzVal">0</span></div>
|
||||
<div class="axis-bar-track"><div class="axis-bar-fill" id="gzBar"></div><div class="axis-bar-center"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +188,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Device Orientation</div>
|
||||
<div class="card orient-card">
|
||||
<canvas id="orientCanvas"></canvas>
|
||||
<div style="font-size:9px;color:var(--label);text-align:center;margin-top:6px" id="orientLabel">— not streaming —</div>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Live Telemetry</div>
|
||||
<div class="telem-grid">
|
||||
<div class="telem-cell"><div class="telem-val accent" id="telTemp">--</div><div class="telem-lbl">Temperature °C</div></div>
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
.viz-panel { background:var(--panel2); border:1px solid var(--border); padding:16px; }
|
||||
.viz-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
|
||||
.viz-title { font-family:var(--sans); font-size:11px; font-weight:600; letter-spacing:0.25em; text-transform:uppercase; color:var(--label); }
|
||||
.orient-card { padding:12px; display:flex; flex-direction:column; align-items:center; }
|
||||
#orientCanvas { display:block; width:100%; height:160px; }
|
||||
.viz-ctrl-btn { background:none; border:1px solid var(--border); color:var(--label); font-size:11px; line-height:1; padding:3px 8px; cursor:pointer; letter-spacing:0.05em; }
|
||||
.viz-ctrl-btn:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.viz-live { font-size:9px; letter-spacing:0.2em; display:block; }
|
||||
|
||||
Reference in New Issue
Block a user