Attempt to add jerk correction

This commit is contained in:
2026-03-02 23:53:10 +01:00
parent 4768754bef
commit a666304013
6 changed files with 215 additions and 14 deletions

View File

@@ -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);

View File

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

View File

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

View File

@@ -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 =
`<td>${r.ms}</td><td>${r.gyroX}</td><td>${r.gyroZ}</td>` +
`<td>${r.accelX}</td><td>${r.accelY}</td><td>${r.accelZ}</td>` +
`<td>${r.moveX}</td><td>${r.moveY}</td><td>${f.join(' ')}</td>`;
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);

View File

@@ -25,6 +25,7 @@
<div class="batt-cells" id="battCells"></div>
<span id="battPct">--%</span>
</div>
<button class="btn btn-debug" id="debugBtn" onclick="openDebugModal()" style="display:none" title="IMU Debug Recorder"><span>DBG</span></button>
<button class="btn btn-theme" id="themeBtn" onclick="cycleTheme()"><span>AUTO</span></button>
<div class="status-pill" id="statusPill"><div class="dot"></div><span id="statusText">DISCONNECTED</span></div>
<button class="btn btn-connect" id="connectBtn" onclick="doConnect()"><span>Connect</span></button>
@@ -100,6 +101,12 @@
<div class="section-label">Tap Configuration</div>
<div class="card">
<div class="param">
<div><div class="param-label">Tap Freeze Sensitivity</div><div class="param-desc">Jerk² threshold — lower = more aggressive cursor freeze during taps</div></div>
<input type="range" id="slJerkThreshold" min="500" max="10000" step="100" value="2000"
oninput="updateDisplay('jerkThreshold',this.value)" onchange="writeConfigBlob()">
<div class="param-value" id="valJerkThreshold">2000</div>
</div>
<div class="param">
<div><div class="param-label">Tap Threshold</div><div class="param-desc">Impact force needed · 1 LSB ≈ 62.5 mg at ±2g</div></div>
<input type="range" id="slTapThreshold" min="1" max="31" step="1" value="12"
@@ -223,6 +230,32 @@
</div>
</div>
<div class="overlay" id="debugOverlay">
<div class="modal debug-modal">
<div class="debug-header">
<h3>IMU Debug Recorder</h3>
<div style="display:flex;align-items:center;gap:8px">
<span class="debug-shock-badge" id="debugShockBadge">SHOCK</span>
<button class="debug-close" onclick="closeDebugModal()"></button>
</div>
</div>
<div class="debug-live" id="debugLive">
<table class="debug-table">
<thead><tr>
<th>ms</th><th>gX</th><th>gZ</th><th>aX</th><th>aY</th><th>aZ</th><th>mX</th><th>mY</th><th>flags</th>
</tr></thead>
<tbody id="debugRows"></tbody>
</table>
</div>
<div class="debug-controls">
<button class="debug-rec-btn" id="debugRecBtn" onclick="toggleDebugRec()">● REC</button>
<span class="debug-rec-count" id="debugRecCount">0 samples</span>
<button class="debug-ctrl-btn" onclick="saveDebugCSV()">Save CSV</button>
<button class="debug-ctrl-btn" onclick="clearDebugRec()">Clear</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -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; }