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 = + `${r.ms}${r.gyroX}${r.gyroZ}` + + `${r.accelX}${r.accelY}${r.accelZ}` + + `${r.moveX}${r.moveY}${f.join(' ')}`; + tbody.appendChild(tr); + } + tbody.parentElement.parentElement.scrollTop = tbody.parentElement.parentElement.scrollHeight; +} + +function toggleDebugRec() { + debugRecording = !debugRecording; + const btn = document.getElementById('debugRecBtn'); + btn.classList.toggle('recording', debugRecording); + btn.textContent = debugRecording ? '■ STOP' : '● REC'; + if (debugRecording) { debugBuffer = []; debugT0 = Date.now(); } + document.getElementById('debugRecCount').textContent = debugBuffer.length + ' samples'; +} + +function saveDebugCSV() { + if (!debugBuffer.length) { log('No recorded data to save','warn'); return; } + const header = 'ms,gyroX_mDPS,gyroZ_mDPS,accelX_mg,accelY_mg,accelZ_mg,moveX,moveY,flags\n'; + const rows = debugBuffer.map(r => + `${r.ms},${r.gyroX},${r.gyroZ},${r.accelX},${r.accelY},${r.accelZ},${r.moveX},${r.moveY},${r.flags}` + ).join('\n'); + const blob = new Blob([header + rows], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `imu_debug_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`; + a.click(); URL.revokeObjectURL(url); + log(`Saved ${debugBuffer.length} samples as CSV`,'ok'); +} + +function clearDebugRec() { + debugBuffer = []; + debugRecording = false; + const btn = document.getElementById('debugRecBtn'); + btn.classList.remove('recording'); + btn.textContent = '● REC'; + document.getElementById('debugRecCount').textContent = '0 samples'; +} + // ── Param display ───────────────────────────────────────────────────────────── function updateDisplay(key, val) { const map = { sensitivity: ['valSensitivity', v=>parseFloat(v).toFixed(0)], deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)], accel: ['valAccel', v=>parseFloat(v).toFixed(2)], + jerkThreshold:['valJerkThreshold',v=>parseFloat(v).toFixed(0)], tapThreshold: ['valTapThreshold', v=>(parseFloat(v)*62.5).toFixed(0)+' mg'], }; const [id,fmt] = map[key]; @@ -601,7 +703,6 @@ async function vizSetPaused(paused) { } function parseImuStream(dv) { - if (vizPaused) return; let view; try { view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv); @@ -625,6 +726,11 @@ function parseImuStream(dv) { moveY = view.getInt8(11); flags = view.getUint8(12); } catch(e) { log(`parseImuStream: parse error — ${e.message}`,'err'); return; } + + // Feed debug recorder (even when viz is paused) + feedDebugRow(gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags); + + if (vizPaused) return; const idle = !!(flags & 0x01); const single = !!(flags & 0x02); const dbl = !!(flags & 0x04); diff --git a/web/index.html b/web/index.html index 67f466b..2875a58 100644 --- a/web/index.html +++ b/web/index.html @@ -25,6 +25,7 @@
--% +
DISCONNECTED
@@ -100,6 +101,12 @@
Tap Configuration
+
+
Tap Freeze Sensitivity
Jerk² threshold — lower = more aggressive cursor freeze during taps
+ +
2000
+
Tap Threshold
Impact force needed · 1 LSB ≈ 62.5 mg at ±2g
+
+ +
+ diff --git a/web/style.css b/web/style.css index 8127f93..919e812 100644 --- a/web/style.css +++ b/web/style.css @@ -131,6 +131,8 @@ .btn:disabled { border-color:var(--dim); color:var(--dim); cursor:not-allowed; } .btn:disabled::before { display:none; } .btn:disabled:hover { color:var(--dim); } + .btn-debug { border:1px solid var(--dim); color:var(--label); min-width:52px; text-align:center; font-size:10px; padding:6px 10px; } + .btn-debug::before { background:var(--accent); } .btn-theme { border:1px solid var(--dim); color:var(--label); min-width:72px; text-align:center; } .btn-theme::before { background:var(--text); } @@ -267,6 +269,29 @@ .btn-confirm { border-color:var(--accent2); color:var(--accent2); } .btn-confirm:hover { background:var(--accent2); color:var(--bg); } + /* ── Debug modal ────────────────────────────────────────────────────────── */ + .debug-modal { max-width:720px; padding:20px; border-color:var(--accent); } + .debug-modal h3 { color:var(--accent); margin-bottom:0; } + .debug-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; } + .debug-close { background:none; border:1px solid var(--border); color:var(--label); font-size:14px; cursor:pointer; padding:2px 8px; font-family:var(--mono); } + .debug-close:hover { border-color:var(--text); color:var(--text); } + .debug-live { max-height:340px; overflow-y:auto; border:1px solid var(--border); margin-bottom:12px; } + .debug-table { width:100%; border-collapse:collapse; font-family:var(--mono); font-size:10px; } + .debug-table thead { position:sticky; top:0; background:var(--panel); z-index:1; } + .debug-table th { padding:4px 6px; text-align:right; color:var(--label); font-weight:400; border-bottom:1px solid var(--border); letter-spacing:0.1em; text-transform:uppercase; } + .debug-table td { padding:2px 6px; text-align:right; color:var(--text); border-bottom:1px solid color-mix(in srgb, var(--border) 40%, transparent); white-space:nowrap; } + .debug-table td:last-child { text-align:left; color:var(--label); } + .debug-table .shock-row td { color:var(--accent2); } + .debug-controls { display:flex; align-items:center; gap:10px; } + .debug-rec-btn { font-family:var(--mono); font-size:12px; font-weight:700; padding:6px 14px; cursor:pointer; border:1px solid var(--accent2); color:var(--accent2); background:transparent; letter-spacing:0.1em; } + .debug-rec-btn.recording { background:var(--accent2); color:var(--bg); animation:rec-pulse 1s infinite; } + @keyframes rec-pulse { 0%,100%{opacity:1} 50%{opacity:0.6} } + .debug-rec-count { font-family:var(--mono); font-size:10px; color:var(--label); } + .debug-ctrl-btn { font-family:var(--mono); font-size:10px; padding:5px 10px; cursor:pointer; border:1px solid var(--border); color:var(--label); background:transparent; } + .debug-ctrl-btn:hover { border-color:var(--text); color:var(--text); } + .debug-shock-badge { font-family:var(--mono); font-size:9px; letter-spacing:0.15em; padding:2px 8px; border:1px solid var(--border); color:var(--border); opacity:0.3; transition:all 0.15s; } + .debug-shock-badge.active { border-color:var(--accent2); color:var(--accent2); opacity:1; } + .no-ble { grid-column:1/-1; text-align:center; padding:80px 24px; } .no-ble h2 { font-family:var(--sans); font-size:28px; font-weight:700; color:var(--accent2); margin-bottom:12px; } .no-ble p { font-size:13px; color:var(--label); line-height:1.8; }