Files
air-mouse/web/app.js
2026-03-03 08:34:43 +01:00

967 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── UUIDs ────────────────────────────────────────────────────────────────────
// 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 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)
const config = { sensitivity:600, deadZone:0.06, accelStrength:0.08, curve:0, axisFlip:0, chargeMode:1,
tapThreshold:12, tapAction:0, tapKey:0, tapMod:0, tapFreezeEnabled:1, jerkThreshold:2000 };
let device=null, server=null, chars={}, userDisconnected=false;
let currentChargeStatus=0, currentBattPct=null, currentBattVoltage=null;
let advancedMode = localStorage.getItem('advanced') === 'true';
// ── 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();
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 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 ──────────────────────────────────────────────────────────────────
(function() {
const _methods = { log: '', warn: 'warn', error: 'err' };
for (const [method, type] of Object.entries(_methods)) {
const orig = console[method].bind(console);
console[method] = (...args) => {
orig(...args);
log(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '), type);
};
}
})();
function log(msg, type='') {
const el=document.getElementById('console');
const now=new Date();
const ts=`${p2(now.getHours())}:${p2(now.getMinutes())}:${p2(now.getSeconds())}.${p3(now.getMilliseconds())}`;
const d=document.createElement('div'); d.className='log-line';
d.innerHTML=`<span class="log-time">${ts}</span><span class="log-msg ${type}">${msg}</span>`;
el.appendChild(d); el.scrollTop=el.scrollHeight;
}
const p2=n=>String(n).padStart(2,'0'), p3=n=>String(n).padStart(3,'0');
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
// ── Connection ───────────────────────────────────────────────────────────────
async function doConnect() {
if (!navigator.bluetooth) { log('Web Bluetooth not supported.','err'); return; }
userDisconnected = false;
setStatus('connecting');
log('Scanning for IMU Mouse…','info');
try {
device = await navigator.bluetooth.requestDevice({
filters:[{name:'IMU Mouse'},{name:'IMU Mouse (safe)'}],
optionalServices:[SVC_UUID,'battery_service']
});
device.addEventListener('gattserverdisconnected', onDisconnected);
log(`Found: ${device.name}`,'ok');
server = await device.gatt.connect();
log('GATT connected','ok');
await discoverServices();
setStatus('connected');
log('Ready','ok');
} catch(e) { log(`Connection failed: ${e.message}`,'err'); setStatus('disconnected'); }
}
function doDisconnect() {
if (device && device.gatt.connected) {
userDisconnected = true;
log('Disconnecting…','warn');
device.gatt.disconnect();
}
}
async function discoverServices() {
log('Discovering services…','info');
try {
const svc = await server.getPrimaryService(SVC_UUID);
chars.configBlob = await svc.getCharacteristic(CHR.configBlob);
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();
// Initial read so values show immediately. Also force updateChargeUI() here
// because parseTelemetry() only calls it on a *change*, and currentChargeStatus
// starts at 0 (discharging) — so a discharging device would never trigger the
// update and ciStatus would stay at '--'.
parseTelemetry(await chars.telemetry.readValue());
updateChargeUI();
// IMU stream — subscribed on demand via play button
chars.imuStream.addEventListener('characteristicvaluechanged', e => parseImuStream(e.target.value));
log('Config service ready (4 chars)','ok');
} catch(e) {
log(`Service discovery failed: ${e.message}`,'err');
// Safe mode device might not have config service
if (e.message.includes('not found')) log('Device may be in safe mode — basic mouse only','warn');
}
// Battery service (standard — always present)
try {
const bsvc = await server.getPrimaryService('battery_service');
const bch = await bsvc.getCharacteristic('battery_level');
bch.addEventListener('characteristicvaluechanged', e => {
currentBattPct = e.target.value.getUint8(0);
updateBatteryBar(currentBattPct, currentChargeStatus);
});
await bch.startNotifications();
const v = await bch.readValue();
currentBattPct = v.getUint8(0);
updateBatteryBar(currentBattPct, currentChargeStatus);
log(`Battery: ${currentBattPct}%`,'ok');
} 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 (24 bytes LE):
// float sensitivity [0], float deadZone [4], float accelStrength [8]
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14]
// uint8 tapThreshold [15], uint8 tapAction [16], uint8 tapKey [17], uint8 tapMod [18], uint8 tapFreezeEnabled [19]
// float jerkThreshold [20]
async function readConfigBlob() {
if (!chars.configBlob) return;
try {
const dv = await chars.configBlob.readValue();
const view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
config.sensitivity = view.getFloat32(0, true);
config.deadZone = view.getFloat32(4, true);
config.accelStrength = view.getFloat32(8, true);
config.curve = view.getUint8(12);
config.axisFlip = view.getUint8(13);
config.chargeMode = view.getUint8(14);
if (view.byteLength >= 20) {
config.tapThreshold = view.getUint8(15);
config.tapAction = view.getUint8(16);
config.tapKey = view.getUint8(17);
config.tapMod = view.getUint8(18);
config.tapFreezeEnabled = view.getUint8(19);
}
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'); }
}
function applyConfigToUI() {
document.getElementById('slSensitivity').value = config.sensitivity;
updateDisplay('sensitivity', config.sensitivity);
document.getElementById('slDeadZone').value = config.deadZone;
updateDisplay('deadZone', config.deadZone);
document.getElementById('slAccel').value = config.accelStrength;
updateDisplay('accel', config.accelStrength);
setCurveUI(config.curve);
document.getElementById('flipX').checked = !!(config.axisFlip & 1);
document.getElementById('flipY').checked = !!(config.axisFlip & 2);
setChargeModeUI(config.chargeMode);
document.getElementById('tapFreezeEnabled').checked = !!config.tapFreezeEnabled;
document.getElementById('slJerkThreshold').value = config.jerkThreshold;
updateDisplay('jerkThreshold', config.jerkThreshold);
updateTapFreezeUI(!!config.tapFreezeEnabled);
document.getElementById('slTapThreshold').value = config.tapThreshold;
updateDisplay('tapThreshold', config.tapThreshold);
setTapActionUI(config.tapAction);
document.getElementById('tapKeyHex').value = config.tapKey ? config.tapKey.toString(16).padStart(2,'0') : '';
document.getElementById('tapModCtrl').checked = !!(config.tapMod & 0x01);
document.getElementById('tapModShift').checked = !!(config.tapMod & 0x02);
document.getElementById('tapModAlt').checked = !!(config.tapMod & 0x04);
document.getElementById('tapModGui').checked = !!(config.tapMod & 0x08);
}
let _writeConfigTimer = null;
function writeConfigBlob() {
clearTimeout(_writeConfigTimer);
_writeConfigTimer = setTimeout(_doWriteConfigBlob, 150);
}
async function _doWriteConfigBlob() {
if (!chars.configBlob) return;
// Gather current UI values into the config shadow
config.sensitivity = +document.getElementById('slSensitivity').value;
config.deadZone = +document.getElementById('slDeadZone').value;
config.accelStrength = +document.getElementById('slAccel').value;
config.axisFlip = (document.getElementById('flipX').checked ? 1 : 0)
| (document.getElementById('flipY').checked ? 2 : 0);
config.tapThreshold = +document.getElementById('slTapThreshold').value;
config.tapMod = (document.getElementById('tapModCtrl').checked ? 0x01 : 0)
| (document.getElementById('tapModShift').checked ? 0x02 : 0)
| (document.getElementById('tapModAlt').checked ? 0x04 : 0)
| (document.getElementById('tapModGui').checked ? 0x08 : 0);
config.tapFreezeEnabled = document.getElementById('tapFreezeEnabled').checked ? 1 : 0;
config.jerkThreshold = +document.getElementById('slJerkThreshold').value;
// config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly
const buf = new ArrayBuffer(24);
const view = new DataView(buf);
view.setFloat32(0, config.sensitivity, true);
view.setFloat32(4, config.deadZone, true);
view.setFloat32(8, config.accelStrength, true);
view.setUint8(12, config.curve);
view.setUint8(13, config.axisFlip);
view.setUint8(14, config.chargeMode);
view.setUint8(15, config.tapThreshold);
view.setUint8(16, config.tapAction);
view.setUint8(17, config.tapKey);
view.setUint8(18, config.tapMod);
view.setUint8(19, config.tapFreezeEnabled);
view.setFloat32(20, config.jerkThreshold, true);
try {
await gattWrite(chars.configBlob, buf);
log(`Config written — sens=${config.sensitivity.toFixed(0)} tapThr=${config.tapThreshold} tapAction=${config.tapAction}`,'ok');
} catch(e) { log(`Config write failed: ${e.message}`,'err'); }
}
// ── Individual control handlers ───────────────────────────────────────────────
// These update the local config shadow then write the full blob
function setCurve(val) {
config.curve = val;
setCurveUI(val);
writeConfigBlob();
log(`Curve → ${['LINEAR','SQUARE','SQRT'][val]}`,'ok');
}
function setCurveUI(val) {
['curveLinear','curveSquare','curveSqrt'].forEach((id,i)=>
document.getElementById(id).classList.toggle('active', i===val));
}
function setChargeMode(val) {
config.chargeMode = val;
setChargeModeUI(val);
writeConfigBlob();
log(`Charge → ${['OFF','SLOW 50mA','FAST 100mA'][val]}`,'warn');
}
function setChargeModeUI(val) {
[['chgOff','off'],['chgSlow','slow'],['chgFast','fast']].forEach(([id,cls],i) => {
const b = document.getElementById(id);
b.classList.remove('active','off','slow','fast');
if (i===val) b.classList.add('active', cls);
});
document.getElementById('ciMode').textContent = ['Off (0mA)','50 mA','100 mA'][val] ?? '--';
}
function onTapFreezeChange(enabled) {
config.tapFreezeEnabled = enabled ? 1 : 0;
updateTapFreezeUI(enabled);
writeConfigBlob();
}
function updateTapFreezeUI(enabled) {
const slider = document.getElementById('slJerkThreshold');
// Only grey out when connected (on disconnect, setStatus handles all inputs)
if (device) slider.disabled = !enabled;
}
function setTapAction(val) {
config.tapAction = val;
setTapActionUI(val);
writeConfigBlob();
}
function setTapActionUI(val) {
['tapActLeft','tapActRight','tapActMiddle','tapActKey'].forEach((id,i) =>
document.getElementById(id).classList.toggle('active', i===val));
document.getElementById('tapKeyRow').style.display = val===3 ? 'flex' : 'none';
}
function onTapKeyInput() {
const hex = document.getElementById('tapKeyHex').value.trim();
const code = parseInt(hex, 16);
config.tapKey = (isNaN(code) || code < 0 || code > 255) ? 0 : code;
writeConfigBlob();
}
async function sendCalibrate() {
if (!chars.command) return;
try { await gattCmd(chars.command, new Uint8Array([0x01])); log('Calibration sent — hold still!','warn'); }
catch(e) { log(`Calibrate failed: ${e.message}`,'err'); }
}
function confirmReset() { document.getElementById('overlay').classList.add('show'); }
function closeModal() { document.getElementById('overlay').classList.remove('show'); }
async function doReset() {
closeModal(); if (!chars.command) return;
try {
await gattCmd(chars.command, new Uint8Array([0xFF]));
log('Factory reset sent…','warn');
setTimeout(async () => { await readConfigBlob(); log('Config reloaded','ok'); }, 1500);
} catch(e) { log(`Reset failed: ${e.message}`,'err'); }
}
// ── Telemetry ────────────────────────────────────────────────────────────────
// TelemetryPacket (28 bytes LE — backwards compatible with 24-byte v3.3):
// uint32 uptime [0], uint32 leftClicks [4], uint32 rightClicks [8]
// float temp [12], float biasRms [16]
// uint16 recalCount [20], uint8 chargeStatus [22], uint8 pad [23]
// float battVoltage [24] (new in v3.4, absent on older firmware)
function parseTelemetry(dv) {
let view;
try {
view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
} catch(e) { log(`parseTelemetry: DataView wrap failed — ${e.message}`,'err'); return; }
if (view.byteLength < 24) {
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const hex = Array.from(bytes).map(b=>b.toString(16).padStart(2,'0')).join(' ');
log(`TELEM: expected 24-28B, got ${view.byteLength}B — MTU too small? raw: ${hex}`,'err');
return;
}
let uptime, leftClicks, rightClicks, temp, biasRms, recalCount, chargeStatus, battVoltage=null;
try {
uptime = view.getUint32(0, true);
leftClicks = view.getUint32(4, true);
rightClicks = view.getUint32(8, true);
temp = view.getFloat32(12,true);
biasRms = view.getFloat32(16,true);
recalCount = view.getUint16(20, true);
chargeStatus= view.getUint8(22);
if (view.byteLength >= 28) battVoltage = view.getFloat32(24, true);
} catch(e) { log(`parseTelemetry: parse error at offset — ${e.message}`,'err'); return; }
document.getElementById('telTemp').textContent = temp.toFixed(1)+'°';
document.getElementById('telUptime').textContent = formatUptime(uptime);
document.getElementById('telLeft').textContent = leftClicks.toLocaleString();
document.getElementById('telRight').textContent = rightClicks.toLocaleString();
document.getElementById('telBias').textContent = biasRms.toFixed(4);
document.getElementById('telRecal').textContent = recalCount;
const tEl = document.getElementById('telTemp');
tEl.className = 'telem-val '+(temp>40?'warn':'accent');
// chargeStatus is now delivered via telemetry (no separate characteristic)
if (chargeStatus !== currentChargeStatus) {
currentChargeStatus = chargeStatus;
updateChargeUI();
}
if (battVoltage !== null) {
currentBattVoltage = battVoltage;
document.getElementById('ciVolt').textContent = battVoltage.toFixed(2) + 'V';
}
}
function formatUptime(s) {
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), ss=s%60;
return h>0 ? `${h}h ${p2(m)}m` : `${m}m ${p2(ss)}s`;
}
function clearTelemetry() {
['telTemp','telUptime','telLeft','telRight','telBias','telRecal'].forEach(id=>
document.getElementById(id).textContent='--');
}
// ── Battery & Charge UI ───────────────────────────────────────────────────────
function updateBatteryBar(pct, status) {
document.getElementById('battBar').style.display='flex';
document.getElementById('battPct').textContent=pct+'%';
document.getElementById('ciPct').textContent=pct+'%';
document.getElementById('badgeCharging').classList.toggle('show', status===1);
document.getElementById('badgeFull').classList.toggle('show', status===2);
const cells=document.getElementById('battCells'); cells.innerHTML='';
const filled=Math.round(pct/10);
for (let i=0;i<10;i++) {
const c=document.createElement('div'); c.className='batt-cell';
if (i<filled) c.className+=status===1?' f charging':pct<=20?' f crit':pct<=40?' f warn':' f';
cells.appendChild(c);
}
}
function updateChargeUI() {
const sl=['Discharging','Charging','Full'];
const sc=['var(--label)','var(--accent)','var(--ok)'];
const el=document.getElementById('ciStatus');
el.textContent=sl[currentChargeStatus]??'--';
el.style.color=sc[currentChargeStatus]??'var(--label)';
if (currentBattPct!==null) updateBatteryBar(currentBattPct, currentChargeStatus);
}
// ── Advanced toggle ───────────────────────────────────────────────────────
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];
document.getElementById(id).textContent = fmt(val);
}
// ── Status UI ────────────────────────────────────────────────────────────────
function setStatus(state) {
const pill=document.getElementById('statusPill');
document.getElementById('statusText').textContent={connected:'CONNECTED',connecting:'CONNECTING…',disconnected:'DISCONNECTED'}[state];
pill.className='status-pill '+state;
document.body.className=state;
const cBtn=document.getElementById('connectBtn'), dBtn=document.getElementById('disconnectBtn');
const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn,#tapKeyHex,.mod-btn input');
if (state==='connected') {
cBtn.style.display='none'; dBtn.style.display='';
inputs.forEach(el=>el.disabled=false);
} else if (state==='connecting') {
cBtn.disabled=true; cBtn.style.display=''; dBtn.style.display='none';
inputs.forEach(el=>el.disabled=true);
} else {
cBtn.disabled=false; cBtn.style.display=''; dBtn.style.display='none';
inputs.forEach(el=>el.disabled=true);
}
}
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(); 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');
setTimeout(async () => {
try {
setStatus('connecting');
server = await savedDevice.gatt.connect();
device = savedDevice;
userDisconnected = false;
log('GATT reconnected','ok');
await discoverServices();
setStatus('connected');
log('Ready','ok');
} catch(e) { log(`Reconnect failed: ${e.message}`,'err'); setStatus('disconnected'); }
}, 1000);
} else {
userDisconnected = false;
}
}
// ── IMU Stream + Visualiser ──────────────────────────────────────────────────
// ImuPacket (14 bytes LE):
// 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');
const ctx = canvas.getContext('2d');
const TRAIL_LEN = 120;
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) {
el.classList.remove('on');
el.classList.add('paused');
el.textContent = '⏸ PAUSED';
} else {
el.classList.add('on');
el.classList.remove('paused');
el.textContent = '● LIVE';
}
document.getElementById('vizPauseBtn').style.display = (!vizPaused && imuSubscribed) ? '' : 'none';
document.getElementById('vizPlayBtn').style.display = (vizPaused || !imuSubscribed) ? '' : 'none';
}
async function vizSetPaused(paused) {
vizPaused = paused;
if (!paused && chars.imuStream && !imuSubscribed) {
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();
}
function parseImuStream(dv) {
let view;
try {
view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
} catch(e) { log(`parseImuStream: DataView wrap failed — ${e.message}`,'err'); return; }
if (view.byteLength < 14) {
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const hex = Array.from(bytes).map(b=>b.toString(16).padStart(2,'0')).join(' ');
log(`IMU raw (${view.byteLength}B, expected 14): ${hex}`,'err');
return;
}
let gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags;
try {
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; }
// 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);
// 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) {
const pct=Math.abs(val)/max*50, neg=val<0;
const bar=document.getElementById(axis+'Bar'), label=document.getElementById(axis+'Val');
bar.style.width=pct+'%';
bar.style.left=neg?(50-pct)+'%':'50%';
bar.className='axis-bar-fill'+(neg?' neg':'');
label.textContent=(val/1000).toFixed(1);
}
function drawViz(idle) {
const W=canvas.width, H=canvas.height;
ctx.fillStyle=cssVar('--canvas-fade'); ctx.fillRect(0,0,W,H);
ctx.strokeStyle=cssVar('--canvas-grid'); ctx.lineWidth=0.5;
for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
ctx.strokeStyle=cssVar('--canvas-center'); ctx.lineWidth=0.5;
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H);ctx.stroke();
ctx.beginPath();ctx.moveTo(0,H/2);ctx.lineTo(W,H/2);ctx.stroke();
const now=Date.now();
const trailRgb=cssVar('--trail-rgb'), trailIdleRgb=cssVar('--trail-idle-rgb');
for(let i=1;i<trail.length;i++){
const age=(now-trail[i].t)/1200, alpha=Math.max(0,1-age); if(alpha<=0) continue;
ctx.strokeStyle=trail[i].idle?`rgba(${trailIdleRgb},${alpha*0.4})`:`rgba(${trailRgb},${alpha*0.7})`;
ctx.lineWidth=1.5;
ctx.beginPath();ctx.moveTo(trail[i-1].x,trail[i-1].y);ctx.lineTo(trail[i].x,trail[i].y);ctx.stroke();
}
const dotColor=idle?cssVar('--canvas-dot-idle'):cssVar('--canvas-dot');
const dotGlow=idle?'transparent':cssVar('--canvas-dot-glow');
ctx.shadowColor=dotGlow; ctx.shadowBlur=12;
ctx.fillStyle=dotColor;
ctx.beginPath();ctx.arc(cursorX,cursorY,idle?3:5,0,Math.PI*2);ctx.fill();
ctx.shadowBlur=0;
if(idle){ctx.fillStyle=cssVar('--canvas-idle-text');ctx.font='10px Share Tech Mono,monospace';ctx.textAlign='center';ctx.fillText('IDLE',W/2,H-10);ctx.textAlign='left';}
}
function flashTap(side){
const el=document.getElementById('tapFlash'+side);
el.classList.add('show'); setTimeout(()=>el.classList.remove('show'),300);
}
function drawInitState() {
const W=canvas.width,H=canvas.height;
ctx.fillStyle=cssVar('--canvas-bg');ctx.fillRect(0,0,W,H);
ctx.strokeStyle=cssVar('--canvas-grid');ctx.lineWidth=0.5;
for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
ctx.strokeStyle=cssVar('--canvas-center');
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H);ctx.stroke();
ctx.beginPath();ctx.moveTo(0,H/2);ctx.lineTo(W,H/2);ctx.stroke();
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 = el.clientHeight || W;
el.width = W; el.height = H;
orientScene = new THREE.Scene();
orientCamera = new THREE.PerspectiveCamera(55, W / H, 0.01, 10);
orientCamera.position.set(0.75, 0.60, 1.10);
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'};
let themeIdx = 0;
function cycleTheme() {
themeIdx = (themeIdx + 1) % 3;
applyTheme(THEMES[themeIdx]);
}
function applyTheme(t) {
document.documentElement.classList.remove('theme-dark','theme-light');
if (t === 'dark') document.documentElement.classList.add('theme-dark');
if (t === 'light') document.documentElement.classList.add('theme-light');
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();
// Restore advanced toggle state
document.getElementById('advancedToggle').checked = advancedMode;
if (advancedMode) toggleAdvanced(true);
})();
if (!navigator.bluetooth) {
document.getElementById('mainContent').innerHTML=`<div class="no-ble"><h2>⚠ Web Bluetooth Not Supported</h2><p>Use <strong>Chrome</strong> or <strong>Edge</strong> on desktop.<br>Linux: enable <code>chrome://flags/#enable-web-bluetooth</code></p></div>`;
} else {
log('Web Bluetooth ready. Click CONNECT to pair your IMU Mouse.','info');
}