pio run` +
``;
}
// ── ConfigBlob read / write ──────────────────────────────────────────────────
// ConfigBlob layout (20 bytes LE):
// float sensitivity [0], float deadZone [4], float accelStrength [8]
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14]
// uint8 tapThreshold [15], uint8 tapAction [16], uint8 tapKey [17], uint8 tapMod [18], uint8 pad [19]
async function readConfigBlob() {
if (!chars.configBlob) return;
try {
const dv = await chars.configBlob.readValue();
const view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
config.sensitivity = view.getFloat32(0, true);
config.deadZone = view.getFloat32(4, true);
config.accelStrength = view.getFloat32(8, true);
config.curve = view.getUint8(12);
config.axisFlip = view.getUint8(13);
config.chargeMode = view.getUint8(14);
if (view.byteLength >= 20) {
config.tapThreshold = view.getUint8(15);
config.tapAction = view.getUint8(16);
config.tapKey = view.getUint8(17);
config.tapMod = view.getUint8(18);
}
applyConfigToUI();
log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} tapThr=${config.tapThreshold}`,'ok');
} catch(e) { log(`Config read error: ${e.message}`,'err'); }
}
function applyConfigToUI() {
document.getElementById('slSensitivity').value = config.sensitivity;
updateDisplay('sensitivity', config.sensitivity);
document.getElementById('slDeadZone').value = config.deadZone;
updateDisplay('deadZone', config.deadZone);
document.getElementById('slAccel').value = config.accelStrength;
updateDisplay('accel', config.accelStrength);
setCurveUI(config.curve);
document.getElementById('flipX').checked = !!(config.axisFlip & 1);
document.getElementById('flipY').checked = !!(config.axisFlip & 2);
setChargeModeUI(config.chargeMode);
document.getElementById('slTapThreshold').value = config.tapThreshold;
updateDisplay('tapThreshold', config.tapThreshold);
setTapActionUI(config.tapAction);
document.getElementById('tapKeyHex').value = config.tapKey ? config.tapKey.toString(16).padStart(2,'0') : '';
document.getElementById('tapModCtrl').checked = !!(config.tapMod & 0x01);
document.getElementById('tapModShift').checked = !!(config.tapMod & 0x02);
document.getElementById('tapModAlt').checked = !!(config.tapMod & 0x04);
document.getElementById('tapModGui').checked = !!(config.tapMod & 0x08);
}
let _writeConfigTimer = null;
function writeConfigBlob() {
clearTimeout(_writeConfigTimer);
_writeConfigTimer = setTimeout(_doWriteConfigBlob, 150);
}
async function _doWriteConfigBlob() {
if (!chars.configBlob) return;
// Gather current UI values into the config shadow
config.sensitivity = +document.getElementById('slSensitivity').value;
config.deadZone = +document.getElementById('slDeadZone').value;
config.accelStrength = +document.getElementById('slAccel').value;
config.axisFlip = (document.getElementById('flipX').checked ? 1 : 0)
| (document.getElementById('flipY').checked ? 2 : 0);
config.tapThreshold = +document.getElementById('slTapThreshold').value;
config.tapMod = (document.getElementById('tapModCtrl').checked ? 0x01 : 0)
| (document.getElementById('tapModShift').checked ? 0x02 : 0)
| (document.getElementById('tapModAlt').checked ? 0x04 : 0)
| (document.getElementById('tapModGui').checked ? 0x08 : 0);
// config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly
const buf = new ArrayBuffer(20);
const view = new DataView(buf);
view.setFloat32(0, config.sensitivity, true);
view.setFloat32(4, config.deadZone, true);
view.setFloat32(8, config.accelStrength, true);
view.setUint8(12, config.curve);
view.setUint8(13, config.axisFlip);
view.setUint8(14, config.chargeMode);
view.setUint8(15, config.tapThreshold);
view.setUint8(16, config.tapAction);
view.setUint8(17, config.tapKey);
view.setUint8(18, config.tapMod);
view.setUint8(19, 0);
try {
await gattWrite(chars.configBlob, buf);
log(`Config written — sens=${config.sensitivity.toFixed(0)} tapThr=${config.tapThreshold} tapAction=${config.tapAction}`,'ok');
} catch(e) { log(`Config write failed: ${e.message}`,'err'); }
}
// ── Individual control handlers ───────────────────────────────────────────────
// These update the local config shadow then write the full blob
function setCurve(val) {
config.curve = val;
setCurveUI(val);
writeConfigBlob();
log(`Curve → ${['LINEAR','SQUARE','SQRT'][val]}`,'ok');
}
function setCurveUI(val) {
['curveLinear','curveSquare','curveSqrt'].forEach((id,i)=>
document.getElementById(id).classList.toggle('active', i===val));
}
function setChargeMode(val) {
config.chargeMode = val;
setChargeModeUI(val);
writeConfigBlob();
log(`Charge → ${['OFF','SLOW 50mA','FAST 100mA'][val]}`,'warn');
}
function setChargeModeUI(val) {
[['chgOff','off'],['chgSlow','slow'],['chgFast','fast']].forEach(([id,cls],i) => {
const b = document.getElementById(id);
b.classList.remove('active','off','slow','fast');
if (i===val) b.classList.add('active', cls);
});
document.getElementById('ciMode').textContent = ['Off (0mA)','50 mA','100 mA'][val] ?? '--';
}
function setTapAction(val) {
config.tapAction = val;
setTapActionUI(val);
writeConfigBlob();
}
function setTapActionUI(val) {
['tapActLeft','tapActRight','tapActMiddle','tapActKey'].forEach((id,i) =>
document.getElementById(id).classList.toggle('active', i===val));
document.getElementById('tapKeyRow').style.display = val===3 ? 'flex' : 'none';
}
function onTapKeyInput() {
const hex = document.getElementById('tapKeyHex').value.trim();
const code = parseInt(hex, 16);
config.tapKey = (isNaN(code) || code < 0 || code > 255) ? 0 : code;
writeConfigBlob();
}
async function sendCalibrate() {
if (!chars.command) return;
try { await gattCmd(chars.command, new Uint8Array([0x01])); log('Calibration sent — hold still!','warn'); }
catch(e) { log(`Calibrate failed: ${e.message}`,'err'); }
}
function confirmReset() { document.getElementById('overlay').classList.add('show'); }
function closeModal() { document.getElementById('overlay').classList.remove('show'); }
async function doReset() {
closeModal(); if (!chars.command) return;
try {
await gattCmd(chars.command, new Uint8Array([0xFF]));
log('Factory reset sent…','warn');
setTimeout(async () => { await readConfigBlob(); log('Config reloaded','ok'); }, 1500);
} catch(e) { log(`Reset failed: ${e.message}`,'err'); }
}
// ── Telemetry ────────────────────────────────────────────────────────────────
// TelemetryPacket (24 bytes LE):
// uint32 uptime [0], uint32 leftClicks [4], uint32 rightClicks [8]
// float temp [12], float biasRms [16]
// uint16 recalCount [20], uint8 chargeStatus [22], uint8 pad [23]
function parseTelemetry(dv) {
let view;
try {
view = dv instanceof DataView ? new DataView(dv.buffer, dv.byteOffset, dv.byteLength) : new DataView(dv);
} catch(e) { log(`parseTelemetry: DataView wrap failed — ${e.message}`,'err'); return; }
if (view.byteLength < 24) {
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const hex = Array.from(bytes).map(b=>b.toString(16).padStart(2,'0')).join(' ');
log(`TELEM: expected 24B, got ${view.byteLength}B — MTU too small? raw: ${hex}`,'err');
return;
}
let uptime, leftClicks, rightClicks, temp, biasRms, recalCount, chargeStatus;
try {
uptime = view.getUint32(0, true);
leftClicks = view.getUint32(4, true);
rightClicks = view.getUint32(8, true);
temp = view.getFloat32(12,true);
biasRms = view.getFloat32(16,true);
recalCount = view.getUint16(20, true);
chargeStatus= view.getUint8(22);
} catch(e) { log(`parseTelemetry: parse error at offset — ${e.message}`,'err'); return; }
document.getElementById('telTemp').textContent = temp.toFixed(1)+'°';
document.getElementById('telUptime').textContent = formatUptime(uptime);
document.getElementById('telLeft').textContent = leftClicks.toLocaleString();
document.getElementById('telRight').textContent = rightClicks.toLocaleString();
document.getElementById('telBias').textContent = biasRms.toFixed(4);
document.getElementById('telRecal').textContent = recalCount;
const tEl = document.getElementById('telTemp');
tEl.className = 'telem-val '+(temp>40?'warn':'accent');
// chargeStatus is now delivered via telemetry (no separate characteristic)
if (chargeStatus !== currentChargeStatus) {
currentChargeStatus = chargeStatus;
updateChargeUI();
}
}
function formatUptime(s) {
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), ss=s%60;
return h>0 ? `${h}h ${p2(m)}m` : `${m}m ${p2(ss)}s`;
}
function clearTelemetry() {
['telTemp','telUptime','telLeft','telRight','telBias','telRecal'].forEach(id=>
document.getElementById(id).textContent='--');
}
// ── Battery & Charge UI ───────────────────────────────────────────────────────
function updateBatteryBar(pct, status) {
document.getElementById('battBar').style.display='flex';
document.getElementById('battPct').textContent=pct+'%';
document.getElementById('ciPct').textContent=pct+'%';
document.getElementById('badgeCharging').classList.toggle('show', status===1);
document.getElementById('badgeFull').classList.toggle('show', status===2);
const cells=document.getElementById('battCells'); cells.innerHTML='';
const filled=Math.round(pct/10);
for (let i=0;i<10;i++) {
const c=document.createElement('div'); c.className='batt-cell';
if (iUse Chrome or Edge on desktop.
Linux: enable chrome://flags/#enable-web-bluetooth