// ── 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 }; let device=null, server=null, chars={}, userDisconnected=false; let currentChargeStatus=0, currentBattPct=null; // ── 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=`${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)); await chars.telemetry.startNotifications(); // Initial read so values show immediately parseTelemetry(await chars.telemetry.readValue()); // 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 (20 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 pad [19] 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); } 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('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.curve, config.chargeMode, config.tapAction, config.tapKey updated directly const buf = new ArrayBuffer(20); 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, 0); 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 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 (24 bytes LE): // uint32 uptime [0], uint32 leftClicks [4], uint32 rightClicks [8] // float temp [12], float biasRms [16] // uint16 recalCount [20], uint8 chargeStatus [22], uint8 pad [23] 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 24B, got ${view.byteLength}B — MTU too small? raw: ${hex}`,'err'); return; } let uptime, leftClicks, rightClicks, temp, biasRms, recalCount, chargeStatus; 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); } 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(); } } 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 (iparseFloat(v).toFixed(0)], deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)], accel: ['valAccel', v=>parseFloat(v).toFixed(2)], 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('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 gyroY_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; } } // ── Orientation (boot-pose tilt compensation) ───────────────────────────────── // We average the first N accel frames to determine which way gravity points, // then build a 2×2 rotation matrix so that "up on screen" stays world-up // regardless of how the device is rotated flat on the table. const ORIENT_SAMPLES = 30; let orientSamples = 0; let orientAccum = [0, 0, 0]; // 2×2 basis: [row0=[gY→screenX coeff, gZ→screenX coeff], // row1=[gY→screenY coeff, gZ→screenY coeff]] // Identity by default (no rotation assumed until samples collected) let orientBasis = [[1, 0], [0, 1]]; function buildOrientBasis() { // Average accel vector = gravity direction in device frame const gx = orientAccum[0] / ORIENT_SAMPLES; const gy = orientAccum[1] / ORIENT_SAMPLES; // gz unused — device assumed roughly flat (|gz| ≈ 1g, gx/gy ≈ 0 when flat) // The horizontal tilt (yaw rotation of the device on the table) is the angle // between device-X axis and world horizontal, derived from gx/gy. // Firmware uses gyroZ for screen-X and gyroY for screen-Y. // A device rotated θ clockwise on the table needs gyros counter-rotated by θ. const norm = Math.sqrt(gx*gx + gy*gy) || 1; const sinT = gx / norm; // sin of table-yaw angle const cosT = -gy / norm; // cos of table-yaw angle (negative Y = gravity down) // Rotation matrix for gyro [gyroY, gyroZ] → [screenX, screenY]: // Normally: screenX = -gyroZ, screenY = -gyroY (firmware convention reflected) // With tilt θ: apply 2D rotation by θ to that vector. orientBasis = [ [-sinT, -cosT], // screenX row [-cosT, sinT], // screenY row ]; const deg = (Math.atan2(gx, -gy) * 180 / Math.PI).toFixed(1); log(`Orient locked — device yaw ≈ ${deg}° from nominal`, 'info'); document.getElementById('orientLabel').textContent = `yaw offset ${deg}° · complementary filter active`; } function resetOrient() { orientSamples = 0; orientAccum = [0, 0, 0]; orientBasis = [[1, 0], [0, 1]]; } 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 { resetOrient(); await chars.imuStream.startNotifications(); imuSubscribed = true; 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(); } catch(e) { log(`IMU stream stop failed: ${e.message}`,'err'); } } vizUpdateIndicator(); } function parseImuStream(dv) { if (vizPaused) return; 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 gyroY, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags; try { gyroY = view.getInt16(0, true); gyroZ = view.getInt16(2, true); 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; } const idle = !!(flags & 0x01); const single = !!(flags & 0x02); const dbl = !!(flags & 0x04); // Accumulate boot-pose from first ORIENT_SAMPLES accel frames (device flat on table) if (orientSamples < ORIENT_SAMPLES) { orientAccum[0] += accelX; orientAccum[1] += accelY; orientAccum[2] += accelZ; orientSamples++; if (orientSamples === ORIENT_SAMPLES) buildOrientBasis(); } // Rotate gyro into world frame using boot-pose basis // gyroY → pitch axis, gyroZ → yaw axis (firmware convention) const wX = gyroY * orientBasis[0][0] + gyroZ * orientBasis[0][1]; const wY = gyroY * orientBasis[1][0] + gyroZ * orientBasis[1][1]; updateAxisBar('gy', wX, 30000); updateAxisBar('gz', wY, 30000); if (!idle) { // moveX/moveY already come corrected from firmware; apply same world rotation const wmX = moveX * orientBasis[0][0] + moveY * orientBasis[1][0]; const wmY = moveX * orientBasis[0][1] + moveY * orientBasis[1][1]; cursorX = Math.max(4, Math.min(canvas.width - 4, cursorX + wmX * 1.5)); cursorY = Math.max(4, Math.min(canvas.height - 4, cursorY + wmY * 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, gyroY, 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 gyroY (pitch) and gyroZ (yaw), mDPS // Map to Three.js axes: gyroZ→world Y, gyroY→world X const gyRad = gyY_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(); })(); 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'); }