diff --git a/source/ble_config.cpp b/source/ble_config.cpp index 9835a6a..50121f4 100644 --- a/source/ble_config.cpp +++ b/source/ble_config.cpp @@ -75,6 +75,7 @@ void pushConfigBlob() { b.tapKey = cfg.tapKey; b.tapMod = cfg.tapMod; b._pad = 0; + b.jerkThreshold = cfg.jerkThreshold; cfgBlob.write((uint8_t*)&b, sizeof(b)); } #endif @@ -117,6 +118,7 @@ void onConfigBlobWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) cfg.tapKey = b->tapKey; cfg.tapMod = b->tapMod; #endif + if (b->jerkThreshold >= 100.0f && b->jerkThreshold <= 50000.0f) cfg.jerkThreshold = b->jerkThreshold; saveConfig(); Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0); Serial.print(" dz="); Serial.print(cfg.deadZone,3); diff --git a/source/config.h b/source/config.h index c81f20d..b9e1690 100644 --- a/source/config.h +++ b/source/config.h @@ -53,7 +53,7 @@ // ─── Persistence ────────────────────────────────────────────────────────────── #define CONFIG_FILENAME "/imu_mouse_cfg.bin" -#define CONFIG_MAGIC 0xDEAD1239UL +#define CONFIG_MAGIC 0xDEAD123AUL // ─── Enums ──────────────────────────────────────────────────────────────────── enum CurveType : uint8_t { CURVE_LINEAR=0, CURVE_SQUARE=1, CURVE_SQRT=2 }; @@ -83,6 +83,7 @@ struct Config { TapAction tapAction; // what a double-tap does uint8_t tapKey; // HID keycode (used when tapAction == TAP_ACTION_KEY) uint8_t tapMod; // HID modifier byte (used when tapAction == TAP_ACTION_KEY) + float jerkThreshold; // jerk² threshold for tap-freeze detection }; extern Config cfg; extern const Config CFG_DEFAULTS; @@ -100,8 +101,9 @@ struct __attribute__((packed)) ConfigBlob { uint8_t tapKey; // [17] HID keycode uint8_t tapMod; // [18] HID modifier uint8_t _pad; // [19] + float jerkThreshold; // [20] jerk² tap-freeze threshold }; -static_assert(sizeof(ConfigBlob) == 20, "ConfigBlob must be 20 bytes"); +static_assert(sizeof(ConfigBlob) == 24, "ConfigBlob must be 24 bytes"); // ─── TelemetryPacket (24 bytes) ─────────────────────────────────────────────── #ifdef FEATURE_TELEMETRY diff --git a/source/main.cpp b/source/main.cpp index 7f85929..06345c5 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -59,7 +59,8 @@ File cfgFile(InternalFS); Config cfg; const Config CFG_DEFAULTS = { CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW, - /*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0 + /*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0, + /*jerkThreshold=*/2000.0f }; // ─── Telemetry definition ───────────────────────────────────────────────────── @@ -128,6 +129,14 @@ uint32_t loopStalls = 0; // loop iterations where dt > 20ms (behind sch bool pendingCal = false; bool pendingReset = false; +// ── Jerk-based shock detection — freeze cursor during tap impacts ──────────── +// Jerk = da/dt (rate of change of acceleration). Normal mouse rotation produces +// smooth accel changes (low jerk); a tap is a sharp impulse (very high jerk). +// This cleanly separates taps from any intentional motion regardless of speed. +unsigned long shockFreezeUntil = 0; +float prevAx = 0, prevAy = 0, prevAz = 0; // previous frame's accel for Δa +const unsigned long SHOCK_FREEZE_MS = 80; // hold freeze after last spike + ChargeStatus lastChargeStatus = CHGSTAT_DISCHARGING; int idleFrames = 0; @@ -223,6 +232,8 @@ void setup() { #endif calibrateGyroBias(); + // Seed previous-accel for jerk detection so first frame doesn't spike + prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); bledis.setManufacturer("Seeed Studio"); bledis.setModel("XIAO nRF52840 Sense"); @@ -294,7 +305,7 @@ void loop() { if (cmd == 'r') { Serial.println("[SERIAL] Reset"); pendingReset = true; } } - if (pendingCal) { pendingCal = false; calibrateGyroBias(); } + if (pendingCal) { pendingCal = false; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); } if (pendingReset) { pendingReset = false; factoryReset(); } // Heartbeat LED @@ -345,19 +356,37 @@ void loop() { float ay = imu.readFloatAccelY(); float az = imu.readFloatAccelZ(); + // ── Jerk-based shock detection — freeze cursor during tap impacts ──────── + // Jerk = da/dt. Normal rotation = smooth accel changes (low jerk); + // a tap is a sharp impulse (very high jerk). + float jx = (ax - prevAx) / dt, jy = (ay - prevAy) / dt, jz = (az - prevAz) / dt; + float jerkSq = jx*jx + jy*jy + jz*jz; + prevAx = ax; prevAy = ay; prevAz = az; + bool shocked = (jerkSq > cfg.jerkThreshold) || (now < shockFreezeUntil); + if (jerkSq > cfg.jerkThreshold) shockFreezeUntil = now + SHOCK_FREEZE_MS; + // 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 + gz*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az)); + // During shock: gyro-only integration to avoid accel spike corrupting angles + if (shocked) { + angleX += gx * dt; + angleY += gz * dt; + } else { + angleX = ALPHA*(angleX + gx*dt) + (1.0f - ALPHA)*atan2f(ax, sqrtf(ay*ay + az*az)); + angleY = ALPHA*(angleY + gz*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az)); + } // ── Gravity-based axis decomposition ────────────────────────────────────── // Low-pass filter accel to get a stable gravity estimate in device frame. // This lets us project angular velocity onto world-aligned axes regardless // of how the device is rolled. Device forward (pointing) axis = X. // Confirmed by diagnostics: GX=roll, GY=nod, GZ=pan in user's hold. + // Skip update during shock to protect the gravity estimate from tap spikes. const float GRAV_LP = 0.05f; - gravX += GRAV_LP * (ax - gravX); - gravY += GRAV_LP * (ay - gravY); - gravZ += GRAV_LP * (az - gravZ); + if (!shocked) { + gravX += GRAV_LP * (ax - gravX); + gravY += GRAV_LP * (ay - gravY); + gravZ += GRAV_LP * (az - gravZ); + } float gN = sqrtf(gravX*gravX + gravY*gravY + gravZ*gravZ); if (gN < 0.3f) gN = 1.0f; @@ -400,14 +429,18 @@ void loop() { #ifdef FEATURE_AUTO_RECAL if (idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) { Serial.println("[AUTO-CAL] Long idle — recalibrating..."); - idleStartMs = 0; calibrateGyroBias(); return; + idleStartMs = 0; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); return; } #endif int8_t moveX = 0, moveY = 0; uint8_t flags = 0; - if (idle) { + if (shocked) { + // Shock freeze — discard accumulated sub-pixel motion and suppress output + accumX = accumY = 0.0f; + flags |= 0x08; // bit3 = shock freeze active + } else if (idle) { accumX = accumY = 0.0f; flags |= 0x01; } else { diff --git a/web/app.js b/web/app.js index 187d699..035afad 100644 --- a/web/app.js +++ b/web/app.js @@ -11,7 +11,7 @@ const CHR = { // Local shadow of the current config (kept in sync with device) const config = { sensitivity:600, deadZone:0.06, accelStrength:0.08, curve:0, axisFlip:0, chargeMode:1, - tapThreshold:12, tapAction:0, tapKey:0, tapMod:0 }; + tapThreshold:12, tapAction:0, tapKey:0, tapMod:0, jerkThreshold:2000 }; let device=null, server=null, chars={}, userDisconnected=false; let currentChargeStatus=0, currentBattPct=null, currentBattVoltage=null; @@ -222,6 +222,9 @@ async function readConfigBlob() { config.tapKey = view.getUint8(17); config.tapMod = view.getUint8(18); } + if (view.byteLength >= 24) { + config.jerkThreshold = view.getFloat32(20, true); + } applyConfigToUI(); log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} tapThr=${config.tapThreshold}`,'ok'); } catch(e) { log(`Config read error: ${e.message}`,'err'); } @@ -238,6 +241,8 @@ function applyConfigToUI() { document.getElementById('flipX').checked = !!(config.axisFlip & 1); document.getElementById('flipY').checked = !!(config.axisFlip & 2); setChargeModeUI(config.chargeMode); + document.getElementById('slJerkThreshold').value = config.jerkThreshold; + updateDisplay('jerkThreshold', config.jerkThreshold); document.getElementById('slTapThreshold').value = config.tapThreshold; updateDisplay('tapThreshold', config.tapThreshold); setTapActionUI(config.tapAction); @@ -267,9 +272,10 @@ async function _doWriteConfigBlob() { | (document.getElementById('tapModShift').checked ? 0x02 : 0) | (document.getElementById('tapModAlt').checked ? 0x04 : 0) | (document.getElementById('tapModGui').checked ? 0x08 : 0); + config.jerkThreshold = +document.getElementById('slJerkThreshold').value; // config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly - const buf = new ArrayBuffer(20); + const buf = new ArrayBuffer(24); const view = new DataView(buf); view.setFloat32(0, config.sensitivity, true); view.setFloat32(4, config.deadZone, true); @@ -282,6 +288,7 @@ async function _doWriteConfigBlob() { view.setUint8(17, config.tapKey); view.setUint8(18, config.tapMod); view.setUint8(19, 0); + view.setFloat32(20, config.jerkThreshold, true); try { await gattWrite(chars.configBlob, buf); @@ -440,16 +447,111 @@ function toggleAdvanced(on) { advancedMode = on; localStorage.setItem('advanced', on); document.getElementById('ciVoltItem').style.display = on ? '' : 'none'; + document.getElementById('debugBtn').style.display = on ? '' : 'none'; // Switch charge-info grid between 3 and 4 columns document.getElementById('chargeInfo').style.gridTemplateColumns = on ? '1fr 1fr 1fr 1fr' : '1fr 1fr 1fr'; } +// ── IMU Debug Recorder ──────────────────────────────────────────────────────── +let debugModalOpen = false; +let debugRecording = false; +let debugBuffer = []; +const DEBUG_LIVE_ROWS = 40; +let debugLiveRing = []; +let debugT0 = 0; + +function openDebugModal() { + debugModalOpen = true; + debugT0 = Date.now(); + debugLiveRing = []; + document.getElementById('debugOverlay').classList.add('show'); + // Auto-start IMU stream if not already running + if (!imuSubscribed && chars.imuStream) vizSetPaused(false); +} +function closeDebugModal() { + debugModalOpen = false; + document.getElementById('debugOverlay').classList.remove('show'); +} + +function feedDebugRow(gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags) { + if (!debugModalOpen) return; + const ms = Date.now() - debugT0; + const row = { ms, gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags }; + + // Live ring buffer + debugLiveRing.push(row); + if (debugLiveRing.length > DEBUG_LIVE_ROWS) debugLiveRing.shift(); + + // Recording buffer + if (debugRecording) { + debugBuffer.push(row); + document.getElementById('debugRecCount').textContent = debugBuffer.length + ' samples'; + } + + // Shock indicator + const shocked = !!(flags & 0x08); + const badge = document.getElementById('debugShockBadge'); + badge.classList.toggle('active', shocked); + + // Render live table + const tbody = document.getElementById('debugRows'); + tbody.innerHTML = ''; + for (const r of debugLiveRing) { + const f = []; + if (r.flags & 0x01) f.push('idle'); + if (r.flags & 0x02) f.push('tap1'); + if (r.flags & 0x04) f.push('tap2'); + if (r.flags & 0x08) f.push('shock'); + const tr = document.createElement('tr'); + if (r.flags & 0x08) tr.className = 'shock-row'; + tr.innerHTML = + `