991 lines
44 KiB
JavaScript
991 lines
44 KiB
JavaScript
// ── 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:1,
|
||
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(() => reject(new Error(`GATT timeout (${ms}ms)`)), ms);
|
||
promise.then(v => { clearTimeout(t); resolve(v); },
|
||
e => { clearTimeout(t); reject(e); });
|
||
});
|
||
}
|
||
|
||
function _enqueue(fn) {
|
||
if (_gattDepth >= GATT_MAX_DEPTH) {
|
||
return Promise.reject(new Error('GATT queue full — device unreachable?'));
|
||
}
|
||
_gattDepth++;
|
||
const p = _gattQueue.then(() => _withTimeout(fn(), GATT_TIMEOUT_MS));
|
||
_gattQueue = p.catch(() => {}).finally(() => { _gattDepth = Math.max(0, _gattDepth - 1); });
|
||
return p;
|
||
}
|
||
|
||
function gattWrite(char, value) { return _enqueue(() => char.writeValueWithResponse(value)); }
|
||
function gattCmd (char, value) { return _enqueue(() => char.writeValueWithoutResponse(value)); }
|
||
|
||
function gattQueueReset() {
|
||
// Drain the chain so a reconnect starts with a fresh resolved promise
|
||
_gattQueue = Promise.resolve();
|
||
_gattDepth = 0;
|
||
}
|
||
|
||
// ── Logging ──────────────────────────────────────────────────────────────────
|
||
(function() {
|
||
const _methods = { log: '', warn: 'warn', error: 'err' };
|
||
for (const [method, type] of Object.entries(_methods)) {
|
||
const orig = console[method].bind(console);
|
||
console[method] = (...args) => {
|
||
orig(...args);
|
||
log(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '), type);
|
||
};
|
||
}
|
||
})();
|
||
|
||
function log(msg, type='') {
|
||
const el=document.getElementById('console');
|
||
const now=new Date();
|
||
const ts=`${p2(now.getHours())}:${p2(now.getMinutes())}:${p2(now.getSeconds())}.${p3(now.getMilliseconds())}`;
|
||
const d=document.createElement('div'); d.className='log-line';
|
||
d.innerHTML=`<span class="log-time">${ts}</span><span class="log-msg ${type}">${msg}</span>`;
|
||
el.appendChild(d); el.scrollTop=el.scrollHeight;
|
||
}
|
||
const p2=n=>String(n).padStart(2,'0'), p3=n=>String(n).padStart(3,'0');
|
||
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
||
|
||
// ── Connection ───────────────────────────────────────────────────────────────
|
||
async function doConnect() {
|
||
if (!navigator.bluetooth) { log('Web Bluetooth not supported.','err'); return; }
|
||
userDisconnected = false;
|
||
setStatus('connecting');
|
||
log('Scanning for IMU Mouse…','info');
|
||
try {
|
||
device = await navigator.bluetooth.requestDevice({
|
||
filters:[{name:'IMU Mouse'},{name:'IMU Mouse (safe)'}],
|
||
optionalServices:[SVC_UUID,'battery_service']
|
||
});
|
||
device.addEventListener('gattserverdisconnected', onDisconnected);
|
||
log(`Found: ${device.name}`,'ok');
|
||
server = await device.gatt.connect();
|
||
log('GATT connected','ok');
|
||
await discoverServices();
|
||
setStatus('connected');
|
||
log('Ready','ok');
|
||
} catch(e) { log(`Connection failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
||
}
|
||
|
||
function doDisconnect() {
|
||
if (device && device.gatt.connected) {
|
||
userDisconnected = true;
|
||
log('Disconnecting…','warn');
|
||
device.gatt.disconnect();
|
||
}
|
||
}
|
||
|
||
async function discoverServices() {
|
||
log('Discovering services…','info');
|
||
try {
|
||
const svc = await server.getPrimaryService(SVC_UUID);
|
||
|
||
chars.configBlob = await svc.getCharacteristic(CHR.configBlob);
|
||
chars.command = await svc.getCharacteristic(CHR.command);
|
||
chars.telemetry = await svc.getCharacteristic(CHR.telemetry);
|
||
chars.imuStream = await svc.getCharacteristic(CHR.imuStream);
|
||
chars.gitHash = await svc.getCharacteristic(CHR.gitHash);
|
||
|
||
// Read config blob and populate UI
|
||
await readConfigBlob();
|
||
|
||
// Read firmware git hash and check against web build hash
|
||
await checkHashMatch();
|
||
|
||
// Telemetry notify (1 Hz) — also carries chargeStatus
|
||
chars.telemetry.addEventListener('characteristicvaluechanged', e => parseTelemetry(e.target.value));
|
||
await chars.telemetry.startNotifications();
|
||
// Initial read so values show immediately. Also force updateChargeUI() here
|
||
// because parseTelemetry() only calls it on a *change*, and currentChargeStatus
|
||
// starts at 0 (discharging) — so a discharging device would never trigger the
|
||
// update and ciStatus would stay at '--'.
|
||
parseTelemetry(await chars.telemetry.readValue());
|
||
updateChargeUI();
|
||
|
||
// IMU stream — subscribed on demand via play button
|
||
chars.imuStream.addEventListener('characteristicvaluechanged', e => parseImuStream(e.target.value));
|
||
|
||
log('Config service ready (4 chars)','ok');
|
||
} catch(e) {
|
||
log(`Service discovery failed: ${e.message}`,'err');
|
||
// Safe mode device might not have config service
|
||
if (e.message.includes('not found')) log('Device may be in safe mode — basic mouse only','warn');
|
||
}
|
||
|
||
// Battery service (standard — always present)
|
||
try {
|
||
const bsvc = await server.getPrimaryService('battery_service');
|
||
const bch = await bsvc.getCharacteristic('battery_level');
|
||
bch.addEventListener('characteristicvaluechanged', e => {
|
||
currentBattPct = e.target.value.getUint8(0);
|
||
updateBatteryBar(currentBattPct, currentChargeStatus);
|
||
});
|
||
await bch.startNotifications();
|
||
const v = await bch.readValue();
|
||
currentBattPct = v.getUint8(0);
|
||
updateBatteryBar(currentBattPct, currentChargeStatus);
|
||
log(`Battery: ${currentBattPct}%`,'ok');
|
||
} catch(e) { log('Battery service unavailable','warn'); }
|
||
}
|
||
|
||
// ── Firmware / web hash mismatch banner ──────────────────────────────────────
|
||
async function checkHashMatch() {
|
||
const banner = document.getElementById('hashMismatchBanner');
|
||
if (!chars.gitHash) return;
|
||
let fwHash = 'unknown';
|
||
try {
|
||
const dv = await chars.gitHash.readValue();
|
||
const bytes = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
||
// Find NUL terminator or use full length
|
||
let end = bytes.indexOf(0);
|
||
if (end === -1) end = bytes.length;
|
||
fwHash = new TextDecoder().decode(bytes.subarray(0, end));
|
||
} catch(e) { log(`Hash read failed: ${e.message}`, 'warn'); }
|
||
|
||
// FIRMWARE_BUILD_HASH comes from web/version.js (written by scripts/git_hash.py at build time)
|
||
const webHash = (typeof FIRMWARE_BUILD_HASH !== 'undefined') ? FIRMWARE_BUILD_HASH : 'unknown';
|
||
|
||
log(`Firmware hash: ${fwHash} · Web hash: ${webHash}`, fwHash === webHash ? 'ok' : 'warn');
|
||
|
||
if (fwHash === 'unknown' || webHash === 'unknown' || fwHash === webHash) {
|
||
banner.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
banner.style.cssText = [
|
||
'display:flex', 'align-items:center', 'justify-content:center', 'gap:12px',
|
||
'background:#7a2020', 'color:#ffd0d0', 'font-family:var(--mono)',
|
||
'font-size:11px', 'padding:6px 16px', 'border-bottom:1px solid #c04040',
|
||
'position:sticky', 'top:0', 'z-index:100',
|
||
].join(';');
|
||
banner.innerHTML =
|
||
`<span style="font-size:14px">⚠</span>` +
|
||
`<span>FIRMWARE / WEB MISMATCH — ` +
|
||
`firmware <b>${fwHash}</b> · web <b>${webHash}</b> — ` +
|
||
`flash firmware or reload the page after a <code>pio run</code></span>` +
|
||
`<button onclick="document.getElementById('hashMismatchBanner').style.display='none'" ` +
|
||
`style="margin-left:8px;background:none;border:1px solid #c04040;color:#ffd0d0;` +
|
||
`cursor:pointer;padding:2px 8px;font-family:var(--mono);font-size:10px">✕</button>`;
|
||
}
|
||
|
||
// ── ConfigBlob read / write ──────────────────────────────────────────────────
|
||
// ConfigBlob layout (25 bytes LE):
|
||
// float sensitivity [0], float deadZone [4], float accelStrength [8]
|
||
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14]
|
||
// uint8 tapThreshold [15], uint8 tapAction [16], uint8 tapKey [17], uint8 tapMod [18], uint8 tapFreezeEnabled [19]
|
||
// float jerkThreshold [20], 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 → ${['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 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;
|
||
updateChargeUI();
|
||
}
|
||
|
||
if (battVoltage !== null) {
|
||
currentBattVoltage = battVoltage;
|
||
document.getElementById('ciVolt').textContent = battVoltage.toFixed(2) + 'V';
|
||
}
|
||
}
|
||
function formatUptime(s) {
|
||
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), ss=s%60;
|
||
return h>0 ? `${h}h ${p2(m)}m` : `${m}m ${p2(ss)}s`;
|
||
}
|
||
function clearTelemetry() {
|
||
['telTemp','telUptime','telLeft','telRight','telBias','telRecal'].forEach(id=>
|
||
document.getElementById(id).textContent='--');
|
||
}
|
||
|
||
// ── Battery & Charge UI ───────────────────────────────────────────────────────
|
||
function updateBatteryBar(pct, status) {
|
||
document.getElementById('battBar').style.display='flex';
|
||
document.getElementById('battPct').textContent=pct+'%';
|
||
document.getElementById('ciPct').textContent=pct+'%';
|
||
document.getElementById('badgeCharging').classList.toggle('show', status===1);
|
||
document.getElementById('badgeFull').classList.toggle('show', status===2);
|
||
const cells=document.getElementById('battCells'); cells.innerHTML='';
|
||
const filled=Math.round(pct/10);
|
||
for (let i=0;i<10;i++) {
|
||
const c=document.createElement('div'); c.className='batt-cell';
|
||
if (i<filled) c.className+=status===1?' f charging':pct<=20?' f crit':pct<=40?' f warn':' f';
|
||
cells.appendChild(c);
|
||
}
|
||
}
|
||
function updateChargeUI() {
|
||
const sl=['Discharging','Charging','Full'];
|
||
const sc=['var(--label)','var(--accent)','var(--ok)'];
|
||
const el=document.getElementById('ciStatus');
|
||
el.textContent=sl[currentChargeStatus]??'--';
|
||
el.style.color=sc[currentChargeStatus]??'var(--label)';
|
||
if (currentBattPct!==null) updateBatteryBar(currentBattPct, currentChargeStatus);
|
||
}
|
||
|
||
// ── Advanced toggle ───────────────────────────────────────────────────────
|
||
function toggleAdvanced(on) {
|
||
advancedMode = on;
|
||
localStorage.setItem('advanced', on);
|
||
document.getElementById('ciVoltItem').style.display = on ? '' : 'none';
|
||
document.getElementById('debugBtn').style.display = on ? '' : 'none';
|
||
// Switch charge-info grid between 3 and 4 columns
|
||
document.getElementById('chargeInfo').style.gridTemplateColumns = on ? '1fr 1fr 1fr 1fr' : '1fr 1fr 1fr';
|
||
}
|
||
|
||
// ── IMU Debug Recorder ────────────────────────────────────────────────────────
|
||
let debugModalOpen = false;
|
||
let debugRecording = false;
|
||
let debugBuffer = [];
|
||
const DEBUG_LIVE_ROWS = 40;
|
||
let debugLiveRing = [];
|
||
let debugT0 = 0;
|
||
|
||
function openDebugModal() {
|
||
debugModalOpen = true;
|
||
debugT0 = Date.now();
|
||
debugLiveRing = [];
|
||
document.getElementById('debugOverlay').classList.add('show');
|
||
// Auto-start IMU stream if not already running
|
||
if (!imuSubscribed && chars.imuStream) vizSetPaused(false);
|
||
}
|
||
function closeDebugModal() {
|
||
debugModalOpen = false;
|
||
document.getElementById('debugOverlay').classList.remove('show');
|
||
}
|
||
|
||
function feedDebugRow(gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags) {
|
||
if (!debugModalOpen) return;
|
||
const ms = Date.now() - debugT0;
|
||
const row = { ms, gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags };
|
||
|
||
// Live ring buffer
|
||
debugLiveRing.push(row);
|
||
if (debugLiveRing.length > DEBUG_LIVE_ROWS) debugLiveRing.shift();
|
||
|
||
// Recording buffer
|
||
if (debugRecording) {
|
||
debugBuffer.push(row);
|
||
document.getElementById('debugRecCount').textContent = debugBuffer.length + ' samples';
|
||
}
|
||
|
||
// Shock indicator
|
||
const shocked = !!(flags & 0x08);
|
||
const badge = document.getElementById('debugShockBadge');
|
||
badge.classList.toggle('active', shocked);
|
||
|
||
// Render live table
|
||
const tbody = document.getElementById('debugRows');
|
||
tbody.innerHTML = '';
|
||
for (const r of debugLiveRing) {
|
||
const f = [];
|
||
if (r.flags & 0x01) f.push('idle');
|
||
if (r.flags & 0x02) f.push('tap1');
|
||
if (r.flags & 0x04) f.push('tap2');
|
||
if (r.flags & 0x08) f.push('shock');
|
||
const tr = document.createElement('tr');
|
||
if (r.flags & 0x08) tr.className = 'shock-row';
|
||
tr.innerHTML =
|
||
`<td>${r.ms}</td><td>${r.gyroX}</td><td>${r.gyroZ}</td>` +
|
||
`<td>${r.accelX}</td><td>${r.accelY}</td><td>${r.accelZ}</td>` +
|
||
`<td>${r.moveX}</td><td>${r.moveY}</td><td>${f.join(' ')}</td>`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
tbody.parentElement.parentElement.scrollTop = tbody.parentElement.parentElement.scrollHeight;
|
||
}
|
||
|
||
function toggleDebugRec() {
|
||
debugRecording = !debugRecording;
|
||
const btn = document.getElementById('debugRecBtn');
|
||
btn.classList.toggle('recording', debugRecording);
|
||
btn.textContent = debugRecording ? '■ STOP' : '● REC';
|
||
if (debugRecording) { debugBuffer = []; debugT0 = Date.now(); }
|
||
document.getElementById('debugRecCount').textContent = debugBuffer.length + ' samples';
|
||
}
|
||
|
||
function saveDebugCSV() {
|
||
if (!debugBuffer.length) { log('No recorded data to save','warn'); return; }
|
||
const header = 'ms,gyroX_mDPS,gyroZ_mDPS,accelX_mg,accelY_mg,accelZ_mg,moveX,moveY,flags\n';
|
||
const rows = debugBuffer.map(r =>
|
||
`${r.ms},${r.gyroX},${r.gyroZ},${r.accelX},${r.accelY},${r.accelZ},${r.moveX},${r.moveY},${r.flags}`
|
||
).join('\n');
|
||
const blob = new Blob([header + rows], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = `imu_debug_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`;
|
||
a.click(); URL.revokeObjectURL(url);
|
||
log(`Saved ${debugBuffer.length} samples as CSV`,'ok');
|
||
}
|
||
|
||
function clearDebugRec() {
|
||
debugBuffer = [];
|
||
debugRecording = false;
|
||
const btn = document.getElementById('debugRecBtn');
|
||
btn.classList.remove('recording');
|
||
btn.textContent = '● REC';
|
||
document.getElementById('debugRecCount').textContent = '0 samples';
|
||
}
|
||
|
||
// ── Param display ─────────────────────────────────────────────────────────────
|
||
function updateDisplay(key, val) {
|
||
const map = {
|
||
sensitivity: ['valSensitivity', v=>parseFloat(v).toFixed(0)],
|
||
deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)],
|
||
accel: ['valAccel', v=>parseFloat(v).toFixed(2)],
|
||
jerkThreshold:['valJerkThreshold',v=>parseFloat(v).toFixed(0)],
|
||
tapThreshold: ['valTapThreshold', v=>(parseFloat(v)*62.5).toFixed(0)+' mg'],
|
||
};
|
||
const [id,fmt] = map[key];
|
||
document.getElementById(id).textContent = fmt(val);
|
||
}
|
||
|
||
// ── Status UI ────────────────────────────────────────────────────────────────
|
||
function setStatus(state) {
|
||
const pill=document.getElementById('statusPill');
|
||
document.getElementById('statusText').textContent={connected:'CONNECTED',connecting:'CONNECTING…',disconnected:'DISCONNECTED'}[state];
|
||
pill.className='status-pill '+state;
|
||
document.body.className=state;
|
||
const cBtn=document.getElementById('connectBtn'), dBtn=document.getElementById('disconnectBtn');
|
||
const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn,#tapKeyHex,.mod-btn input');
|
||
if (state==='connected') {
|
||
cBtn.style.display='none'; dBtn.style.display='';
|
||
inputs.forEach(el=>el.disabled=false);
|
||
} else if (state==='connecting') {
|
||
cBtn.disabled=true; cBtn.style.display=''; dBtn.style.display='none';
|
||
inputs.forEach(el=>el.disabled=true);
|
||
} else {
|
||
cBtn.disabled=false; cBtn.style.display=''; dBtn.style.display='none';
|
||
inputs.forEach(el=>el.disabled=true);
|
||
}
|
||
}
|
||
function onDisconnected() {
|
||
log('Device disconnected','warn');
|
||
const savedDevice = device;
|
||
gattQueueReset();
|
||
chars={}; device=null; server=null;
|
||
setStatus('disconnected');
|
||
document.getElementById('battBar').style.display='none';
|
||
document.getElementById('badgeCharging').classList.remove('show');
|
||
document.getElementById('badgeFull').classList.remove('show');
|
||
imuSubscribed = false; vizPaused = true; vizUpdateIndicator(); streamDiagReset();
|
||
document.getElementById('orientLabel').textContent = '— not streaming —';
|
||
document.getElementById('hashMismatchBanner').style.display = 'none';
|
||
clearTelemetry();
|
||
if (!userDisconnected && document.getElementById('autoReconnect').checked && savedDevice) {
|
||
log('Auto-reconnecting…','info');
|
||
setTimeout(async () => {
|
||
try {
|
||
setStatus('connecting');
|
||
server = await savedDevice.gatt.connect();
|
||
device = savedDevice;
|
||
userDisconnected = false;
|
||
log('GATT reconnected','ok');
|
||
await discoverServices();
|
||
setStatus('connected');
|
||
log('Ready','ok');
|
||
} catch(e) { log(`Reconnect failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
||
}, 1000);
|
||
} else {
|
||
userDisconnected = false;
|
||
}
|
||
}
|
||
|
||
// ── IMU Stream + Visualiser ──────────────────────────────────────────────────
|
||
// ImuPacket (14 bytes LE):
|
||
// int16 gyroX_mDPS [0], int16 gyroZ_mDPS [2]
|
||
// int16 accelX_mg [4], int16 accelY_mg [6], int16 accelZ_mg [8]
|
||
// int8 moveX [10], int8 moveY [11], uint8 flags [12], uint8 pad [13]
|
||
const canvas = document.getElementById('vizCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const TRAIL_LEN = 120;
|
||
let cursorX = canvas.width/2, cursorY = canvas.height/2, trail = [];
|
||
let vizPaused = true;
|
||
let imuSubscribed = false;
|
||
|
||
// ── Stream diagnostics ────────────────────────────────────────────────────────
|
||
let streamPktCount = 0; // packets received this second
|
||
let streamPktTotal = 0; // lifetime packet count
|
||
let streamLastPktT = 0; // timestamp of last packet (for gap detection)
|
||
let streamLastRateT = 0; // timestamp of last rate log
|
||
let streamFreezeTimer = null; // fires if no packet for >1s while subscribed
|
||
|
||
function streamDiagReset() {
|
||
streamPktCount = streamPktTotal = streamLastPktT = streamLastRateT = 0;
|
||
if (streamFreezeTimer) { clearTimeout(streamFreezeTimer); streamFreezeTimer = null; }
|
||
}
|
||
|
||
function streamDiagPkt() {
|
||
const now = Date.now();
|
||
|
||
// Gap detection — warn if >300ms since last packet while streaming
|
||
if (streamLastPktT) {
|
||
const gap = now - streamLastPktT;
|
||
if (gap > 300) log(`[STREAM] gap ${gap}ms (pkt #${streamPktTotal})`, 'warn');
|
||
}
|
||
streamLastPktT = now;
|
||
streamPktCount++;
|
||
streamPktTotal++;
|
||
|
||
// Reset freeze watchdog — 1.5s without a packet = freeze
|
||
if (streamFreezeTimer) clearTimeout(streamFreezeTimer);
|
||
streamFreezeTimer = setTimeout(() => {
|
||
log(`[STREAM] FROZEN — no packet for 1.5s (total rx: ${streamPktTotal})`, 'err');
|
||
streamFreezeTimer = null;
|
||
}, 1500);
|
||
|
||
// Log rate every 5s
|
||
if (streamLastRateT === 0) streamLastRateT = now;
|
||
if (now - streamLastRateT >= 5000) {
|
||
const rate = (streamPktCount / ((now - streamLastRateT) / 1000)).toFixed(1);
|
||
log(`[STREAM] ${rate} pkt/s · total ${streamPktTotal}`, 'info');
|
||
streamPktCount = 0;
|
||
streamLastRateT = now;
|
||
}
|
||
}
|
||
|
||
// Roll compensation is done entirely in firmware (calibrateGyroBias computes
|
||
// rollSin/rollCos from boot-pose accel and applies the rotation before moveX/moveY).
|
||
// The web visualiser just uses moveX/moveY directly — no re-rotation needed here.
|
||
function resetOrient() {} // kept for call-site compatibility
|
||
|
||
function vizUpdateIndicator() {
|
||
const el = document.getElementById('vizLive');
|
||
if (!imuSubscribed || vizPaused) {
|
||
el.classList.remove('on');
|
||
el.classList.add('paused');
|
||
el.textContent = '⏸ PAUSED';
|
||
} else {
|
||
el.classList.add('on');
|
||
el.classList.remove('paused');
|
||
el.textContent = '● LIVE';
|
||
}
|
||
document.getElementById('vizPauseBtn').style.display = (!vizPaused && imuSubscribed) ? '' : 'none';
|
||
document.getElementById('vizPlayBtn').style.display = (vizPaused || !imuSubscribed) ? '' : 'none';
|
||
}
|
||
|
||
async function vizSetPaused(paused) {
|
||
vizPaused = paused;
|
||
if (!paused && chars.imuStream && !imuSubscribed) {
|
||
try {
|
||
await chars.imuStream.startNotifications();
|
||
imuSubscribed = true;
|
||
document.getElementById('orientLabel').textContent = 'roll correction active (firmware)';
|
||
log('IMU stream subscribed','ok');
|
||
} catch(e) { log(`IMU stream start failed: ${e.message}`,'err'); vizPaused = true; }
|
||
} else if (paused && imuSubscribed) {
|
||
try {
|
||
await chars.imuStream.stopNotifications();
|
||
imuSubscribed = false;
|
||
streamDiagReset();
|
||
document.getElementById('orientLabel').textContent = '— not streaming —';
|
||
} catch(e) { log(`IMU stream stop failed: ${e.message}`,'err'); }
|
||
}
|
||
vizUpdateIndicator();
|
||
}
|
||
|
||
function parseImuStream(dv) {
|
||
let view;
|
||
try {
|
||
view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
|
||
} catch(e) { log(`parseImuStream: DataView wrap failed — ${e.message}`,'err'); return; }
|
||
|
||
if (view.byteLength < 14) {
|
||
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||
const hex = Array.from(bytes).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
||
log(`IMU raw (${view.byteLength}B, expected 14): ${hex}`,'err');
|
||
return;
|
||
}
|
||
|
||
let gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags;
|
||
try {
|
||
gyroX = view.getInt16(0, true); // GX = pitch axis (nod → cursor Y)
|
||
gyroZ = view.getInt16(2, true); // GZ = yaw axis (pan → cursor X)
|
||
accelX = view.getInt16(4, true);
|
||
accelY = view.getInt16(6, true);
|
||
accelZ = view.getInt16(8, true);
|
||
moveX = view.getInt8(10);
|
||
moveY = view.getInt8(11);
|
||
flags = view.getUint8(12);
|
||
} catch(e) { log(`parseImuStream: parse error — ${e.message}`,'err'); return; }
|
||
|
||
// Feed debug recorder (even when viz is paused)
|
||
feedDebugRow(gyroX, gyroZ, accelX, accelY, accelZ, moveX, moveY, flags);
|
||
|
||
if (vizPaused) return;
|
||
const idle = !!(flags & 0x01);
|
||
const single = !!(flags & 0x02);
|
||
const dbl = !!(flags & 0x04);
|
||
|
||
// Axis bars: show raw gyro (firmware convention: Z→screen-X, X→screen-Y)
|
||
updateAxisBar('gy', -gyroZ, 30000);
|
||
updateAxisBar('gz', -gyroX, 30000);
|
||
|
||
if (!idle) {
|
||
// moveX/moveY are already roll-corrected by firmware — use them directly
|
||
cursorX = Math.max(4, Math.min(canvas.width - 4, cursorX + moveX * 1.5));
|
||
cursorY = Math.max(4, Math.min(canvas.height - 4, cursorY + moveY * 1.5));
|
||
}
|
||
trail.push({x:cursorX, y:cursorY, t:Date.now(), idle});
|
||
if (trail.length > TRAIL_LEN) trail.shift();
|
||
|
||
streamDiagPkt();
|
||
|
||
if (single) flashTap('Left');
|
||
if (dbl) flashTap('Right');
|
||
drawViz(idle);
|
||
|
||
orientFeedIMU(accelX, accelY, accelZ, gyroX, gyroZ);
|
||
}
|
||
|
||
function updateAxisBar(axis, val, max) {
|
||
const pct=Math.abs(val)/max*50, neg=val<0;
|
||
const bar=document.getElementById(axis+'Bar'), label=document.getElementById(axis+'Val');
|
||
bar.style.width=pct+'%';
|
||
bar.style.left=neg?(50-pct)+'%':'50%';
|
||
bar.className='axis-bar-fill'+(neg?' neg':'');
|
||
label.textContent=(val/1000).toFixed(1);
|
||
}
|
||
|
||
function drawViz(idle) {
|
||
const W=canvas.width, H=canvas.height;
|
||
ctx.fillStyle=cssVar('--canvas-fade'); ctx.fillRect(0,0,W,H);
|
||
ctx.strokeStyle=cssVar('--canvas-grid'); ctx.lineWidth=0.5;
|
||
for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
|
||
for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
|
||
ctx.strokeStyle=cssVar('--canvas-center'); ctx.lineWidth=0.5;
|
||
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H);ctx.stroke();
|
||
ctx.beginPath();ctx.moveTo(0,H/2);ctx.lineTo(W,H/2);ctx.stroke();
|
||
const now=Date.now();
|
||
const trailRgb=cssVar('--trail-rgb'), trailIdleRgb=cssVar('--trail-idle-rgb');
|
||
for(let i=1;i<trail.length;i++){
|
||
const age=(now-trail[i].t)/1200, alpha=Math.max(0,1-age); if(alpha<=0) continue;
|
||
ctx.strokeStyle=trail[i].idle?`rgba(${trailIdleRgb},${alpha*0.4})`:`rgba(${trailRgb},${alpha*0.7})`;
|
||
ctx.lineWidth=1.5;
|
||
ctx.beginPath();ctx.moveTo(trail[i-1].x,trail[i-1].y);ctx.lineTo(trail[i].x,trail[i].y);ctx.stroke();
|
||
}
|
||
const dotColor=idle?cssVar('--canvas-dot-idle'):cssVar('--canvas-dot');
|
||
const dotGlow=idle?'transparent':cssVar('--canvas-dot-glow');
|
||
ctx.shadowColor=dotGlow; ctx.shadowBlur=12;
|
||
ctx.fillStyle=dotColor;
|
||
ctx.beginPath();ctx.arc(cursorX,cursorY,idle?3:5,0,Math.PI*2);ctx.fill();
|
||
ctx.shadowBlur=0;
|
||
if(idle){ctx.fillStyle=cssVar('--canvas-idle-text');ctx.font='10px Share Tech Mono,monospace';ctx.textAlign='center';ctx.fillText('IDLE',W/2,H-10);ctx.textAlign='left';}
|
||
}
|
||
|
||
function flashTap(side){
|
||
const el=document.getElementById('tapFlash'+side);
|
||
el.classList.add('show'); setTimeout(()=>el.classList.remove('show'),300);
|
||
}
|
||
|
||
function drawInitState() {
|
||
const W=canvas.width,H=canvas.height;
|
||
ctx.fillStyle=cssVar('--canvas-bg');ctx.fillRect(0,0,W,H);
|
||
ctx.strokeStyle=cssVar('--canvas-grid');ctx.lineWidth=0.5;
|
||
for(let x=0;x<W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
|
||
for(let y=0;y<H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
|
||
ctx.strokeStyle=cssVar('--canvas-center');
|
||
ctx.beginPath();ctx.moveTo(W/2,0);ctx.lineTo(W/2,H);ctx.stroke();
|
||
ctx.beginPath();ctx.moveTo(0,H/2);ctx.lineTo(W,H/2);ctx.stroke();
|
||
ctx.fillStyle=cssVar('--canvas-idle-text');ctx.font='10px Share Tech Mono,monospace';
|
||
ctx.textAlign='center';ctx.fillText('connect to activate stream',W/2,H/2+4);ctx.textAlign='left';
|
||
}
|
||
// ── 3D Orientation Viewer ─────────────────────────────────────────────────────
|
||
// Device box: L=115mm (X), W=36mm (Y), H=20mm (Z)
|
||
// Complementary filter mirrors firmware: α=0.96, dt from packet rate (~50ms)
|
||
const ORIENT_ALPHA = 0.96;
|
||
const DEVICE_L = 1.15, DEVICE_W = 0.36, DEVICE_H = 0.20; // metres (Three.js units)
|
||
|
||
let orientScene, orientCamera, orientRenderer, orientMesh, orientEdges;
|
||
let orientQ = new THREE.Quaternion(); // current estimated orientation
|
||
let orientLastT = 0;
|
||
|
||
function initOrientViewer() {
|
||
const el = document.getElementById('orientCanvas');
|
||
const W = el.clientWidth || 340, H = el.clientHeight || W;
|
||
el.width = W; el.height = H;
|
||
|
||
orientScene = new THREE.Scene();
|
||
orientCamera = new THREE.PerspectiveCamera(55, W / H, 0.01, 10);
|
||
orientCamera.position.set(0.75, 0.60, 1.10);
|
||
orientCamera.lookAt(0, 0, 0);
|
||
|
||
orientRenderer = new THREE.WebGLRenderer({ canvas: el, antialias: true, alpha: true });
|
||
orientRenderer.setSize(W, H);
|
||
orientRenderer.setClearColor(0x000000, 0);
|
||
|
||
// Box geometry
|
||
const geo = new THREE.BoxGeometry(DEVICE_L, DEVICE_H, DEVICE_W);
|
||
const mat = new THREE.MeshPhongMaterial({
|
||
color: 0x1a2230, emissive: 0x050a10, specular: 0x00e5ff,
|
||
shininess: 60, transparent: true, opacity: 0.85,
|
||
});
|
||
orientMesh = new THREE.Mesh(geo, mat);
|
||
orientScene.add(orientMesh);
|
||
|
||
// Wireframe edges
|
||
const edgeMat = new THREE.LineBasicMaterial({ color: 0x00e5ff, linewidth: 1 });
|
||
orientEdges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMat);
|
||
orientMesh.add(orientEdges);
|
||
|
||
// "Front" face marker — small arrow along +X (length axis)
|
||
const arrowGeo = new THREE.ConeGeometry(0.02, 0.07, 6);
|
||
arrowGeo.rotateZ(-Math.PI / 2);
|
||
arrowGeo.translate(DEVICE_L / 2 + 0.04, 0, 0);
|
||
const arrowMesh = new THREE.Mesh(arrowGeo,
|
||
new THREE.MeshBasicMaterial({ color: 0x00e5ff }));
|
||
orientMesh.add(arrowMesh);
|
||
|
||
// Lighting
|
||
orientScene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||
const dlight = new THREE.DirectionalLight(0xffffff, 0.9);
|
||
dlight.position.set(1, 2, 2);
|
||
orientScene.add(dlight);
|
||
|
||
orientRenderer.render(orientScene, orientCamera);
|
||
}
|
||
|
||
function orientUpdateColors() {
|
||
// Re-read CSS variables so it adapts to theme changes
|
||
const accent = cssVar('--accent').replace('#','');
|
||
const c = parseInt(accent, 16);
|
||
if (orientEdges) orientEdges.material.color.setHex(c);
|
||
}
|
||
|
||
function orientFeedIMU(ax, ay, az, gyX_mDPS, gyZ_mDPS) {
|
||
if (!orientRenderer) return;
|
||
const now = Date.now();
|
||
const dt = orientLastT ? Math.min((now - orientLastT) / 1000, 0.1) : 0.05;
|
||
orientLastT = now;
|
||
|
||
// Accel in g (packet is in mg)
|
||
const axG = ax / 1000, ayG = ay / 1000, azG = az / 1000;
|
||
const aNorm = Math.sqrt(axG*axG + ayG*ayG + azG*azG);
|
||
|
||
// Accel-derived quaternion (gravity reference). LSM6DS3 axes:
|
||
// device flat, face up: az ≈ +1g
|
||
// Pitch (tilt front up): ay changes; Roll (tilt right side up): ax changes
|
||
let qAccel = new THREE.Quaternion();
|
||
if (aNorm > 0.5 && aNorm < 2.0) {
|
||
// gravity unit vector in device frame
|
||
const gx = axG / aNorm, gy = ayG / aNorm, gz = azG / aNorm;
|
||
// Align device -Z (down face) with gravity
|
||
const up = new THREE.Vector3(0, 1, 0); // Three.js world up
|
||
const gVec = new THREE.Vector3(-gx, -gz, gy); // map device→Three axes
|
||
gVec.normalize();
|
||
qAccel.setFromUnitVectors(gVec, up);
|
||
} else {
|
||
qAccel.copy(orientQ);
|
||
}
|
||
|
||
// Gyro integration — firmware sends gyroX (pitch) and gyroZ (yaw), mDPS
|
||
// Map to Three.js axes: gyroZ→world Y, gyroX→world X
|
||
const gyRad = gyX_mDPS * (Math.PI / 180) / 1000;
|
||
const gzRad = gyZ_mDPS * (Math.PI / 180) / 1000;
|
||
const dq = new THREE.Quaternion(
|
||
gyRad * dt * 0.5, // x
|
||
-gzRad * dt * 0.5, // y
|
||
0, 1
|
||
).normalize();
|
||
const qGyro = orientQ.clone().multiply(dq);
|
||
|
||
// Complementary filter
|
||
orientQ.copy(qGyro).slerp(qAccel, 1 - ORIENT_ALPHA);
|
||
orientQ.normalize();
|
||
|
||
orientMesh.quaternion.copy(orientQ);
|
||
orientRenderer.render(orientScene, orientCamera);
|
||
}
|
||
|
||
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||
const THEMES = ['auto','dark','light'];
|
||
const THEME_LABELS = {auto:'AUTO',dark:'DARK',light:'LIGHT'};
|
||
let themeIdx = 0;
|
||
|
||
function cycleTheme() {
|
||
themeIdx = (themeIdx + 1) % 3;
|
||
applyTheme(THEMES[themeIdx]);
|
||
}
|
||
function applyTheme(t) {
|
||
document.documentElement.classList.remove('theme-dark','theme-light');
|
||
if (t === 'dark') document.documentElement.classList.add('theme-dark');
|
||
if (t === 'light') document.documentElement.classList.add('theme-light');
|
||
document.getElementById('themeBtn').querySelector('span').textContent = THEME_LABELS[t];
|
||
localStorage.setItem('theme', t);
|
||
if (!chars.imuStream) drawInitState();
|
||
orientUpdateColors();
|
||
}
|
||
(function(){
|
||
const saved = localStorage.getItem('theme') ?? 'auto';
|
||
themeIdx = Math.max(0, THEMES.indexOf(saved));
|
||
applyTheme(saved);
|
||
initOrientViewer();
|
||
// Restore advanced toggle state
|
||
document.getElementById('advancedToggle').checked = advancedMode;
|
||
if (advancedMode) toggleAdvanced(true);
|
||
})();
|
||
|
||
if (!navigator.bluetooth) {
|
||
document.getElementById('mainContent').innerHTML=`<div class="no-ble"><h2>⚠ Web Bluetooth Not Supported</h2><p>Use <strong>Chrome</strong> or <strong>Edge</strong> on desktop.<br>Linux: enable <code>chrome://flags/#enable-web-bluetooth</code></p></div>`;
|
||
} else {
|
||
log('Web Bluetooth ready. Click CONNECT to pair your IMU Mouse.','info');
|
||
}
|