// 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 }; // Runtime feature-override flag bitmask constants (mirror firmware FLAG_* defines) const FLAG_TAP_ENABLED = 0x01; const FLAG_TEMP_COMP_ENABLED = 0x02; const FLAG_AUTO_RECAL_ENABLED = 0x04; const FLAG_ALL_DEFAULT = FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG_AUTO_RECAL_ENABLED; // 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:0, tapThreshold:12, tapAction:0, tapKey:0, tapMod:0, tapFreezeEnabled:1, jerkThreshold:2000, featureFlags:FLAG_ALL_DEFAULT, }; 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(() => { console.warn(`[GATT] operation timed out after ${ms}ms`); 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) { console.warn(`[GATT] write dropped - queue depth ${_gattDepth} >= max ${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() { console.log('[GATT] queue reset'); // 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=`${ts}${msg}`; 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)); console.log('[TELEM] subscribing to notifications'); await chars.telemetry.startNotifications(); console.log('[TELEM] subscribed, reading initial value'); // 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 = `` + `FIRMWARE / WEB MISMATCH - ` + `firmware ${fwHash} · web ${webHash} - ` + `flash firmware or reload the page after a pio run` + ``; } // ConfigBlob read / write // ConfigBlob layout (25 bytes LE): // float sensitivity [0], float deadZone [4], float accelStrength [8] // uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14] (0=SLOW 1=FAST) // uint8 tapThreshold [15], uint8 tapAction [16], uint8 tapKey [17], uint8 tapMod [18], uint8 tapFreezeEnabled [19] // float jerkThreshold [20], uint8 featureFlags [24] 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); } if (view.byteLength >= 25) { config.featureFlags = view.getUint8(24); } else { config.featureFlags = FLAG_ALL_DEFAULT; // old firmware - assume all on } 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); document.getElementById('capTapEnabled').checked = !!(config.featureFlags & FLAG_TAP_ENABLED); document.getElementById('capTempComp').checked = !!(config.featureFlags & FLAG_TEMP_COMP_ENABLED); document.getElementById('capAutoRecal').checked = !!(config.featureFlags & FLAG_AUTO_RECAL_ENABLED); } 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.featureFlags = (document.getElementById('capTapEnabled').checked ? FLAG_TAP_ENABLED : 0) | (document.getElementById('capTempComp').checked ? FLAG_TEMP_COMP_ENABLED : 0) | (document.getElementById('capAutoRecal').checked ? FLAG_AUTO_RECAL_ENABLED : 0); // config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly const buf = new ArrayBuffer(25); 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); view.setUint8(24, config.featureFlags); 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 → ${['SLOW 50mA','FAST 100mA'][val]}`,'warn'); } function setChargeModeUI(val) { [['chgSlow','slow'],['chgFast','fast']].forEach(([id,cls],i) => { const b = document.getElementById(id); b.classList.remove('active','slow','fast'); if (i===val) b.classList.add('active', cls); }); document.getElementById('ciMode').textContent = ['50 mA','100 mA'][val] ?? '--'; } function onCapTapChange(enabled) { writeConfigBlob(); log('Tap detection ' + (enabled ? 'enabled' : 'disabled') + ' - restart device to apply', 'warn'); } 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; console.log('[TELEM] charge status:', ['discharging','charging','full'][chargeStatus] ?? 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 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]; 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; let _prevIdle = 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() { console.log('[STREAM] diagnostics reset'); streamPktCount = streamPktTotal = streamLastPktT = streamLastRateT = 0; _prevIdle = false; 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) { console.log('[STREAM] requesting subscribe'); 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) { console.log(`[STREAM] requesting unsubscribe (total rx: ${streamPktTotal})`); try { await chars.imuStream.stopNotifications(); imuSubscribed = false; streamDiagReset(); document.getElementById('orientLabel').textContent = '- not streaming -'; } catch(e) { log(`IMU stream stop failed: ${e.message}`,'err'); } } else { console.log(`[STREAM] vizSetPaused(${paused}) - no action (imuSubscribed=${imuSubscribed})`); } 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 (idle !== _prevIdle) { console.log(`[STREAM] idle → ${idle ? 'idle' : 'active'} (pkt #${streamPktTotal})`); _prevIdle = idle; } if (single) console.log(`[STREAM] single tap (pkt #${streamPktTotal})`); if (dbl) console.log(`[STREAM] double tap (pkt #${streamPktTotal})`); 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;xel.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 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=`

⚠ Web Bluetooth Not Supported

Use Chrome or Edge on desktop.
Linux: enable chrome://flags/#enable-web-bluetooth

`; } else { log('Web Bluetooth ready. Click CONNECT to pair your IMU Mouse.','info'); }