WebUI, telemetry

This commit is contained in:
2026-03-01 00:44:19 +01:00
parent 7f37dff483
commit 4d8cacf74c
2 changed files with 1226 additions and 381 deletions
+464 -381
View File
@@ -1,32 +1,51 @@
/*
* IMU BLE Mouse — Seeed XIAO nRF52840 Sense (v2 — Full Featured)
* IMU BLE Mouse — Seeed XIAO nRF52840 Sense (v3.3)
* ================================================================
* Board BSP : Adafruit nRF52 (NOT Seeed mbed BSP)
* Board manager URL: https://adafruit.github.io/arduino-board-index/package_adafruit_index.json
* Select board: "Seeed XIAO nRF52840 Sense" (listed under Adafruit nRF52)
* Changes vs v3.2:
* - 10 characteristics → 4 (fixes SoftDevice RAM overflow)
* - configAttrTableSize reduced to 1024
* - All config params merged into one 16-byte ConfigBlob (0x1235)
* - chargeStatus merged into TelemetryPacket._pad (0x1237)
* - ImuStream (0x1238) and Command (0x1236) unchanged
* - Boot-loop detection retained
*
* Required Libraries:
* - Seeed Arduino LSM6DS3
* - Adafruit nRF52 BSP
* ── BLE Config Service (UUID 0x1234) ───────────────────────────────
* UUID | Len | R/W/N | Description
* ───────|─────|───────|──────────────────────────────────────────
* 0x1235 | 16 | R/W | ConfigBlob — all settings in one write
* 0x1236 | 1 | W | Command: 0x01=Cal 0xFF=FactoryReset
* 0x1237 | 24 | R/N | TelemetryPacket, notified 1 Hz
* 0x1238 | 14 | N | ImuPacket, notified ~100 Hz
*
* New in v2:
* 1. BLE Configuration Service — UUID 0x1234 with writable characteristics
* 2. EEPROM persistence — config saved to flash via InternalFileSystem
* 3. BLE calibration trigger — write 0x01 to CAL characteristic
* 4. Motion scaling curve select — LINEAR / SQUARE / SQRT
* 5. Factory Reset command — write 0xFF to CMD characteristic
* 6. Auto-recalibrate on idle — after AUTO_RECAL_MINUTES minutes of stillness
* 7. Axis flip flags — flip X and/or Y via BLE config
* ── ConfigBlob (16 bytes, little-endian) ────────────────────────────
* float sensitivity [0]
* float deadZone [4]
* float accelStrength [8]
* uint8_t curve [12] 0=LINEAR 1=SQUARE 2=SQRT
* uint8_t axisFlip [13] bit0=flipX bit1=flipY
* uint8_t chargeMode [14] 0=OFF 1=50mA 2=100mA
* uint8_t _pad [15]
*
* ── BLE Config Service layout (UUID 0x1234) ────────────────────
* Characteristic | UUID | Len | Description
* ──────────────────|────────|─────|──────────────────────────
* Sensitivity | 0x1235 | 4 | float, cursor speed
* Dead Zone | 0x1236 | 4 | float, noise floor rad/s
* Accel Strength | 0x1237 | 4 | float, pointer accel
* Curve Select | 0x1238 | 1 | 0=LINEAR 1=SQUARE 2=SQRT
* Axis Flip | 0x1239 | 1 | bit0=flipX bit1=flipY
* Command | 0x123A | 1 | 0x01=Calibrate 0xFF=FactoryReset
* ── TelemetryPacket (24 bytes, little-endian) ────────────────────────
* uint32_t uptimeSeconds [0]
* uint32_t leftClicks [4]
* uint32_t rightClicks [8]
* float tempCelsius [12]
* float biasRmsRadS [16]
* uint16_t recalCount [20]
* uint8_t chargeStatus [22] 0=discharging 1=charging 2=full
* uint8_t _pad [23]
*
* ── ImuPacket (14 bytes, little-endian) ─────────────────────────────
* int16_t gyroY_mDPS [0]
* int16_t gyroZ_mDPS [2]
* int16_t accelX_mg [4]
* int16_t accelY_mg [6]
* int16_t accelZ_mg [8]
* int8_t moveX [10]
* int8_t moveY [11]
* uint8_t flags [12] bit0=idle bit1=singleTap bit2=doubleTap
* uint8_t _pad [13]
*/
#include <bluefruit.h>
@@ -38,333 +57,396 @@
// ─── Debug ────────────────────────────────────────────────────────────────────
// #define DEBUG
// ─── Boot-loop detection ──────────────────────────────────────────────────────
static uint32_t __attribute__((section(".noinit"))) bootCount;
static uint32_t __attribute__((section(".noinit"))) bootMagic;
static bool safeMode = false;
static bool bootCountCleared = false;
// ─── BLE Standard Services ────────────────────────────────────────────────────
BLEDis bledis;
BLEHidAdafruit blehid;
BLEBas blebas;
// ─── BLE Config Service & Characteristics ────────────────────────────────────
BLEService cfgService(0x1234);
BLECharacteristic cfgSensitivity (0x1235);
BLECharacteristic cfgDeadZone (0x1236);
BLECharacteristic cfgAccelStr (0x1237);
BLECharacteristic cfgCurve (0x1238);
BLECharacteristic cfgAxisFlip (0x1239);
BLECharacteristic cfgCommand (0x123A);
// ─── BLE Config Service — 4 characteristics only ─────────────────────────────
BLEService cfgService (0x1234);
BLECharacteristic cfgBlob (0x1235); // ConfigBlob R/W 16 bytes
BLECharacteristic cfgCommand (0x1236); // Command W 1 byte
BLECharacteristic cfgTelemetry (0x1237); // Telemetry R/N 24 bytes 1 Hz
BLECharacteristic cfgImuStream (0x1238); // ImuStream N 14 bytes ~100 Hz
// ─── IMU ──────────────────────────────────────────────────────────────────────
LSM6DS3 imu(I2C_MODE, 0x6A);
// ─── Pin Definitions ──────────────────────────────────────────────────────────
#define LSM6DS3_CTRL1_XL 0x10
#define LSM6DS3_TAP_CFG 0x58
#define LSM6DS3_TAP_THS_6D 0x59
#define LSM6DS3_INT_DUR2 0x5A
#define LSM6DS3_WAKE_UP_THS 0x5B
#define LSM6DS3_MD1_CFG 0x5E
#define LSM6DS3_TAP_SRC 0x1C
#define LSM6DS3_OUT_TEMP_L 0x20
#define LSM6DS3_OUT_TEMP_H 0x21
// ─── Pins ─────────────────────────────────────────────────────────────────────
#define PIN_VBAT_ENABLE (14)
#define PIN_VBAT_READ (32)
#define PIN_CHG (17)
#define PIN_CHG (23)
#define PIN_HICHG (22)
// ─── EEPROM / Persistence ─────────────────────────────────────────────────────
#define CONFIG_FILENAME "/imu_mouse_cfg.bin"
#define CONFIG_MAGIC 0xDEAD1234UL
// ─── Persistence ──────────────────────────────────────────────────────────────
#define CONFIG_FILENAME "/imu_mouse_cfg.bin"
#define CONFIG_MAGIC 0xDEAD1238UL // bumped — struct layout unchanged but version tag updated
using namespace Adafruit_LittleFS_Namespace;
File cfgFile(InternalFS);
// ─── Motion Scaling Curves ────────────────────────────────────────────────────
enum CurveType : uint8_t {
CURVE_LINEAR = 0,
CURVE_SQUARE = 1,
CURVE_SQRT = 2
};
// ─── Enums ────────────────────────────────────────────────────────────────────
enum CurveType : uint8_t { CURVE_LINEAR=0, CURVE_SQUARE=1, CURVE_SQRT=2 };
enum ChargeMode : uint8_t { CHARGE_OFF=0, CHARGE_SLOW=1, CHARGE_FAST=2 };
enum ChargeStatus: uint8_t { CHGSTAT_DISCHARGING=0, CHGSTAT_CHARGING=1, CHGSTAT_FULL=2 };
// ─── Config Struct (persisted) ────────────────────────────────────────────────
// ─── Config ───────────────────────────────────────────────────────────────────
struct Config {
uint32_t magic;
float sensitivity;
float deadZone;
float accelStrength;
CurveType curve;
uint8_t axisFlip; // bit0=flipX, bit1=flipY
uint32_t magic;
float sensitivity;
float deadZone;
float accelStrength;
CurveType curve;
uint8_t axisFlip;
ChargeMode chargeMode;
};
Config cfg;
const Config CFG_DEFAULTS = { CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW };
// ─── Default Parameters ───────────────────────────────────────────────────────
const Config CFG_DEFAULTS = {
CONFIG_MAGIC,
600.0f, // sensitivity
0.060f, // dead zone
0.08f, // accel strength
CURVE_LINEAR,
0x00 // no flips
// ─── ConfigBlob (what goes over BLE — no magic field) ─────────────────────────
struct __attribute__((packed)) ConfigBlob {
float sensitivity;
float deadZone;
float accelStrength;
uint8_t curve;
uint8_t axisFlip;
uint8_t chargeMode;
uint8_t _pad;
};
static_assert(sizeof(ConfigBlob) == 16, "ConfigBlob must be 16 bytes");
// ─── Fixed Parameters ─────────────────────────────────────────────────────────
const float ALPHA = 0.96f;
const int LOOP_RATE_MS = 10;
const int BIAS_SAMPLES = 200;
const int IDLE_FRAMES = 150;
// ─── TelemetryPacket ──────────────────────────────────────────────────────────
struct __attribute__((packed)) TelemetryPacket {
uint32_t uptimeSeconds;
uint32_t leftClicks;
uint32_t rightClicks;
float tempCelsius;
float biasRmsRadS;
uint16_t recalCount;
uint8_t chargeStatus; // replaces old _pad — no extra characteristic needed
uint8_t _pad;
};
static_assert(sizeof(TelemetryPacket) == 24, "TelemetryPacket must be 24 bytes");
// Auto-recalibrate: recalibrate after this many minutes of continuous idle
const unsigned long AUTO_RECAL_MINUTES = 5;
const unsigned long AUTO_RECAL_MS = AUTO_RECAL_MINUTES * 60UL * 1000UL;
// ─── ImuPacket ────────────────────────────────────────────────────────────────
struct __attribute__((packed)) ImuPacket {
int16_t gyroY_mDPS;
int16_t gyroZ_mDPS;
int16_t accelX_mg;
int16_t accelY_mg;
int16_t accelZ_mg;
int8_t moveX;
int8_t moveY;
uint8_t flags;
uint8_t _pad;
};
static_assert(sizeof(ImuPacket) == 14, "ImuPacket must be 14 bytes");
const unsigned long BATT_REPORT_MS = 10000;
const unsigned long HEARTBEAT_MS = 2000;
const int HEARTBEAT_DUR = 30;
const float BATT_FULL = 4.20f;
const float BATT_EMPTY = 3.00f;
const float BATT_CRITICAL = 3.10f;
// ─── Tuning constants ─────────────────────────────────────────────────────────
const float ALPHA = 0.96f;
const int LOOP_RATE_MS = 10;
const int BIAS_SAMPLES = 200;
const int IDLE_FRAMES = 150;
const float TEMP_COMP_COEFF_DPS_C = 0.004f;
const unsigned long AUTO_RECAL_MS = 5UL * 60UL * 1000UL;
const unsigned long BATT_REPORT_MS = 10000;
const unsigned long TELEMETRY_MS = 1000;
const unsigned long HEARTBEAT_MS = 2000;
const int HEARTBEAT_DUR = 30;
const unsigned long CLICK_HOLD_MS = 40;
const unsigned long DOUBLE_TAP_WINDOW_MS = 400;
const unsigned long BOOT_SAFE_MS = 5000;
const float BATT_FULL = 4.20f;
const float BATT_EMPTY = 3.00f;
const float BATT_CRITICAL = 3.10f;
// ─── State ────────────────────────────────────────────────────────────────────
float angleX = 0.0f, angleY = 0.0f;
float accumX = 0.0f, accumY = 0.0f;
float biasGX = 0.0f, biasGY = 0.0f, biasGZ = 0.0f;
int idleFrames = 0;
bool pendingCal = false; // set by BLE write callback
bool pendingReset = false; // set by BLE write callback
float angleX = 0, angleY = 0;
float accumX = 0, accumY = 0;
float biasGX = 0, biasGY = 0, biasGZ = 0;
float calTempC = 25.0f;
float cachedTempC = 25.0f;
unsigned long lastTime = 0;
unsigned long lastBattTime = 0;
unsigned long lastHeartbeat = 0;
unsigned long idleStartMs = 0; // when continuous idle began (0 = not idle)
TelemetryPacket telem = {};
// ─── EEPROM Helpers ───────────────────────────────────────────────────────────
bool imuStreamEnabled = false;
bool tapPending = false;
bool clickButtonDown = false;
uint8_t clickButton = 0;
unsigned long tapSeenMs = 0;
unsigned long clickDownMs = 0;
bool pendingCal = false;
bool pendingReset = false;
int idleFrames = 0;
unsigned long idleStartMs = 0;
unsigned long lastTime = 0;
unsigned long lastBattTime = 0;
unsigned long lastHeartbeat = 0;
unsigned long lastTelemetry = 0;
unsigned long bootStartMs = 0;
// ─── I2C helpers ──────────────────────────────────────────────────────────────
void imuWriteReg(uint8_t reg, uint8_t val) {
Wire.beginTransmission(0x6A); Wire.write(reg); Wire.write(val); Wire.endTransmission();
}
uint8_t imuReadReg(uint8_t reg) {
Wire.beginTransmission(0x6A); Wire.write(reg); Wire.endTransmission(false);
Wire.requestFrom((uint8_t)0x6A, (uint8_t)1);
return Wire.available() ? Wire.read() : 0;
}
// ─── Temperature ──────────────────────────────────────────────────────────────
float readIMUTemp() {
int16_t raw = (int16_t)((imuReadReg(LSM6DS3_OUT_TEMP_H) << 8) | imuReadReg(LSM6DS3_OUT_TEMP_L));
return 25.0f + (float)raw / 256.0f;
}
// ─── Tap detection ────────────────────────────────────────────────────────────
void setupTapDetection() {
imuWriteReg(LSM6DS3_CTRL1_XL, 0x60);
imuWriteReg(LSM6DS3_TAP_CFG, 0x8E);
imuWriteReg(LSM6DS3_TAP_THS_6D, 0x08);
imuWriteReg(LSM6DS3_INT_DUR2, 0x77);
imuWriteReg(LSM6DS3_WAKE_UP_THS, 0x80);
imuWriteReg(LSM6DS3_MD1_CFG, 0x48);
Serial.println("[TAP] Engine configured — single=LEFT, double=RIGHT");
}
// ─── Charge mode ──────────────────────────────────────────────────────────────
void applyChargeMode(ChargeMode mode) {
switch (mode) {
case CHARGE_OFF: pinMode(PIN_HICHG, INPUT_PULLUP); break;
case CHARGE_SLOW: pinMode(PIN_HICHG, OUTPUT); digitalWrite(PIN_HICHG, HIGH); break;
case CHARGE_FAST: pinMode(PIN_HICHG, OUTPUT); digitalWrite(PIN_HICHG, LOW); break;
}
const char* n[] = {"OFF (~0mA)", "SLOW (50mA)", "FAST (100mA)"};
Serial.print("[CHG] "); Serial.println(n[mode]);
}
// ─── Config persistence ───────────────────────────────────────────────────────
void loadConfig() {
InternalFS.begin();
cfgFile.open(CONFIG_FILENAME, FILE_O_READ);
if (cfgFile) {
cfgFile.read(&cfg, sizeof(cfg));
cfgFile.close();
if (cfg.magic != CONFIG_MAGIC) {
Serial.println("[CFG] Bad magic — using defaults");
cfg = CFG_DEFAULTS;
} else {
Serial.println("[CFG] Loaded from flash");
}
} else {
Serial.println("[CFG] No file — using defaults");
cfg = CFG_DEFAULTS;
}
cfgFile.read(&cfg, sizeof(cfg)); cfgFile.close();
if (cfg.magic != CONFIG_MAGIC) { cfg = CFG_DEFAULTS; Serial.println("[CFG] Defaults (bad magic)"); }
else { Serial.println("[CFG] Loaded from flash"); }
} else { cfg = CFG_DEFAULTS; Serial.println("[CFG] Defaults (no file)"); }
}
void saveConfig() {
InternalFS.remove(CONFIG_FILENAME);
cfgFile.open(CONFIG_FILENAME, FILE_O_WRITE);
if (cfgFile) {
cfgFile.write((uint8_t*)&cfg, sizeof(cfg));
cfgFile.close();
Serial.println("[CFG] Saved to flash");
} else {
Serial.println("[CFG] ERROR: could not open file for write");
}
if (cfgFile) { cfgFile.write((uint8_t*)&cfg, sizeof(cfg)); cfgFile.close(); Serial.println("[CFG] Saved"); }
else { Serial.println("[CFG] ERROR: write failed"); }
}
// Push current config as a ConfigBlob to the BLE characteristic
void pushConfigBlob() {
ConfigBlob blob;
blob.sensitivity = cfg.sensitivity;
blob.deadZone = cfg.deadZone;
blob.accelStrength = cfg.accelStrength;
blob.curve = (uint8_t)cfg.curve;
blob.axisFlip = cfg.axisFlip;
blob.chargeMode = (uint8_t)cfg.chargeMode;
blob._pad = 0;
cfgBlob.write((uint8_t*)&blob, sizeof(blob));
}
void factoryReset() {
Serial.println("[CFG] Factory reset!");
cfg = CFG_DEFAULTS;
cfg = CFG_DEFAULTS; saveConfig();
applyChargeMode(cfg.chargeMode);
if (!safeMode) pushConfigBlob();
telem = {};
Serial.println("[CFG] Factory reset complete");
}
// ─── BLE callbacks ────────────────────────────────────────────────────────────
// Single callback handles the whole config blob
void onConfigBlobWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) {
if (l != sizeof(ConfigBlob)) { Serial.println("[CFG] Bad blob length"); return; }
ConfigBlob* b = (ConfigBlob*)d;
cfg.sensitivity = b->sensitivity;
cfg.deadZone = b->deadZone;
cfg.accelStrength = b->accelStrength;
if (b->curve <= 2) cfg.curve = (CurveType)b->curve;
cfg.axisFlip = b->axisFlip;
if (b->chargeMode <= 2) {
cfg.chargeMode = (ChargeMode)b->chargeMode;
applyChargeMode(cfg.chargeMode);
}
saveConfig();
// Push defaults back to BLE characteristics
cfgSensitivity.write((uint8_t*)&cfg.sensitivity, 4);
cfgDeadZone.write ((uint8_t*)&cfg.deadZone, 4);
cfgAccelStr.write ((uint8_t*)&cfg.accelStrength, 4);
cfgCurve.write ((uint8_t*)&cfg.curve, 1);
cfgAxisFlip.write ((uint8_t*)&cfg.axisFlip, 1);
Serial.print("[CFG] Blob written — sens="); Serial.print(cfg.sensitivity);
Serial.print(" dz="); Serial.print(cfg.deadZone, 3);
Serial.print(" curve="); Serial.print(cfg.curve);
Serial.print(" chg="); Serial.println(cfg.chargeMode);
}
// ─── BLE Write Callbacks ──────────────────────────────────────────────────────
void onSensitivityWrite(uint16_t conn_hdl, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (len == 4) { memcpy(&cfg.sensitivity, data, 4); saveConfig(); }
}
void onDeadZoneWrite(uint16_t conn_hdl, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (len == 4) { memcpy(&cfg.deadZone, data, 4); saveConfig(); }
}
void onAccelStrWrite(uint16_t conn_hdl, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (len == 4) { memcpy(&cfg.accelStrength, data, 4); saveConfig(); }
}
void onCurveWrite(uint16_t conn_hdl, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (len == 1 && data[0] <= 2) {
cfg.curve = (CurveType)data[0];
saveConfig();
Serial.print("[CFG] Curve -> "); Serial.println(cfg.curve);
}
}
void onAxisFlipWrite(uint16_t conn_hdl, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (len == 1) {
cfg.axisFlip = data[0];
saveConfig();
Serial.print("[CFG] AxisFlip -> 0x"); Serial.println(cfg.axisFlip, HEX);
}
}
void onCommandWrite(uint16_t conn_hdl, BLECharacteristic* chr,
uint8_t* data, uint16_t len) {
if (len < 1) return;
if (data[0] == 0x01) {
pendingCal = true;
Serial.println("[CMD] Calibration requested via BLE");
} else if (data[0] == 0xFF) {
pendingReset = true;
Serial.println("[CMD] Factory reset requested via BLE");
}
void onCommandWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) {
if (l < 1) return;
if (d[0] == 0x01) pendingCal = true;
if (d[0] == 0xFF) pendingReset = true;
}
// ─── BLE Config Service Setup ─────────────────────────────────────────────────
void onImuStreamCccd(uint16_t conn_hdl, BLECharacteristic* chr, uint16_t value) {
imuStreamEnabled = (value == BLE_GATT_HVX_NOTIFICATION);
Serial.print("[STREAM] "); Serial.println(imuStreamEnabled ? "ON" : "OFF");
}
// ─── BLE service setup ────────────────────────────────────────────────────────
void setupConfigService() {
cfgService.begin();
// Each characteristic: READ | WRITE, no response needed for writes
auto props = CHR_PROPS_READ | CHR_PROPS_WRITE;
cfgSensitivity.setProperties(props);
cfgSensitivity.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgSensitivity.setFixedLen(4);
cfgSensitivity.setWriteCallback(onSensitivityWrite);
cfgSensitivity.begin();
cfgSensitivity.write((uint8_t*)&cfg.sensitivity, 4);
cfgDeadZone.setProperties(props);
cfgDeadZone.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgDeadZone.setFixedLen(4);
cfgDeadZone.setWriteCallback(onDeadZoneWrite);
cfgDeadZone.begin();
cfgDeadZone.write((uint8_t*)&cfg.deadZone, 4);
cfgAccelStr.setProperties(props);
cfgAccelStr.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgAccelStr.setFixedLen(4);
cfgAccelStr.setWriteCallback(onAccelStrWrite);
cfgAccelStr.begin();
cfgAccelStr.write((uint8_t*)&cfg.accelStrength, 4);
cfgCurve.setProperties(props);
cfgCurve.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgCurve.setFixedLen(1);
cfgCurve.setWriteCallback(onCurveWrite);
cfgCurve.begin();
cfgCurve.write((uint8_t*)&cfg.curve, 1);
cfgAxisFlip.setProperties(props);
cfgAxisFlip.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgAxisFlip.setFixedLen(1);
cfgAxisFlip.setWriteCallback(onAxisFlipWrite);
cfgAxisFlip.begin();
cfgAxisFlip.write((uint8_t*)&cfg.axisFlip, 1);
// ConfigBlob — R/W 16 bytes
cfgBlob.setProperties(CHR_PROPS_READ | CHR_PROPS_WRITE);
cfgBlob.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgBlob.setFixedLen(sizeof(ConfigBlob));
cfgBlob.setWriteCallback(onConfigBlobWrite);
cfgBlob.begin();
pushConfigBlob();
// Command — W 1 byte
cfgCommand.setProperties(CHR_PROPS_WRITE);
cfgCommand.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgCommand.setFixedLen(1);
cfgCommand.setWriteCallback(onCommandWrite);
cfgCommand.begin();
// Telemetry — R/N 24 bytes
cfgTelemetry.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY);
cfgTelemetry.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS);
cfgTelemetry.setFixedLen(sizeof(TelemetryPacket));
cfgTelemetry.begin();
cfgTelemetry.write((uint8_t*)&telem, sizeof(telem));
// ImuStream — N 14 bytes
cfgImuStream.setProperties(CHR_PROPS_NOTIFY);
cfgImuStream.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS);
cfgImuStream.setFixedLen(sizeof(ImuPacket));
cfgImuStream.setCccdWriteCallback(onImuStreamCccd);
cfgImuStream.begin();
}
// ─── Battery ──────────────────────────────────────────────────────────────────
float readBatteryVoltage() {
pinMode(PIN_VBAT_ENABLE, OUTPUT);
digitalWrite(PIN_VBAT_ENABLE, LOW);
delay(1);
pinMode(PIN_VBAT_ENABLE, OUTPUT); digitalWrite(PIN_VBAT_ENABLE, LOW); delay(1);
pinMode(PIN_VBAT_READ, INPUT);
analogReference(AR_INTERNAL_3_0);
analogReadResolution(12);
for (int i = 0; i < 5; i++) { analogRead(PIN_VBAT_READ); delay(1); }
int32_t raw = 0;
for (int i = 0; i < 16; i++) raw += analogRead(PIN_VBAT_READ);
raw /= 16;
analogReference(AR_INTERNAL_3_0); analogReadResolution(12);
for (int i=0; i<5; i++) { analogRead(PIN_VBAT_READ); delay(1); }
int32_t raw=0; for (int i=0; i<16; i++) raw += analogRead(PIN_VBAT_READ); raw /= 16;
digitalWrite(PIN_VBAT_ENABLE, HIGH);
analogReference(AR_DEFAULT);
analogReadResolution(10);
float v = (raw / 4096.0f) * 3.0f * 2.0f;
Serial.print("[BATT DBG] raw="); Serial.print(raw);
Serial.print(" ("); Serial.print(v, 3); Serial.print("V)");
Serial.print(" CHG pin="); Serial.println(digitalRead(PIN_CHG));
return v;
}
int batteryPercent(float v) {
return (int) constrain((v - BATT_EMPTY) / (BATT_FULL - BATT_EMPTY) * 100.0f, 0, 100);
analogReference(AR_DEFAULT); analogReadResolution(10);
return (raw / 4096.0f) * 3.0f * 2.0f;
}
int batteryPercent(float v) { return (int)constrain((v - BATT_EMPTY) / (BATT_FULL - BATT_EMPTY) * 100.f, 0, 100); }
void updateBattery() {
float v = readBatteryVoltage();
int pct = batteryPercent(v);
bool charging = (digitalRead(PIN_CHG) == LOW);
float v = readBatteryVoltage(); int pct = batteryPercent(v);
bool chg = (digitalRead(PIN_CHG) == LOW);
ChargeStatus status = chg ? (pct >= 99 ? CHGSTAT_FULL : CHGSTAT_CHARGING) : CHGSTAT_DISCHARGING;
blebas.write(pct);
Serial.print("[BATT] ");
Serial.print(v, 2); Serial.print("V ");
Serial.print(pct); Serial.print("%");
if (charging) Serial.print(" [CHARGING]");
else if (pct >= 99) Serial.print(" [FULL]");
else if (v < BATT_CRITICAL) Serial.print(" [CRITICAL - CHARGE NOW]");
else Serial.print(" [ON BATTERY]");
Serial.println();
if (!charging && v < BATT_CRITICAL) {
pinMode(LED_RED, OUTPUT);
for (int i = 0; i < 6; i++) {
digitalWrite(LED_RED, LOW); delay(80);
digitalWrite(LED_RED, HIGH); delay(80);
}
}
// chargeStatus is now pushed via telemetry packet — no separate characteristic
telem.chargeStatus = (uint8_t)status;
const char* st[] = {"discharging","charging","full"};
Serial.print("[BATT] "); Serial.print(v,2); Serial.print("V ");
Serial.print(pct); Serial.print("% "); Serial.println(st[status]);
if (status == CHGSTAT_DISCHARGING && v < BATT_CRITICAL)
for (int i=0; i<6; i++) { digitalWrite(LED_RED,LOW); delay(80); digitalWrite(LED_RED,HIGH); delay(80); }
}
// ─── Gyro Calibration ─────────────────────────────────────────────────────────
// ─── Calibration ─────────────────────────────────────────────────────────────
void calibrateGyroBias() {
Serial.println("[CAL] Hold still — calibrating gyro bias...");
pinMode(LED_BLUE, OUTPUT);
double sumX = 0, sumY = 0, sumZ = 0;
for (int i = 0; i < BIAS_SAMPLES; i++) {
sumX += imu.readFloatGyroX();
sumY += imu.readFloatGyroY();
sumZ += imu.readFloatGyroZ();
digitalWrite(LED_BLUE, (i % 20 < 10));
delay(5);
Serial.println("[CAL] Hold still...");
double sx=0, sy=0, sz=0;
for (int i=0; i<BIAS_SAMPLES; i++) {
sx += imu.readFloatGyroX(); sy += imu.readFloatGyroY(); sz += imu.readFloatGyroZ();
digitalWrite(LED_BLUE, (i%20<10)); delay(5);
}
biasGX = (float)(sumX / BIAS_SAMPLES);
biasGY = (float)(sumY / BIAS_SAMPLES);
biasGZ = (float)(sumZ / BIAS_SAMPLES);
// Reset angle state to avoid a jump after recal
angleX = 0.0f;
angleY = 0.0f;
accumX = 0.0f;
accumY = 0.0f;
biasGX = (float)(sx/BIAS_SAMPLES); biasGY = (float)(sy/BIAS_SAMPLES); biasGZ = (float)(sz/BIAS_SAMPLES);
calTempC = readIMUTemp();
angleX = angleY = accumX = accumY = 0.0f;
telem.recalCount++;
float bxr = biasGX*(PI/180.f), byr = biasGY*(PI/180.f), bzr = biasGZ*(PI/180.f);
telem.biasRmsRadS = sqrtf((bxr*bxr + byr*byr + bzr*bzr) / 3.0f);
digitalWrite(LED_BLUE, HIGH);
Serial.print("[CAL] Done. Bias — gx:"); Serial.print(biasGX, 4);
Serial.print(" gy:"); Serial.print(biasGY, 4);
Serial.print(" gz:"); Serial.println(biasGZ, 4);
}
// ─── Motion Scaling ───────────────────────────────────────────────────────────
float applyAcceleration(float delta) {
// Pointer acceleration on top of curve
return delta * (1.0f + fabsf(delta) * cfg.accelStrength);
Serial.print("[CAL] T="); Serial.print(calTempC,1);
Serial.print("C bias="); Serial.print(biasGX,4);
Serial.print(","); Serial.print(biasGY,4);
Serial.print(","); Serial.println(biasGZ,4);
}
// ─── Motion curve ─────────────────────────────────────────────────────────────
float applyCurve(float v) {
switch (cfg.curve) {
case CURVE_SQUARE:
return (v >= 0.0f ? 1.0f : -1.0f) * v * v;
case CURVE_SQRT:
return (v >= 0.0f ? 1.0f : -1.0f) * sqrtf(fabsf(v));
case CURVE_LINEAR:
default:
return v;
case CURVE_SQUARE: return (v>=0 ? 1.f : -1.f) * v * v;
case CURVE_SQRT: return (v>=0 ? 1.f : -1.f) * sqrtf(fabsf(v));
default: return v;
}
}
float applyAcceleration(float d) { return d * (1.0f + fabsf(d) * cfg.accelStrength); }
// ─── BLE Advertising ──────────────────────────────────────────────────────────
// ─── Tap state machine ────────────────────────────────────────────────────────
void processTaps(unsigned long now) {
if (clickButtonDown && (now - clickDownMs >= CLICK_HOLD_MS)) {
blehid.mouseButtonPress(clickButton, false);
clickButtonDown = false; clickButton = 0;
}
uint8_t tapSrc = imuReadReg(LSM6DS3_TAP_SRC);
bool singleTap = (tapSrc & 0x20) != 0;
bool doubleTap = (tapSrc & 0x10) != 0;
bool tapEvent = (tapSrc & 0x40) != 0;
if (!tapEvent) {
if (tapPending && (now - tapSeenMs >= DOUBLE_TAP_WINDOW_MS)) {
tapPending = false;
if (!clickButtonDown) {
Serial.println("[TAP] Single → LEFT");
blehid.mouseButtonPress(MOUSE_BUTTON_LEFT, true);
clickButton = MOUSE_BUTTON_LEFT; clickButtonDown = true; clickDownMs = now;
telem.leftClicks++;
}
}
return;
}
if (doubleTap && !clickButtonDown) {
tapPending = false;
Serial.println("[TAP] Double → RIGHT");
blehid.mouseButtonPress(MOUSE_BUTTON_RIGHT, true);
clickButton = MOUSE_BUTTON_RIGHT; clickButtonDown = true; clickDownMs = now;
telem.rightClicks++;
return;
}
if (singleTap && !tapPending && !clickButtonDown) { tapPending = true; tapSeenMs = now; }
}
// ─── Telemetry ────────────────────────────────────────────────────────────────
void pushTelemetry(unsigned long now) {
telem.uptimeSeconds = now / 1000;
telem.tempCelsius = cachedTempC;
// telem.chargeStatus is updated in updateBattery()
cfgTelemetry.write ((uint8_t*)&telem, sizeof(telem));
cfgTelemetry.notify((uint8_t*)&telem, sizeof(telem));
}
// ─── Advertising ─────────────────────────────────────────────────────────────
void startAdvertising() {
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
Bluefruit.Advertising.addTxPower();
@@ -378,169 +460,170 @@ void startAdvertising() {
Bluefruit.Advertising.start(0);
}
// ─────────────────────────────────────────────────────────────────────────────
// ─── Setup ────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
while (!Serial) delay(10);
unsigned long serialWait = millis();
while (!Serial && (millis() - serialWait < 2000)) { delay(10); }
pinMode(PIN_CHG, INPUT_PULLUP);
pinMode(LED_RED, OUTPUT); digitalWrite(LED_RED, HIGH);
pinMode(LED_BLUE, OUTPUT); digitalWrite(LED_BLUE, HIGH);
// ── Load persisted config ─────────────────────────────────────────────────
loadConfig();
// ── IMU ───────────────────────────────────────────────────────────────────
if (imu.begin() != 0) {
Serial.println("[ERROR] IMU init failed.");
while (1) { digitalWrite(LED_RED, !digitalRead(LED_RED)); delay(100); }
// ── Boot-loop detection ───────────────────────────────────────────────────
if (bootMagic != 0xCAFEBABE) { bootMagic = 0xCAFEBABE; bootCount = 0; }
bootCount++;
Serial.print("[BOOT] count="); Serial.println(bootCount);
if (bootCount >= 3) {
bootCount = 0; safeMode = true;
Serial.println("[BOOT] Boot loop detected — safe mode");
InternalFS.begin(); InternalFS.remove(CONFIG_FILENAME);
for (int i=0; i<3; i++) { digitalWrite(LED_RED,LOW); delay(150); digitalWrite(LED_RED,HIGH); delay(150); }
}
Serial.println("[OK] IMU initialised");
loadConfig();
applyChargeMode(cfg.chargeMode);
// 1024 is sufficient for 4 characteristics (was 3072/2048 — both overflowed)
Bluefruit.configAttrTableSize(1024);
Bluefruit.begin(1, 0);
Bluefruit.setTxPower(4);
Bluefruit.setName(safeMode ? "IMU Mouse (safe)" : "IMU Mouse");
Bluefruit.Periph.setConnInterval(6, 12);
if (imu.begin() != 0) {
Serial.println("[ERROR] IMU init failed");
while(1) { digitalWrite(LED_RED, !digitalRead(LED_RED)); delay(100); }
}
Serial.println("[OK] IMU ready");
setupTapDetection();
cachedTempC = readIMUTemp();
updateBattery();
calibrateGyroBias();
// ── BLE ───────────────────────────────────────────────────────────────────
Bluefruit.begin();
Bluefruit.setTxPower(4);
Bluefruit.setName("IMU Mouse");
Bluefruit.Periph.setConnInterval(6, 12);
bledis.setManufacturer("Seeed Studio");
bledis.setModel("XIAO nRF52840 Sense");
bledis.begin();
blehid.begin();
blebas.begin();
blebas.write(100);
blebas.begin(); blebas.write(100);
// Config service must begin AFTER Bluefruit.begin()
setupConfigService();
if (!safeMode) {
setupConfigService();
Serial.println("[OK] Config service started (4 characteristics)");
} else {
Serial.println("[SAFE] Config service skipped — basic mouse only");
}
startAdvertising();
Serial.println("[OK] BLE advertising — pair 'IMU Mouse' on your host");
Serial.println(" Config service UUID 0x1234 available for tuning");
Serial.print("[OK] Advertising as '");
Serial.print(safeMode ? "IMU Mouse (safe)" : "IMU Mouse");
Serial.println("'");
lastTime = millis();
lastBattTime = millis();
lastHeartbeat = millis();
idleStartMs = 0;
bootStartMs = millis();
lastTime = lastBattTime = lastHeartbeat = lastTelemetry = millis();
}
// ─────────────────────────────────────────────────────────────────────────────
// ─── Loop ─────────────────────────────────────────────────────────────────────
void loop() {
unsigned long now = millis();
// ── Deferred commands (from BLE callbacks, safe to run on main thread) ────
if (pendingCal) {
pendingCal = false;
calibrateGyroBias();
}
if (pendingReset) {
pendingReset = false;
factoryReset();
if (!bootCountCleared && (now - bootStartMs >= BOOT_SAFE_MS)) {
bootCount = 0; bootCountCleared = true;
Serial.println("[BOOT] Stable — boot counter cleared");
}
// ── Heartbeat LED ─────────────────────────────────────────────────────────
if (pendingCal) { pendingCal = false; calibrateGyroBias(); }
if (pendingReset) { pendingReset = false; factoryReset(); }
// Heartbeat LED
if (now - lastHeartbeat >= HEARTBEAT_MS) {
lastHeartbeat = now;
int led = Bluefruit.connected() ? LED_BLUE : LED_RED;
digitalWrite(led, LOW);
delay(HEARTBEAT_DUR);
digitalWrite(led, HIGH);
digitalWrite(led, LOW); delay(HEARTBEAT_DUR); digitalWrite(led, HIGH);
}
// ── Battery ───────────────────────────────────────────────────────────────
if (now - lastBattTime >= BATT_REPORT_MS) {
lastBattTime = now;
updateBattery();
}
if (now - lastBattTime >= BATT_REPORT_MS) { lastBattTime = now; updateBattery(); }
processTaps(now);
// ── IMU rate limit ────────────────────────────────────────────────────────
if (now - lastTime < (unsigned long)LOOP_RATE_MS) return;
float dt = (now - lastTime) / 1000.0f;
lastTime = now;
if (dt <= 0.0f || dt > 0.5f) return;
// ── Read IMU ──────────────────────────────────────────────────────────────
float gx = (imu.readFloatGyroX() - biasGX) * (PI / 180.0f);
float gy = (imu.readFloatGyroY() - biasGY) * (PI / 180.0f);
float gz = (imu.readFloatGyroZ() - biasGZ) * (PI / 180.0f);
cachedTempC = readIMUTemp();
if (!safeMode && (now - lastTelemetry >= TELEMETRY_MS)) {
lastTelemetry = now; pushTelemetry(now);
}
float tempDelta = cachedTempC - calTempC;
float correction = TEMP_COMP_COEFF_DPS_C * tempDelta;
float gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f);
float gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f);
float gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f);
float ax = imu.readFloatAccelX();
float ay = imu.readFloatAccelY();
float az = imu.readFloatAccelZ();
// ── Complementary filter ──────────────────────────────────────────────────
angleX = ALPHA * (angleX + gx * dt) + (1.0f - ALPHA) * atan2f(ax, sqrtf(ay*ay + az*az));
angleY = ALPHA * (angleY + gy * dt) + (1.0f - ALPHA) * atan2f(ay, sqrtf(ax*ax + az*az));
angleX = ALPHA*(angleX + gx*dt) + (1.0f - ALPHA)*atan2f(ax, sqrtf(ay*ay + az*az));
angleY = ALPHA*(angleY + gy*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az));
// ── Dead zone ─────────────────────────────────────────────────────────────
float filteredGy = (fabsf(gy) > cfg.deadZone) ? gy : 0.0f;
float filteredGz = (fabsf(gz) > cfg.deadZone) ? gz : 0.0f;
// ── Idle detection + auto-recalibrate ─────────────────────────────────────
bool moving = (filteredGy != 0.0f || filteredGz != 0.0f);
if (moving) {
idleFrames = 0;
idleStartMs = 0;
} else {
idleFrames++;
if (idleStartMs == 0) idleStartMs = now; // mark start of idle streak
}
float fGy = (fabsf(gy) > cfg.deadZone) ? gy : 0.0f;
float fGz = (fabsf(gz) > cfg.deadZone) ? gz : 0.0f;
bool moving = (fGy != 0.0f || fGz != 0.0f);
if (moving) { idleFrames = 0; idleStartMs = 0; }
else { idleFrames++; if (idleStartMs == 0) idleStartMs = now; }
bool idle = (idleFrames >= IDLE_FRAMES);
// Auto-recalibrate after AUTO_RECAL_MS of continuous stillness
if (idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) {
Serial.println("[AUTO-CAL] Long idle detected — recalibrating...");
idleStartMs = 0; // reset so we don't retrigger immediately
calibrateGyroBias();
return;
Serial.println("[AUTO-CAL] Long idle — recalibrating...");
idleStartMs = 0; calibrateGyroBias(); return;
}
int8_t moveX = 0, moveY = 0;
uint8_t flags = 0;
if (idle) {
accumX = 0.0f;
accumY = 0.0f;
#ifdef DEBUG
Serial.println("[IDLE]");
#endif
return;
accumX = accumY = 0.0f;
flags |= 0x01;
} else {
float rawX = applyAcceleration(applyCurve(-fGz * cfg.sensitivity * dt));
float rawY = applyAcceleration(applyCurve( fGy * cfg.sensitivity * dt));
if (cfg.axisFlip & 0x01) rawX = -rawX;
if (cfg.axisFlip & 0x02) rawY = -rawY;
accumX += rawX; accumY += rawY;
moveX = (int8_t)constrain((int)accumX, -127, 127);
moveY = (int8_t)constrain((int)accumY, -127, 127);
accumX -= moveX; accumY -= moveY;
if (Bluefruit.connected() && (moveX != 0 || moveY != 0)) blehid.mouseMove(moveX, moveY);
}
// ── Delta + curve + acceleration + sub-pixel accumulation ─────────────────
float rawX = -filteredGz * cfg.sensitivity * dt;
float rawY = filteredGy * cfg.sensitivity * dt;
rawX = applyCurve(rawX);
rawY = applyCurve(rawY);
rawX = applyAcceleration(rawX);
rawY = applyAcceleration(rawY);
// ── Axis flip ─────────────────────────────────────────────────────────────
if (cfg.axisFlip & 0x01) rawX = -rawX; // flip X
if (cfg.axisFlip & 0x02) rawY = -rawY; // flip Y
accumX += rawX;
accumY += rawY;
int8_t moveX = (int8_t) constrain((int)accumX, -127, 127);
int8_t moveY = (int8_t) constrain((int)accumY, -127, 127);
accumX -= moveX;
accumY -= moveY;
// ── BLE HID ───────────────────────────────────────────────────────────────
if (Bluefruit.connected() && (moveX != 0 || moveY != 0)) {
blehid.mouseMove(moveX, moveY);
if (!safeMode && imuStreamEnabled && Bluefruit.connected()) {
ImuPacket pkt;
pkt.gyroY_mDPS = (int16_t)constrain(gy*(180.f/PI)*1000.f, -32000, 32000);
pkt.gyroZ_mDPS = (int16_t)constrain(gz*(180.f/PI)*1000.f, -32000, 32000);
pkt.accelX_mg = (int16_t)constrain(ax*1000.f, -32000, 32000);
pkt.accelY_mg = (int16_t)constrain(ay*1000.f, -32000, 32000);
pkt.accelZ_mg = (int16_t)constrain(az*1000.f, -32000, 32000);
pkt.moveX = moveX;
pkt.moveY = moveY;
pkt.flags = flags;
pkt._pad = 0;
cfgImuStream.notify((uint8_t*)&pkt, sizeof(pkt));
}
#ifdef DEBUG
Serial.print("gy:"); Serial.print(gy, 3);
Serial.print(" gz:"); Serial.print(gz, 3);
Serial.print(" | mx:"); Serial.print(moveX);
Serial.print(" my:"); Serial.println(moveY);
Serial.print("T="); Serial.print(cachedTempC,1);
Serial.print(" gy="); Serial.print(gy,3);
Serial.print(" gz="); Serial.print(gz,3);
Serial.print(" mx="); Serial.print(moveX);
Serial.print(" my="); Serial.println(moveY);
#endif
}
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IMU Mouse // Config Terminal</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700;900&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0c0e;
--panel: #111417;
--panel2: #0d1013;
--border: #1f2428;
--accent: #00e5ff;
--accent2: #ff3d71;
--warn: #ffaa00;
--ok: #00e096;
--dim: #3a4050;
--text: #c8d0dc;
--label: #5a6480;
--mono: 'Share Tech Mono', monospace;
--sans: 'Barlow Condensed', sans-serif;
}
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--text); font-family:var(--mono); min-height:100vh; overflow-x:hidden; }
body::before { content:''; position:fixed; inset:0; pointer-events:none; z-index:9999;
background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.07) 2px,rgba(0,0,0,0.07) 4px); }
header { border-bottom:1px solid var(--border); padding:16px 28px; display:flex; align-items:center; gap:20px; position:sticky; top:0; background:rgba(10,12,14,0.96); backdrop-filter:blur(10px); z-index:100; }
.logo { font-family:var(--sans); font-weight:900; font-size:22px; letter-spacing:0.08em; color:#fff; text-transform:uppercase; line-height:1; }
.logo span { color:var(--accent); }
.logo-sub { font-size:10px; color:var(--label); letter-spacing:0.25em; text-transform:uppercase; margin-top:3px; }
.header-right { margin-left:auto; display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
.status-pill { display:flex; align-items:center; gap:8px; padding:6px 12px; border:1px solid var(--border); font-size:11px; letter-spacing:0.15em; text-transform:uppercase; color:var(--label); transition:all 0.3s; white-space:nowrap; }
.status-pill.connected { border-color:var(--ok); color:var(--ok); }
.status-pill.connecting { border-color:var(--warn); color:var(--warn); }
.dot { width:7px; height:7px; border-radius:50%; background:var(--dim); flex-shrink:0; }
.connected .dot { background:var(--ok); box-shadow:0 0 8px var(--ok); animation:pulse 2s infinite; }
.connecting .dot { background:var(--warn); box-shadow:0 0 8px var(--warn); animation:pulse 0.8s infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:0.3} }
@keyframes chgpulse { 0%,100%{opacity:1}50%{opacity:0.5} }
.btn { font-family:var(--sans); font-weight:700; font-size:13px; letter-spacing:0.15em; text-transform:uppercase; background:transparent; padding:8px 18px; cursor:pointer; transition:all 0.2s; position:relative; overflow:hidden; white-space:nowrap; }
.btn-connect { border:1px solid var(--accent); color:var(--accent); }
.btn-disconnect { border:1px solid var(--accent2); color:var(--accent2); }
.btn::before { content:''; position:absolute; inset:0; transform:scaleX(0); transform-origin:left; transition:transform 0.2s; }
.btn-connect::before { background:var(--accent); }
.btn-disconnect::before { background:var(--accent2); }
.btn:hover::before { transform:scaleX(1); }
.btn:hover { color:var(--bg); }
.btn span { position:relative; z-index:1; }
.btn:disabled { border-color:var(--dim); color:var(--dim); cursor:not-allowed; }
.btn:disabled::before { display:none; }
.btn:disabled:hover { color:var(--dim); }
.batt-bar { display:flex; align-items:center; gap:8px; font-size:11px; color:var(--label); }
.batt-cells { display:flex; gap:2px; }
.batt-cell { width:9px; height:15px; border:1px solid var(--dim); background:transparent; transition:background 0.3s; }
.batt-cell.f { background:var(--ok); border-color:var(--ok); }
.batt-cell.f.warn { background:var(--warn); border-color:var(--warn); }
.batt-cell.f.crit { background:var(--accent2); border-color:var(--accent2); }
.batt-cell.f.charging { background:var(--accent); border-color:var(--accent); animation:chgpulse 1.2s ease-in-out infinite; }
.chg-badge { display:none; align-items:center; gap:4px; padding:3px 8px; font-size:10px; letter-spacing:0.15em; text-transform:uppercase; white-space:nowrap; border:1px solid; }
.chg-badge.charging { border-color:var(--accent); color:var(--accent); animation:chgpulse 1.6s ease-in-out infinite; }
.chg-badge.full { border-color:var(--ok); color:var(--ok); }
.chg-badge.show { display:flex; }
main { max-width:1100px; margin:0 auto; padding:32px 20px 80px; display:grid; grid-template-columns:1fr 380px; gap:16px; align-items:start; }
.col-left { display:grid; gap:12px; }
.col-right { display:grid; gap:12px; position:sticky; top:80px; }
.section-label { font-family:var(--sans); font-size:11px; font-weight:600; letter-spacing:0.3em; text-transform:uppercase; color:var(--label); padding:4px 0; border-bottom:1px solid var(--border); margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.section-label::before { content:'//'; color:var(--accent); font-family:var(--mono); font-size:10px; }
.card { background:var(--panel); border:1px solid var(--border); padding:20px; position:relative; }
.card::before { content:''; position:absolute; top:0; left:0; width:3px; height:100%; background:var(--accent); opacity:0; transition:opacity 0.3s; }
.card:focus-within::before { opacity:1; }
.param { display:grid; grid-template-columns:190px 1fr auto; align-items:center; gap:14px; padding:12px 0; border-bottom:1px solid var(--border); }
.param:last-child { border-bottom:none; padding-bottom:0; }
.param:first-child { padding-top:0; }
.param-label { font-family:var(--sans); font-size:13px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; }
.param-desc { font-size:10px; color:var(--label); margin-top:3px; }
.param-value { font-size:13px; color:var(--accent); min-width:52px; text-align:right; }
input[type=range] { -webkit-appearance:none; appearance:none; width:100%; height:2px; background:var(--border); outline:none; cursor:pointer; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; width:13px; height:13px; border-radius:0; background:var(--accent); cursor:pointer; transition:transform 0.15s; }
input[type=range]::-webkit-slider-thumb:hover { transform:scale(1.4); }
input[type=range]:disabled { opacity:0.35; }
input[type=range]:disabled::-webkit-slider-thumb { background:var(--dim); cursor:not-allowed; }
.segmented { display:flex; border:1px solid var(--border); }
.seg-btn { flex:1; padding:7px 8px; background:transparent; border:none; border-right:1px solid var(--border); font-family:var(--mono); font-size:10px; letter-spacing:0.1em; color:var(--label); cursor:pointer; text-transform:uppercase; transition:all 0.15s; }
.seg-btn:last-child { border-right:none; }
.seg-btn.active { background:var(--accent); color:var(--bg); font-weight:bold; }
.seg-btn:disabled { cursor:not-allowed; opacity:0.35; }
.charge-seg .seg-btn.active.off { background:var(--dim); color:#fff; }
.charge-seg .seg-btn.active.slow { background:var(--warn); color:var(--bg); }
.charge-seg .seg-btn.active.fast { background:var(--accent2);color:#fff; }
.flip-row { display:flex; gap:16px; padding:12px 0; border-bottom:1px solid var(--border); align-items:center; }
.flip-row:last-child { border-bottom:none; }
.flip-label { font-family:var(--sans); font-size:13px; font-weight:600; text-transform:uppercase; flex:1; }
.toggle { position:relative; width:40px; height:22px; flex-shrink:0; }
.toggle input { display:none; }
.toggle-track { position:absolute; inset:0; background:var(--border); cursor:pointer; transition:background 0.2s; }
.toggle input:checked + .toggle-track { background:var(--accent); }
.toggle-thumb { position:absolute; top:3px; left:3px; width:16px; height:16px; background:#fff; transition:transform 0.2s; pointer-events:none; }
.toggle input:checked ~ .toggle-thumb { transform:translateX(18px); }
.toggle input:disabled + .toggle-track { cursor:not-allowed; opacity:0.4; }
.cmd-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.cmd-btn { font-family:var(--sans); font-weight:700; font-size:13px; letter-spacing:0.12em; text-transform:uppercase; background:transparent; border:1px solid var(--border); color:var(--text); padding:14px; cursor:pointer; transition:all 0.2s; position:relative; overflow:hidden; text-align:left; display:flex; flex-direction:column; gap:5px; }
.cmd-btn .cmd-icon { font-size:20px; }
.cmd-btn .cmd-desc { font-family:var(--mono); font-size:9px; color:var(--label); letter-spacing:0.04em; text-transform:none; font-weight:400; }
.cmd-btn::before { content:''; position:absolute; inset:0; opacity:0; transition:opacity 0.2s; }
.cmd-btn:hover::before { opacity:1; }
.cmd-btn:hover { color:var(--bg); }
.cmd-btn:hover .cmd-desc { color:rgba(10,12,14,0.65); }
.cmd-btn.calibrate::before { background:var(--accent); }
.cmd-btn.calibrate:hover { border-color:var(--accent); }
.cmd-btn.reset::before { background:var(--accent2); }
.cmd-btn.reset:hover { border-color:var(--accent2); }
.cmd-btn span { position:relative; z-index:1; }
.cmd-btn:disabled { opacity:0.3; cursor:not-allowed; }
.cmd-btn:disabled::before { display:none; }
.cmd-btn:disabled:hover { color:var(--text); border-color:var(--border); }
.cmd-btn:disabled:hover .cmd-desc { color:var(--label); }
.console { background:var(--panel2); border:1px solid var(--border); padding:14px; height:160px; overflow-y:auto; font-size:10.5px; line-height:1.85; }
.console::-webkit-scrollbar { width:3px; }
.console::-webkit-scrollbar-thumb { background:var(--dim); }
.log-line { display:flex; gap:10px; }
.log-time { color:var(--dim); flex-shrink:0; }
.log-msg { color:var(--text); }
.log-msg.ok { color:var(--ok); }
.log-msg.err { color:var(--accent2); }
.log-msg.warn { color:var(--warn); }
.log-msg.info { color:var(--accent); }
.viz-panel { background:var(--panel2); border:1px solid var(--border); padding:16px; }
.viz-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.viz-title { font-family:var(--sans); font-size:11px; font-weight:600; letter-spacing:0.25em; text-transform:uppercase; color:var(--label); }
.viz-live { font-size:9px; letter-spacing:0.2em; color:var(--accent2); display:none; }
.viz-live.on { display:block; animation:pulse 1.5s infinite; }
#vizCanvas { display:block; width:100%; background:var(--panel2); border:1px solid var(--border); cursor:crosshair; image-rendering:pixelated; }
.viz-axes { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:10px; }
.axis-bar-wrap { display:flex; flex-direction:column; gap:3px; }
.axis-bar-label { font-size:9px; letter-spacing:0.15em; color:var(--label); text-transform:uppercase; display:flex; justify-content:space-between; }
.axis-bar-track { height:4px; background:var(--border); position:relative; }
.axis-bar-fill { position:absolute; top:0; height:100%; background:var(--accent); transition:width 0.05s, left 0.05s; }
.axis-bar-fill.neg { background:var(--accent2); }
.axis-bar-center { position:absolute; top:-2px; left:50%; width:1px; height:8px; background:var(--dim); }
.telem-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.telem-cell { background:var(--panel2); border:1px solid var(--border); padding:12px 14px; }
.telem-val { font-family:var(--sans); font-size:24px; font-weight:700; color:var(--text); line-height:1; }
.telem-val.accent { color:var(--accent); }
.telem-val.warn { color:var(--warn); }
.telem-val.ok { color:var(--ok); }
.telem-lbl { font-size:9px; letter-spacing:0.2em; text-transform:uppercase; color:var(--label); margin-top:5px; }
.charge-info { display:grid; grid-template-columns:1fr 1fr 1fr; gap:0; margin-top:14px; border:1px solid var(--border); }
.ci-item { padding:10px 12px; text-align:center; border-right:1px solid var(--border); }
.ci-item:last-child { border-right:none; }
.ci-val { font-family:var(--sans); font-size:16px; font-weight:700; }
.ci-lbl { font-size:9px; letter-spacing:0.2em; text-transform:uppercase; color:var(--label); margin-top:3px; }
.overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:500; align-items:center; justify-content:center; }
.overlay.show { display:flex; }
.modal { background:var(--panel); border:1px solid var(--accent2); padding:28px; max-width:360px; width:100%; }
.modal h3 { font-family:var(--sans); font-size:18px; font-weight:700; color:var(--accent2); margin-bottom:10px; text-transform:uppercase; }
.modal p { font-size:11px; color:var(--label); line-height:1.8; margin-bottom:20px; }
.modal-btns { display:flex; gap:10px; }
.modal-btns button { flex:1; font-family:var(--sans); font-weight:700; font-size:12px; letter-spacing:0.1em; text-transform:uppercase; padding:10px; cursor:pointer; border:1px solid; transition:all 0.2s; background:transparent; }
.btn-cancel { border-color:var(--dim); color:var(--dim); }
.btn-cancel:hover { border-color:var(--text); color:var(--text); }
.btn-confirm { border-color:var(--accent2); color:var(--accent2); }
.btn-confirm:hover { background:var(--accent2); color:var(--bg); }
.no-ble { grid-column:1/-1; text-align:center; padding:80px 24px; }
.no-ble h2 { font-family:var(--sans); font-size:28px; font-weight:700; color:var(--accent2); margin-bottom:12px; }
.no-ble p { font-size:13px; color:var(--label); line-height:1.8; }
body.disconnected .card { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
body.disconnected .cmd-grid { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
.tap-flash { position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity 0.25s; }
.tap-flash.left { background:radial-gradient(circle at center, rgba(0,229,255,0.35) 0%, transparent 70%); }
.tap-flash.right { background:radial-gradient(circle at center, rgba(255,61,113,0.35) 0%, transparent 70%); }
.tap-flash.show { opacity:1; }
.viz-wrap { position:relative; }
</style>
</head>
<body class="disconnected">
<header>
<div>
<div class="logo">IMU<span>·</span>Mouse</div>
<div class="logo-sub">BLE Config Terminal v3.3</div>
</div>
<div class="header-right">
<div class="batt-bar" id="battBar" style="display:none">
<div id="badgeCharging" class="chg-badge charging">⚡ CHARGING</div>
<div id="badgeFull" class="chg-badge full">✓ FULL</div>
<div class="batt-cells" id="battCells"></div>
<span id="battPct">--%</span>
</div>
<div class="status-pill" id="statusPill"><div class="dot"></div><span id="statusText">DISCONNECTED</span></div>
<button class="btn btn-connect" id="connectBtn" onclick="doConnect()"><span>Connect</span></button>
<button class="btn btn-disconnect" id="disconnectBtn" onclick="doDisconnect()" style="display:none"><span>Disconnect</span></button>
</div>
</header>
<main id="mainContent">
<div class="col-left">
<div class="section-label">Motion Parameters</div>
<div class="card">
<div class="param">
<div><div class="param-label">Sensitivity</div><div class="param-desc">Cursor speed multiplier</div></div>
<input type="range" id="slSensitivity" min="100" max="1500" step="10" value="600"
oninput="updateDisplay('sensitivity',this.value)" onchange="writeConfigBlob()">
<div class="param-value" id="valSensitivity">600</div>
</div>
<div class="param">
<div><div class="param-label">Dead Zone</div><div class="param-desc">Noise floor (rad/s) — raise to reduce drift</div></div>
<input type="range" id="slDeadZone" min="0.005" max="0.2" step="0.005" value="0.06"
oninput="updateDisplay('deadZone',this.value)" onchange="writeConfigBlob()">
<div class="param-value" id="valDeadZone">0.060</div>
</div>
<div class="param">
<div><div class="param-label">Accel Strength</div><div class="param-desc">Pointer acceleration multiplier</div></div>
<input type="range" id="slAccel" min="0" max="0.5" step="0.01" value="0.08"
oninput="updateDisplay('accel',this.value)" onchange="writeConfigBlob()">
<div class="param-value" id="valAccel">0.08</div>
</div>
</div>
<div class="section-label">Motion Curve</div>
<div class="card">
<div class="param" style="border-bottom:none;padding:0">
<div><div class="param-label">Scaling Curve</div><div class="param-desc">Response shape for input magnitude</div></div>
<div class="segmented" style="grid-column:2/4">
<button class="seg-btn active" id="curveLinear" onclick="setCurve(0)" disabled>LINEAR</button>
<button class="seg-btn" id="curveSquare" onclick="setCurve(1)" disabled>SQUARE ²</button>
<button class="seg-btn" id="curveSqrt" onclick="setCurve(2)" disabled>√ SQRT</button>
</div>
</div>
<div style="padding-top:12px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;font-size:9px;color:var(--label);text-align:center">
<div>Proportional.<br>Predictable.</div>
<div>Precision at slow,<br>power at fast.</div>
<div>Coarse fast,<br>fine near target.</div>
</div>
</div>
<div class="section-label">Battery Charging</div>
<div class="card">
<div class="param" style="border-bottom:none;padding:0">
<div><div class="param-label">Charge Mode</div><div class="param-desc">BQ25100 ISET via P0.13 (HICHG)</div></div>
<div class="segmented charge-seg" style="grid-column:2/4">
<button class="seg-btn off" id="chgOff" onclick="setChargeMode(0)" disabled>OFF</button>
<button class="seg-btn slow" id="chgSlow" onclick="setChargeMode(1)" disabled>SLOW · 50mA</button>
<button class="seg-btn fast" id="chgFast" onclick="setChargeMode(2)" disabled>FAST · 100mA</button>
</div>
</div>
<div class="charge-info">
<div class="ci-item"><div class="ci-val" id="ciStatus">--</div><div class="ci-lbl">Status</div></div>
<div class="ci-item"><div class="ci-val" id="ciMode">--</div><div class="ci-lbl">Current</div></div>
<div class="ci-item"><div class="ci-val" id="ciPct">--%</div><div class="ci-lbl">Level</div></div>
</div>
</div>
<div class="section-label">Axis Configuration</div>
<div class="card">
<div class="flip-row">
<div class="flip-label">Flip X Axis</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Invert left / right</div>
<label class="toggle"><input type="checkbox" id="flipX" onchange="writeConfigBlob()" disabled><div class="toggle-track"></div><div class="toggle-thumb"></div></label>
</div>
<div class="flip-row" style="border-bottom:none">
<div class="flip-label">Flip Y Axis</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Invert up / down</div>
<label class="toggle"><input type="checkbox" id="flipY" onchange="writeConfigBlob()" disabled><div class="toggle-track"></div><div class="toggle-thumb"></div></label>
</div>
</div>
<div class="section-label">Device Commands</div>
<div class="cmd-grid">
<button class="cmd-btn calibrate" id="btnCal" onclick="sendCalibrate()" disabled>
<span class="cmd-icon"></span><span>Calibrate Gyro</span>
<span class="cmd-desc">Hold device still — recalculates bias + records cal temperature.</span>
</button>
<button class="cmd-btn reset" id="btnReset" onclick="confirmReset()" disabled>
<span class="cmd-icon"></span><span>Factory Reset</span>
<span class="cmd-desc">Wipes all config from flash and restores defaults.</span>
</button>
</div>
<div class="section-label" style="margin-top:8px">Event Log</div>
<div class="console" id="console"></div>
</div>
<div class="col-right">
<div class="section-label">Live Cursor Visualiser</div>
<div class="viz-panel">
<div class="viz-header">
<div class="viz-title">IMU Stream</div>
<div class="viz-live" id="vizLive">● LIVE</div>
</div>
<div class="viz-wrap">
<canvas id="vizCanvas" width="340" height="260"></canvas>
<div class="tap-flash left" id="tapFlashLeft"></div>
<div class="tap-flash right" id="tapFlashRight"></div>
</div>
<div class="viz-axes">
<div class="axis-bar-wrap">
<div class="axis-bar-label"><span>GY (up/down)</span><span id="gyVal">0</span></div>
<div class="axis-bar-track"><div class="axis-bar-fill" id="gyBar"></div><div class="axis-bar-center"></div></div>
</div>
<div class="axis-bar-wrap">
<div class="axis-bar-label"><span>GZ (left/right)</span><span id="gzVal">0</span></div>
<div class="axis-bar-track"><div class="axis-bar-fill" id="gzBar"></div><div class="axis-bar-center"></div></div>
</div>
</div>
<div style="margin-top:10px;font-size:9px;color:var(--label);line-height:1.7">
Dot = cursor position. Trail fades over time. <span style="color:var(--accent)">Cyan flash</span> = left click, <span style="color:var(--accent2)">red flash</span> = right click.
</div>
</div>
<div class="section-label">Live Telemetry</div>
<div class="telem-grid">
<div class="telem-cell"><div class="telem-val accent" id="telTemp">--</div><div class="telem-lbl">Temperature °C</div></div>
<div class="telem-cell"><div class="telem-val" id="telUptime">--</div><div class="telem-lbl">Uptime</div></div>
<div class="telem-cell"><div class="telem-val ok" id="telLeft">0</div><div class="telem-lbl">Left Clicks</div></div>
<div class="telem-cell"><div class="telem-val warn" id="telRight">0</div><div class="telem-lbl">Right Clicks</div></div>
<div class="telem-cell"><div class="telem-val" id="telBias">--</div><div class="telem-lbl">Bias RMS (rad/s)</div></div>
<div class="telem-cell"><div class="telem-val" id="telRecal">0</div><div class="telem-lbl">Recal Count</div></div>
</div>
</div>
</main>
<div class="overlay" id="overlay">
<div class="modal">
<h3>⚠ Factory Reset</h3>
<p>Erase all stored configuration from flash and restore factory defaults. This cannot be undone.</p>
<div class="modal-btns">
<button class="btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn-confirm" onclick="doReset()">Reset Device</button>
</div>
</div>
</div>
<script>
// ── 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 };
let device=null, server=null, chars={};
let currentChargeStatus=0, currentBattPct=null;
// ── Logging ──────────────────────────────────────────────────────────────────
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');
// ── Connection ───────────────────────────────────────────────────────────────
async function doConnect() {
if (!navigator.bluetooth) { log('Web Bluetooth not supported.','err'); return; }
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) { 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 (16 bytes LE):
// float sensitivity [0], float deadZone [4], float accelStrength [8]
// uint8 curve [12], uint8 axisFlip [13], uint8 chargeMode [14], uint8 pad [15]
async function readConfigBlob() {
if (!chars.configBlob) return;
try {
const dv = await chars.configBlob.readValue();
const view = new DataView(dv.buffer ?? 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);
applyConfigToUI();
log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)}`,'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);
}
async function writeConfigBlob() {
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.curve and config.chargeMode are updated directly by setCurve/setChargeMode
const buf = new ArrayBuffer(16);
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, 0);
try {
await chars.configBlob.writeValue(buf);
log(`Config written — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} curve=${config.curve} chg=${config.chargeMode}`,'ok');
} catch(e) { log(`Config write failed: ${e.message}`,'err'); }
}
// ── Individual control handlers ───────────────────────────────────────────────
// These update the local config shadow then write the full blob
async function setCurve(val) {
config.curve = val;
setCurveUI(val);
await 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));
}
async function setChargeMode(val) {
config.chargeMode = val;
setChargeModeUI(val);
await 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] ?? '--';
}
async function sendCalibrate() {
if (!chars.command) return;
try { await chars.command.writeValue(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 chars.command.writeValue(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) {
const view = new DataView(dv.buffer ?? dv);
const uptime = view.getUint32(0, true);
const leftClicks = view.getUint32(4, true);
const rightClicks = view.getUint32(8, true);
const temp = view.getFloat32(12,true);
const biasRms = view.getFloat32(16,true);
const recalCount = view.getUint16(20, true);
const chargeStatus= view.getUint8(22);
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.toExponential(2);
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)],
};
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');
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');
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();
}
// ── 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) {
const view = new DataView(dv.buffer ?? dv);
const gyroY = view.getInt16(0, true);
const gyroZ = view.getInt16(2, true);
const moveX = view.getInt8(10);
const moveY = view.getInt8(11);
const flags = view.getUint8(12);
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='rgba(13,16,19,0.25)'; ctx.fillRect(0,0,W,H);
ctx.strokeStyle='rgba(31,36,40,0.6)'; 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='rgba(58,64,80,0.5)'; 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();
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(58,64,80,${alpha*0.4})`:`rgba(0,229,255,${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?'#3a4050':'#00e5ff', dotGlow=idle?'transparent':'rgba(0,229,255,0.35)';
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='rgba(90,100,128,0.7)';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 initCanvas(){
const W=canvas.width,H=canvas.height;
ctx.fillStyle='#0d1013';ctx.fillRect(0,0,W,H);
ctx.strokeStyle='rgba(31,36,40,0.5)';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='rgba(58,64,80,0.4)';
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='rgba(58,64,80,0.6)';ctx.font='10px Share Tech Mono,monospace';
ctx.textAlign='center';ctx.fillText('connect to activate stream',W/2,H/2+4);ctx.textAlign='left';
})();
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');
}
</script>
</body>
</html>