Files
air-mouse/web/app.js
2026-03-01 21:35:36 +01:00

825 lines
36 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 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") ───────────────
// 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);
// 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 — 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'); }
}
// ── 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 (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);
}
// ── 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)],
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();
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;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 = 160;
el.width = W; el.height = H;
orientScene = new THREE.Scene();
orientCamera = new THREE.PerspectiveCamera(40, W / H, 0.01, 10);
orientCamera.position.set(0.6, 0.5, 0.9);
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, gyY_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 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=`<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');
}