// ── 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 16 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 }; // 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") ─────────────── let _gattQueue = Promise.resolve(); function gattWrite(char, value) { const p = _gattQueue.then(() => char.writeValueWithResponse(value)); _gattQueue = p.catch(() => {}); return p; } function gattCmd(char, value) { const p = _gattQueue.then(() => char.writeValueWithoutResponse(value)); _gattQueue = p.catch(() => {}); return p; } // ── 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); // Read config blob and populate UI await readConfigBlob(); // 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 notify (~100 Hz) chars.imuStream.addEventListener('characteristicvaluechanged', e => parseImuStream(e.target.value)); await chars.imuStream.startNotifications(); document.getElementById('vizLive').classList.add('on'); log('IMU stream subscribed','ok'); 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'); } } // ── 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; 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'); document.getElementById('vizLive').classList.remove('on'); 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 = []; 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 gyroY, gyroZ, moveX, moveY, flags; try { gyroY = view.getInt16(0, true); gyroZ = view.getInt16(2, 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); updateAxisBar('gy', gyroY, 30000); updateAxisBar('gz', gyroZ, 30000); if (!idle) { 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(); if (single) flashTap('Left'); if (dbl) flashTap('Right'); drawViz(idle); } 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

⚠ 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'); }