Restructure project
This commit is contained in:
797
air-mouse.ino
797
air-mouse.ino
@@ -1,797 +0,0 @@
|
||||
/*
|
||||
* IMU BLE Mouse — Seeed XIAO nRF52840 Sense (v3.4)
|
||||
* ================================================================
|
||||
* Feature flags — comment out any line to disable that feature.
|
||||
* ATT table size is computed automatically from enabled features.
|
||||
* Start with minimal flags to isolate the SoftDevice RAM issue,
|
||||
* then re-enable one at a time.
|
||||
*
|
||||
* MINIMUM (just working mouse, no BLE config):
|
||||
* leave only FEATURE_BATTERY_MONITOR + FEATURE_BOOT_LOOP_DETECT
|
||||
*
|
||||
* RECOMMENDED first test:
|
||||
* enable FEATURE_CONFIG_SERVICE, keep TAP + STREAM + TELEMETRY off
|
||||
*
|
||||
* ── Feature flag index ───────────────────────────────────────────
|
||||
* FEATURE_CONFIG_SERVICE Custom GATT service (ConfigBlob + Command)
|
||||
* FEATURE_TELEMETRY +24-byte notify characteristic, 1 Hz
|
||||
* FEATURE_IMU_STREAM +14-byte notify characteristic, ~100 Hz
|
||||
* FEATURE_TAP_DETECTION LSM6DS3 hardware tap engine → L/R clicks
|
||||
* FEATURE_TEMP_COMPENSATION Gyro drift correction by temperature delta
|
||||
* FEATURE_AUTO_RECAL Recalibrate after AUTO_RECAL_MS idle
|
||||
* FEATURE_BATTERY_MONITOR ADC battery read + BLE Battery Service
|
||||
* FEATURE_BOOT_LOOP_DETECT .noinit crash counter → safe mode
|
||||
*
|
||||
* Dependencies:
|
||||
* FEATURE_TELEMETRY requires FEATURE_CONFIG_SERVICE
|
||||
* FEATURE_IMU_STREAM requires FEATURE_CONFIG_SERVICE
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
// ─── Feature Flags ────────────────────────────────────────────────────────────
|
||||
#define FEATURE_CONFIG_SERVICE
|
||||
#define FEATURE_TELEMETRY
|
||||
#define FEATURE_IMU_STREAM
|
||||
#define FEATURE_TAP_DETECTION
|
||||
#define FEATURE_TEMP_COMPENSATION
|
||||
#define FEATURE_AUTO_RECAL
|
||||
#define FEATURE_BATTERY_MONITOR
|
||||
#define FEATURE_BOOT_LOOP_DETECT
|
||||
|
||||
// ─── Debug ────────────────────────────────────────────────────────────────────
|
||||
// #define DEBUG
|
||||
|
||||
// ─── ATT table size ───────────────────────────────────────────────────────────
|
||||
// Must be passed to configAttrTableSize() BEFORE Bluefruit.begin().
|
||||
// Too small → SoftDevice panics. Too large → SoftDevice claims RAM the linker
|
||||
// already assigned to the app and also panics. Sweet spot is 1500-2000.
|
||||
//
|
||||
// HID service alone costs ~700 B (report map + 6 characteristics + CCCDs).
|
||||
// Add DIS (~100 B), BAS (~50 B), then our custom characteristics.
|
||||
// Use 1536 as the safe base; add 256 per notify characteristic.
|
||||
//
|
||||
// Feature cost breakdown:
|
||||
// HID + DIS + BAS baseline : ~900 B (always present)
|
||||
// CONFIG_SERVICE (blob+cmd) : ~80 B
|
||||
// TELEMETRY (R/N 24 bytes) : ~40 B
|
||||
// IMU_STREAM (N 14 bytes) : ~30 B
|
||||
//
|
||||
#define _ATT_BASE 900
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
#define _ATT_CFG 80
|
||||
#else
|
||||
#define _ATT_CFG 0
|
||||
#endif
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
#define _ATT_TELEM 40
|
||||
#else
|
||||
#define _ATT_TELEM 0
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
#define _ATT_STREAM 30
|
||||
#else
|
||||
#define _ATT_STREAM 0
|
||||
#endif
|
||||
// Floor of 1536 so we never go below what HID actually needs
|
||||
#define ATT_TABLE_SIZE_CALC (_ATT_BASE + _ATT_CFG + _ATT_TELEM + _ATT_STREAM)
|
||||
#define ATT_TABLE_SIZE (ATT_TABLE_SIZE_CALC < 1536 ? 1536 : ATT_TABLE_SIZE_CALC)
|
||||
|
||||
// ─── Includes ─────────────────────────────────────────────────────────────────
|
||||
#include <bluefruit.h>
|
||||
#include <Adafruit_LittleFS.h>
|
||||
#include <InternalFileSystem.h>
|
||||
#include "LSM6DS3.h"
|
||||
#include "Wire.h"
|
||||
|
||||
// ─── Boot-loop detection ──────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
static uint32_t __attribute__((section(".noinit"))) bootCount;
|
||||
static uint32_t __attribute__((section(".noinit"))) bootMagic;
|
||||
#endif
|
||||
static bool safeMode = false;
|
||||
static bool bootCountCleared = false;
|
||||
|
||||
// ─── BLE Standard Services ────────────────────────────────────────────────────
|
||||
BLEDis bledis;
|
||||
BLEHidAdafruit blehid;
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
BLEBas blebas;
|
||||
#endif
|
||||
|
||||
// ─── BLE Config Service ───────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
BLEService cfgService (0x1234);
|
||||
BLECharacteristic cfgBlob (0x1235); // ConfigBlob R/W 16 bytes
|
||||
BLECharacteristic cfgCommand (0x1236); // Command W 1 byte
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
BLECharacteristic cfgTelemetry(0x1237); // Telemetry R/N 24 bytes
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
BLECharacteristic cfgImuStream(0x1238); // ImuStream N 14 bytes
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// ─── IMU ──────────────────────────────────────────────────────────────────────
|
||||
LSM6DS3 imu(I2C_MODE, 0x6A);
|
||||
|
||||
#define REG_CTRL1_XL 0x10
|
||||
#define REG_TAP_CFG 0x58
|
||||
#define REG_TAP_THS_6D 0x59
|
||||
#define REG_INT_DUR2 0x5A
|
||||
#define REG_WAKE_UP_THS 0x5B
|
||||
#define REG_MD1_CFG 0x5E
|
||||
#define REG_TAP_SRC 0x1C
|
||||
#define REG_OUT_TEMP_L 0x20
|
||||
#define REG_OUT_TEMP_H 0x21
|
||||
|
||||
// ─── Pins ─────────────────────────────────────────────────────────────────────
|
||||
#define PIN_VBAT_ENABLE (14)
|
||||
#define PIN_VBAT_READ (32)
|
||||
#define PIN_CHG (23)
|
||||
#define PIN_HICHG (22)
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
#define CONFIG_FILENAME "/imu_mouse_cfg.bin"
|
||||
#define CONFIG_MAGIC 0xDEAD1239UL
|
||||
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
File cfgFile(InternalFS);
|
||||
|
||||
// ─── 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 (stored in flash) ─────────────────────────────────────────────────
|
||||
struct Config {
|
||||
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
|
||||
};
|
||||
|
||||
// ─── ConfigBlob (over BLE, no magic) ─────────────────────────────────────────
|
||||
struct __attribute__((packed)) ConfigBlob {
|
||||
float sensitivity; // [0]
|
||||
float deadZone; // [4]
|
||||
float accelStrength; // [8]
|
||||
uint8_t curve; // [12]
|
||||
uint8_t axisFlip; // [13]
|
||||
uint8_t chargeMode; // [14]
|
||||
uint8_t _pad; // [15]
|
||||
};
|
||||
static_assert(sizeof(ConfigBlob) == 16, "ConfigBlob must be 16 bytes");
|
||||
|
||||
// ─── TelemetryPacket ──────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
struct __attribute__((packed)) TelemetryPacket {
|
||||
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]
|
||||
uint8_t _pad; // [23]
|
||||
};
|
||||
static_assert(sizeof(TelemetryPacket) == 24, "TelemetryPacket must be 24 bytes");
|
||||
TelemetryPacket telem = {};
|
||||
#endif
|
||||
|
||||
// ─── ImuPacket ────────────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
struct __attribute__((packed)) ImuPacket {
|
||||
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]
|
||||
};
|
||||
static_assert(sizeof(ImuPacket) == 14, "ImuPacket must be 14 bytes");
|
||||
#endif
|
||||
|
||||
// ─── Tuning constants ─────────────────────────────────────────────────────────
|
||||
const float ALPHA = 0.96f;
|
||||
const int LOOP_RATE_MS = 10;
|
||||
const int BIAS_SAMPLES = 200;
|
||||
const int IDLE_FRAMES = 150;
|
||||
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 BOOT_SAFE_MS = 5000;
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
const unsigned long IMU_STREAM_RATE_MS = 50; // 20 Hz max — 100 Hz overwhelms BLE conn interval
|
||||
#endif
|
||||
const float BATT_FULL = 4.20f;
|
||||
const float BATT_EMPTY = 3.00f;
|
||||
const float BATT_CRITICAL = 3.10f;
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
const unsigned long CLICK_HOLD_MS = 60;
|
||||
#endif
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
const float TEMP_COMP_COEFF_DPS_C = 0.004f;
|
||||
#endif
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
const unsigned long AUTO_RECAL_MS = 5UL * 60UL * 1000UL;
|
||||
#endif
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
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;
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
bool clickButtonDown = false;
|
||||
uint8_t clickButton = 0;
|
||||
unsigned long clickDownMs= 0;
|
||||
uint32_t statLeftClicks = 0;
|
||||
uint32_t statRightClicks = 0;
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
bool imuStreamEnabled = false;
|
||||
#endif
|
||||
|
||||
bool pendingCal = false;
|
||||
bool pendingReset = false;
|
||||
|
||||
ChargeStatus lastChargeStatus = CHGSTAT_DISCHARGING;
|
||||
|
||||
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;
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
unsigned long lastImuStream = 0;
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
uint16_t statRecalCount = 0;
|
||||
float statBiasRms = 0.0f;
|
||||
#endif
|
||||
|
||||
// ─── I2C helpers ──────────────────────────────────────────────────────────────
|
||||
void imuWriteReg(uint8_t reg, uint8_t val) {
|
||||
// LSM6DS3 is on Wire1 (internal I2C, SDA=P0.17, SCL=P0.16), NOT Wire (external pins 4/5)
|
||||
Wire1.beginTransmission(0x6A); Wire1.write(reg); Wire1.write(val); Wire1.endTransmission();
|
||||
}
|
||||
uint8_t imuReadReg(uint8_t reg) {
|
||||
Wire1.beginTransmission(0x6A); Wire1.write(reg); Wire1.endTransmission(false);
|
||||
Wire1.requestFrom((uint8_t)0x6A, (uint8_t)1);
|
||||
return Wire1.available() ? Wire1.read() : 0;
|
||||
}
|
||||
|
||||
// ─── Temperature ──────────────────────────────────────────────────────────────
|
||||
float readIMUTemp() {
|
||||
int16_t raw = (int16_t)((imuReadReg(REG_OUT_TEMP_H) << 8) | imuReadReg(REG_OUT_TEMP_L));
|
||||
return 25.0f + (float)raw / 256.0f;
|
||||
}
|
||||
|
||||
// ─── Tap detection ────────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
void setupTapDetection() {
|
||||
imuWriteReg(REG_CTRL1_XL, 0x60); // ODR=416Hz, FS=±2g
|
||||
imuWriteReg(REG_TAP_CFG, 0x8E); // INT_EN + LIR + TAP_Z/Y/X
|
||||
imuWriteReg(REG_TAP_THS_6D, 0x0C); // threshold 750 mg (was 500 mg — too easy to false-trigger)
|
||||
imuWriteReg(REG_INT_DUR2, 0x7A); // DUR=7(538ms), QUIET=2(19ms), SHOCK=2(38ms)
|
||||
imuWriteReg(REG_WAKE_UP_THS, 0x80); // enable double-tap
|
||||
imuWriteReg(REG_MD1_CFG, 0x48); // route taps to INT1
|
||||
Serial.println("[TAP] Engine configured — single=LEFT, double=RIGHT");
|
||||
}
|
||||
|
||||
void processTaps(unsigned long now) {
|
||||
// Release held button after CLICK_HOLD_MS
|
||||
if (clickButtonDown && (now - clickDownMs >= CLICK_HOLD_MS)) {
|
||||
blehid.mouseButtonPress(clickButton, false);
|
||||
clickButtonDown = false; clickButton = 0;
|
||||
}
|
||||
if (clickButtonDown) return; // Don't start a new click while one is held
|
||||
|
||||
// The LSM6DS3 (with D_TAP_EN) already disambiguates at hardware level:
|
||||
// SINGLE_TAP is only set after the DUR window expires with no second tap.
|
||||
// DOUBLE_TAP is set immediately when the second tap arrives within DUR.
|
||||
// We trust this directly — no software delay needed.
|
||||
uint8_t tapSrc = imuReadReg(REG_TAP_SRC);
|
||||
if (!(tapSrc & 0x40)) return; // TAP_IA not set — no event
|
||||
|
||||
if (tapSrc & 0x10) { // DOUBLE_TAP → right click
|
||||
Serial.println("[TAP] Double → RIGHT");
|
||||
blehid.mouseButtonPress(MOUSE_BUTTON_RIGHT, true);
|
||||
clickButton = MOUSE_BUTTON_RIGHT; clickButtonDown = true; clickDownMs = now;
|
||||
statRightClicks++;
|
||||
} else if (tapSrc & 0x20) { // SINGLE_TAP → left click
|
||||
Serial.println("[TAP] Single → LEFT");
|
||||
blehid.mouseButtonPress(MOUSE_BUTTON_LEFT, true);
|
||||
clickButton = MOUSE_BUTTON_LEFT; clickButtonDown = true; clickDownMs = now;
|
||||
statLeftClicks++;
|
||||
}
|
||||
}
|
||||
#endif // FEATURE_TAP_DETECTION
|
||||
|
||||
// ─── 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) {
|
||||
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"); }
|
||||
else { Serial.println("[CFG] ERROR: write failed"); }
|
||||
}
|
||||
|
||||
// ─── ConfigBlob push ─────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
void pushConfigBlob() {
|
||||
ConfigBlob b;
|
||||
b.sensitivity = cfg.sensitivity; b.deadZone = cfg.deadZone;
|
||||
b.accelStrength = cfg.accelStrength; b.curve = (uint8_t)cfg.curve;
|
||||
b.axisFlip = cfg.axisFlip; b.chargeMode = (uint8_t)cfg.chargeMode; b._pad = 0;
|
||||
cfgBlob.write((uint8_t*)&b, sizeof(b));
|
||||
}
|
||||
#endif
|
||||
|
||||
void factoryReset() {
|
||||
cfg = CFG_DEFAULTS; saveConfig();
|
||||
applyChargeMode(cfg.chargeMode);
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
if (!safeMode) pushConfigBlob();
|
||||
#endif
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
telem = {};
|
||||
#endif
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
statLeftClicks = statRightClicks = 0;
|
||||
#endif
|
||||
Serial.println("[CFG] Factory reset complete");
|
||||
}
|
||||
|
||||
// ─── BLE callbacks ────────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
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();
|
||||
Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0);
|
||||
Serial.print(" dz="); Serial.print(cfg.deadZone,3);
|
||||
Serial.print(" curve="); Serial.print(cfg.curve);
|
||||
Serial.print(" chg="); Serial.println(cfg.chargeMode);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
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");
|
||||
}
|
||||
#endif
|
||||
#endif // FEATURE_CONFIG_SERVICE
|
||||
|
||||
// ─── BLE service setup ────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
void setupConfigService() {
|
||||
cfgService.begin();
|
||||
|
||||
cfgBlob.setProperties(CHR_PROPS_READ | CHR_PROPS_WRITE);
|
||||
cfgBlob.setPermission(SECMODE_OPEN, SECMODE_OPEN);
|
||||
cfgBlob.setFixedLen(sizeof(ConfigBlob));
|
||||
cfgBlob.setWriteCallback(onConfigBlobWrite);
|
||||
cfgBlob.begin();
|
||||
pushConfigBlob();
|
||||
|
||||
cfgCommand.setProperties(CHR_PROPS_WRITE);
|
||||
cfgCommand.setPermission(SECMODE_OPEN, SECMODE_OPEN);
|
||||
cfgCommand.setFixedLen(1);
|
||||
cfgCommand.setWriteCallback(onCommandWrite);
|
||||
cfgCommand.begin();
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
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));
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
cfgImuStream.setProperties(CHR_PROPS_NOTIFY);
|
||||
cfgImuStream.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS);
|
||||
cfgImuStream.setFixedLen(sizeof(ImuPacket));
|
||||
cfgImuStream.setCccdWriteCallback(onImuStreamCccd);
|
||||
cfgImuStream.begin();
|
||||
#endif
|
||||
|
||||
// Print actual ATT table budget at runtime
|
||||
Serial.print("[BLE] ATT_TABLE_SIZE="); Serial.print(ATT_TABLE_SIZE);
|
||||
Serial.print(" | chars=2");
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
Serial.print("+TELEM");
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
Serial.print("+STREAM");
|
||||
#endif
|
||||
Serial.println();
|
||||
}
|
||||
#endif // FEATURE_CONFIG_SERVICE
|
||||
|
||||
// ─── Battery ──────────────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
float readBatteryVoltage() {
|
||||
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;
|
||||
digitalWrite(PIN_VBAT_ENABLE, HIGH);
|
||||
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 chg = (digitalRead(PIN_CHG) == LOW);
|
||||
ChargeStatus status = chg ? (pct >= 99 ? CHGSTAT_FULL : CHGSTAT_CHARGING) : CHGSTAT_DISCHARGING;
|
||||
blebas.write(pct);
|
||||
lastChargeStatus = status;
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
telem.chargeStatus = (uint8_t)status;
|
||||
#endif
|
||||
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); }
|
||||
}
|
||||
#endif // FEATURE_BATTERY_MONITOR
|
||||
|
||||
// ─── Calibration ─────────────────────────────────────────────────────────────
|
||||
void calibrateGyroBias() {
|
||||
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)(sx/BIAS_SAMPLES);
|
||||
biasGY = (float)(sy/BIAS_SAMPLES);
|
||||
biasGZ = (float)(sz/BIAS_SAMPLES);
|
||||
calTempC = readIMUTemp();
|
||||
angleX = angleY = accumX = accumY = 0.0f;
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
statRecalCount++;
|
||||
float bxr = biasGX*(PI/180.f), byr = biasGY*(PI/180.f), bzr = biasGZ*(PI/180.f);
|
||||
statBiasRms = sqrtf((bxr*bxr + byr*byr + bzr*bzr) / 3.0f);
|
||||
#endif
|
||||
|
||||
digitalWrite(LED_BLUE, HIGH);
|
||||
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 ? 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); }
|
||||
|
||||
// ─── Telemetry push ───────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
void pushTelemetry(unsigned long now) {
|
||||
telem.uptimeSeconds = now / 1000;
|
||||
telem.tempCelsius = cachedTempC;
|
||||
telem.biasRmsRadS = statBiasRms;
|
||||
telem.recalCount = statRecalCount;
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
telem.leftClicks = statLeftClicks;
|
||||
telem.rightClicks = statRightClicks;
|
||||
#endif
|
||||
cfgTelemetry.write ((uint8_t*)&telem, sizeof(telem));
|
||||
cfgTelemetry.notify((uint8_t*)&telem, sizeof(telem));
|
||||
}
|
||||
#endif
|
||||
|
||||
// ─── Advertising ─────────────────────────────────────────────────────────────
|
||||
void startAdvertising() {
|
||||
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
|
||||
Bluefruit.Advertising.addTxPower();
|
||||
Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_MOUSE);
|
||||
Bluefruit.Advertising.addService(blehid);
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
Bluefruit.Advertising.addService(blebas);
|
||||
#endif
|
||||
Bluefruit.Advertising.addName();
|
||||
Bluefruit.Advertising.restartOnDisconnect(true);
|
||||
Bluefruit.Advertising.setInterval(32, 244);
|
||||
Bluefruit.Advertising.setFastTimeout(30);
|
||||
Bluefruit.Advertising.start(0);
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
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);
|
||||
|
||||
// ── Boot-loop detection ───────────────────────────────────────────────────
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
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 — safe mode (no config service)");
|
||||
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); }
|
||||
}
|
||||
#endif
|
||||
|
||||
loadConfig();
|
||||
applyChargeMode(cfg.chargeMode);
|
||||
|
||||
// configAttrTableSize MUST be called before Bluefruit.begin().
|
||||
// Value must be >= what HID+DIS+BAS+custom services actually need.
|
||||
// Too small crashes just as hard as too large. Floor is 1536.
|
||||
Serial.print("[BLE] ATT table: "); Serial.print(ATT_TABLE_SIZE); Serial.println(" bytes");
|
||||
Bluefruit.configAttrTableSize(ATT_TABLE_SIZE);
|
||||
|
||||
Bluefruit.begin(1, 0);
|
||||
Bluefruit.setTxPower(4);
|
||||
Bluefruit.setName(safeMode ? "IMU Mouse (safe)" : "IMU Mouse");
|
||||
Bluefruit.Periph.setConnInterval(12, 24); // 15-30ms — less aggressive, prevents stream disconnect
|
||||
|
||||
Wire1.begin(); // LSM6DS3 is on internal I2C bus (Wire1), must init before imu.begin()
|
||||
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");
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
setupTapDetection();
|
||||
#endif
|
||||
|
||||
cachedTempC = readIMUTemp();
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
updateBattery();
|
||||
#endif
|
||||
|
||||
calibrateGyroBias();
|
||||
|
||||
bledis.setManufacturer("Seeed Studio");
|
||||
bledis.setModel("XIAO nRF52840 Sense");
|
||||
bledis.begin();
|
||||
|
||||
blehid.begin();
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
blebas.begin(); blebas.write(100);
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
if (!safeMode) {
|
||||
setupConfigService();
|
||||
Serial.println("[OK] Config service started");
|
||||
} else {
|
||||
Serial.println("[SAFE] Config service skipped");
|
||||
}
|
||||
#endif
|
||||
|
||||
startAdvertising();
|
||||
Serial.print("[OK] Advertising — features:");
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
Serial.print(" CFG");
|
||||
#endif
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
Serial.print(" TELEM");
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
Serial.print(" STREAM");
|
||||
#endif
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
Serial.print(" TAP");
|
||||
#endif
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
Serial.print(" TEMPCOMP");
|
||||
#endif
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
Serial.print(" AUTORECAL");
|
||||
#endif
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
Serial.print(" BATT");
|
||||
#endif
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
Serial.print(" BOOTDET");
|
||||
#endif
|
||||
Serial.println();
|
||||
|
||||
bootStartMs = millis();
|
||||
lastTime = lastBattTime = lastHeartbeat = lastTelemetry = millis();
|
||||
}
|
||||
|
||||
// ─── Loop ─────────────────────────────────────────────────────────────────────
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
|
||||
// Clear boot counter after BOOT_SAFE_MS of stable running
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
if (!bootCountCleared && (now - bootStartMs >= BOOT_SAFE_MS)) {
|
||||
bootCount = 0; bootCountCleared = true;
|
||||
Serial.println("[BOOT] Stable — counter cleared");
|
||||
}
|
||||
#endif
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
if (now - lastBattTime >= BATT_REPORT_MS) { lastBattTime = now; updateBattery(); }
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
processTaps(now);
|
||||
#endif
|
||||
|
||||
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;
|
||||
|
||||
cachedTempC = readIMUTemp();
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
if (!safeMode && (now - lastTelemetry >= TELEMETRY_MS)) {
|
||||
lastTelemetry = now; pushTelemetry(now);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Gyro reads with optional temperature compensation
|
||||
float gx, gy, gz;
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
float correction = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC);
|
||||
gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f);
|
||||
gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f);
|
||||
gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f);
|
||||
#else
|
||||
gx = (imu.readFloatGyroX() - biasGX) * (PI/180.0f);
|
||||
gy = (imu.readFloatGyroY() - biasGY) * (PI/180.0f);
|
||||
gz = (imu.readFloatGyroZ() - biasGZ) * (PI/180.0f);
|
||||
#endif
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
if (idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) {
|
||||
Serial.println("[AUTO-CAL] Long idle — recalibrating...");
|
||||
idleStartMs = 0; calibrateGyroBias(); return;
|
||||
}
|
||||
#endif
|
||||
|
||||
int8_t moveX = 0, moveY = 0;
|
||||
uint8_t flags = 0;
|
||||
|
||||
if (idle) {
|
||||
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);
|
||||
}
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
if (!safeMode && imuStreamEnabled && Bluefruit.connected()
|
||||
&& (now - lastImuStream >= IMU_STREAM_RATE_MS)) {
|
||||
lastImuStream = now;
|
||||
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));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef DEBUG
|
||||
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
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
; pio run -t upload <- build + flash
|
||||
; ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[platformio]
|
||||
src_dir = source
|
||||
|
||||
[env:xiao_nrf52840_sense]
|
||||
platform = https://github.com/Seeed-Studio/platform-seeedboards.git
|
||||
board = seeed-xiao-afruitnrf52-nrf52840
|
||||
|
||||
46
source/battery.cpp
Normal file
46
source/battery.cpp
Normal file
@@ -0,0 +1,46 @@
|
||||
#include "battery.h"
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
#include <bluefruit.h>
|
||||
|
||||
extern BLEBas blebas;
|
||||
|
||||
// Battery ADC is kept permanently configured at 12-bit / AR_INTERNAL_3_0 to avoid
|
||||
// the SAADC re-init cost of analogReference() changes, which blocks the CPU for
|
||||
// several ms and causes BLE connection-interval violations (visible mouse freeze).
|
||||
// PIN_VBAT_ENABLE is held LOW permanently once battery monitoring starts.
|
||||
void initBatteryADC() {
|
||||
pinMode(PIN_VBAT_ENABLE, OUTPUT); digitalWrite(PIN_VBAT_ENABLE, LOW);
|
||||
pinMode(PIN_VBAT_READ, INPUT);
|
||||
analogReference(AR_INTERNAL_3_0); analogReadResolution(12);
|
||||
// Warm up with a few reads (no delay — just discard results)
|
||||
for (int i=0; i<8; i++) analogRead(PIN_VBAT_READ);
|
||||
}
|
||||
|
||||
float readBatteryVoltage() {
|
||||
// 8 quick reads, no delay() calls, no analogReference() change
|
||||
int32_t raw=0; for (int i=0; i<8; i++) raw += analogRead(PIN_VBAT_READ); raw /= 8;
|
||||
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 chg = (digitalRead(PIN_CHG) == LOW);
|
||||
ChargeStatus status = chg ? (pct >= 99 ? CHGSTAT_FULL : CHGSTAT_CHARGING) : CHGSTAT_DISCHARGING;
|
||||
blebas.write(pct);
|
||||
lastChargeStatus = status;
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
telem.chargeStatus = (uint8_t)status;
|
||||
#endif
|
||||
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); }
|
||||
}
|
||||
|
||||
#endif // FEATURE_BATTERY_MONITOR
|
||||
9
source/battery.h
Normal file
9
source/battery.h
Normal file
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include "config.h"
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
void initBatteryADC();
|
||||
float readBatteryVoltage();
|
||||
int batteryPercent(float v);
|
||||
void updateBattery();
|
||||
#endif
|
||||
169
source/ble_config.cpp
Normal file
169
source/ble_config.cpp
Normal file
@@ -0,0 +1,169 @@
|
||||
#include "ble_config.h"
|
||||
#include <Adafruit_LittleFS.h>
|
||||
#include <InternalFileSystem.h>
|
||||
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
extern File cfgFile;
|
||||
|
||||
// ─── BLE Config Service objects ───────────────────────────────────────────────
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
BLEService cfgService (0x1234);
|
||||
BLECharacteristic cfgBlob (0x1235); // ConfigBlob R/W 16 bytes
|
||||
BLECharacteristic cfgCommand (0x1236); // Command W 1 byte
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
BLECharacteristic cfgTelemetry(0x1237); // Telemetry R/N 24 bytes
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
BLECharacteristic cfgImuStream(0x1238); // ImuStream N 14 bytes
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// ─── 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) {
|
||||
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"); }
|
||||
else { Serial.println("[CFG] ERROR: write failed"); }
|
||||
}
|
||||
|
||||
// ─── ConfigBlob push ─────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
void pushConfigBlob() {
|
||||
ConfigBlob b;
|
||||
b.sensitivity = cfg.sensitivity; b.deadZone = cfg.deadZone;
|
||||
b.accelStrength = cfg.accelStrength; b.curve = (uint8_t)cfg.curve;
|
||||
b.axisFlip = cfg.axisFlip; b.chargeMode = (uint8_t)cfg.chargeMode; b._pad = 0;
|
||||
cfgBlob.write((uint8_t*)&b, sizeof(b));
|
||||
}
|
||||
#endif
|
||||
|
||||
void factoryReset() {
|
||||
cfg = CFG_DEFAULTS; saveConfig();
|
||||
applyChargeMode(cfg.chargeMode);
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
if (!safeMode) pushConfigBlob();
|
||||
#endif
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
telem = {};
|
||||
#endif
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
statLeftClicks = statRightClicks = 0;
|
||||
#endif
|
||||
Serial.println("[CFG] Factory reset complete");
|
||||
}
|
||||
|
||||
// ─── BLE callbacks ────────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
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();
|
||||
Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0);
|
||||
Serial.print(" dz="); Serial.print(cfg.deadZone,3);
|
||||
Serial.print(" curve="); Serial.print(cfg.curve);
|
||||
Serial.print(" chg="); Serial.println(cfg.chargeMode);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
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");
|
||||
}
|
||||
#endif
|
||||
|
||||
// ─── BLE config service setup ─────────────────────────────────────────────────
|
||||
void setupConfigService() {
|
||||
cfgService.begin();
|
||||
|
||||
cfgBlob.setProperties(CHR_PROPS_READ | CHR_PROPS_WRITE);
|
||||
cfgBlob.setPermission(SECMODE_OPEN, SECMODE_OPEN);
|
||||
cfgBlob.setFixedLen(sizeof(ConfigBlob));
|
||||
cfgBlob.setWriteCallback(onConfigBlobWrite);
|
||||
cfgBlob.begin();
|
||||
pushConfigBlob();
|
||||
|
||||
cfgCommand.setProperties(CHR_PROPS_WRITE);
|
||||
cfgCommand.setPermission(SECMODE_OPEN, SECMODE_OPEN);
|
||||
cfgCommand.setFixedLen(1);
|
||||
cfgCommand.setWriteCallback(onCommandWrite);
|
||||
cfgCommand.begin();
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
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));
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
cfgImuStream.setProperties(CHR_PROPS_NOTIFY);
|
||||
cfgImuStream.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS);
|
||||
cfgImuStream.setFixedLen(sizeof(ImuPacket));
|
||||
cfgImuStream.setCccdWriteCallback(onImuStreamCccd);
|
||||
cfgImuStream.begin();
|
||||
#endif
|
||||
|
||||
Serial.print("[BLE] ATT_TABLE_SIZE="); Serial.print(ATT_TABLE_SIZE);
|
||||
Serial.print(" | chars=2");
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
Serial.print("+TELEM");
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
Serial.print("+STREAM");
|
||||
#endif
|
||||
Serial.println();
|
||||
}
|
||||
#endif // FEATURE_CONFIG_SERVICE
|
||||
|
||||
// ─── Telemetry push ───────────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
void pushTelemetry(unsigned long now) {
|
||||
telem.uptimeSeconds = now / 1000;
|
||||
telem.tempCelsius = cachedTempC;
|
||||
telem.biasRmsRadS = statBiasRms;
|
||||
telem.recalCount = statRecalCount;
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
telem.leftClicks = statLeftClicks;
|
||||
telem.rightClicks = statRightClicks;
|
||||
#endif
|
||||
cfgTelemetry.write ((uint8_t*)&telem, sizeof(telem));
|
||||
cfgTelemetry.notify((uint8_t*)&telem, sizeof(telem));
|
||||
}
|
||||
#endif
|
||||
29
source/ble_config.h
Normal file
29
source/ble_config.h
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include "config.h"
|
||||
#include <bluefruit.h>
|
||||
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
|
||||
extern BLEService cfgService;
|
||||
extern BLECharacteristic cfgBlob;
|
||||
extern BLECharacteristic cfgCommand;
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
extern BLECharacteristic cfgTelemetry;
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
extern BLECharacteristic cfgImuStream;
|
||||
#endif
|
||||
|
||||
void pushConfigBlob();
|
||||
void setupConfigService();
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
void pushTelemetry(unsigned long now);
|
||||
#endif
|
||||
|
||||
#endif // FEATURE_CONFIG_SERVICE
|
||||
|
||||
void applyChargeMode(ChargeMode mode);
|
||||
void loadConfig();
|
||||
void saveConfig();
|
||||
void factoryReset();
|
||||
184
source/config.h
Normal file
184
source/config.h
Normal file
@@ -0,0 +1,184 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
// ─── Feature Flags ────────────────────────────────────────────────────────────
|
||||
#define FEATURE_CONFIG_SERVICE
|
||||
#define FEATURE_TELEMETRY
|
||||
#define FEATURE_IMU_STREAM
|
||||
#define FEATURE_TAP_DETECTION
|
||||
#define FEATURE_TEMP_COMPENSATION
|
||||
#define FEATURE_AUTO_RECAL
|
||||
#define FEATURE_BATTERY_MONITOR
|
||||
#define FEATURE_BOOT_LOOP_DETECT
|
||||
|
||||
// ─── Debug ────────────────────────────────────────────────────────────────────
|
||||
// #define DEBUG
|
||||
|
||||
// ─── ATT table size ───────────────────────────────────────────────────────────
|
||||
#define _ATT_BASE 900
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
#define _ATT_CFG 80
|
||||
#else
|
||||
#define _ATT_CFG 0
|
||||
#endif
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
#define _ATT_TELEM 40
|
||||
#else
|
||||
#define _ATT_TELEM 0
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
#define _ATT_STREAM 30
|
||||
#else
|
||||
#define _ATT_STREAM 0
|
||||
#endif
|
||||
#define ATT_TABLE_SIZE_CALC (_ATT_BASE + _ATT_CFG + _ATT_TELEM + _ATT_STREAM)
|
||||
#define ATT_TABLE_SIZE (ATT_TABLE_SIZE_CALC < 1536 ? 1536 : ATT_TABLE_SIZE_CALC)
|
||||
|
||||
// ─── IMU register addresses ───────────────────────────────────────────────────
|
||||
#define REG_CTRL1_XL 0x10
|
||||
#define REG_TAP_CFG 0x58
|
||||
#define REG_TAP_THS_6D 0x59
|
||||
#define REG_INT_DUR2 0x5A
|
||||
#define REG_WAKE_UP_THS 0x5B
|
||||
#define REG_MD1_CFG 0x5E
|
||||
#define REG_TAP_SRC 0x1C
|
||||
#define REG_OUT_TEMP_L 0x20
|
||||
#define REG_OUT_TEMP_H 0x21
|
||||
|
||||
// ─── Pins ─────────────────────────────────────────────────────────────────────
|
||||
#define PIN_VBAT_ENABLE (14)
|
||||
#define PIN_VBAT_READ (32)
|
||||
#define PIN_CHG (23)
|
||||
#define PIN_HICHG (22)
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
#define CONFIG_FILENAME "/imu_mouse_cfg.bin"
|
||||
#define CONFIG_MAGIC 0xDEAD1239UL
|
||||
|
||||
// ─── 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 (stored in flash) ─────────────────────────────────────────────────
|
||||
struct Config {
|
||||
uint32_t magic;
|
||||
float sensitivity;
|
||||
float deadZone;
|
||||
float accelStrength;
|
||||
CurveType curve;
|
||||
uint8_t axisFlip;
|
||||
ChargeMode chargeMode;
|
||||
};
|
||||
extern Config cfg;
|
||||
extern const Config CFG_DEFAULTS;
|
||||
|
||||
// ─── ConfigBlob (over BLE, 16 bytes) ─────────────────────────────────────────
|
||||
struct __attribute__((packed)) ConfigBlob {
|
||||
float sensitivity; // [0]
|
||||
float deadZone; // [4]
|
||||
float accelStrength; // [8]
|
||||
uint8_t curve; // [12]
|
||||
uint8_t axisFlip; // [13]
|
||||
uint8_t chargeMode; // [14]
|
||||
uint8_t _pad; // [15]
|
||||
};
|
||||
static_assert(sizeof(ConfigBlob) == 16, "ConfigBlob must be 16 bytes");
|
||||
|
||||
// ─── TelemetryPacket (24 bytes) ───────────────────────────────────────────────
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
struct __attribute__((packed)) TelemetryPacket {
|
||||
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]
|
||||
uint8_t _pad; // [23]
|
||||
};
|
||||
static_assert(sizeof(TelemetryPacket) == 24, "TelemetryPacket must be 24 bytes");
|
||||
extern TelemetryPacket telem;
|
||||
#endif
|
||||
|
||||
// ─── ImuPacket (14 bytes) ─────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
struct __attribute__((packed)) ImuPacket {
|
||||
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]
|
||||
};
|
||||
static_assert(sizeof(ImuPacket) == 14, "ImuPacket must be 14 bytes");
|
||||
#endif
|
||||
|
||||
// ─── Tuning constants ─────────────────────────────────────────────────────────
|
||||
extern const float ALPHA;
|
||||
extern const int LOOP_RATE_MS;
|
||||
extern const int BIAS_SAMPLES;
|
||||
extern const int IDLE_FRAMES;
|
||||
extern const unsigned long BATT_REPORT_MS;
|
||||
extern const unsigned long TELEMETRY_MS;
|
||||
extern const unsigned long HEARTBEAT_MS;
|
||||
extern const int HEARTBEAT_DUR;
|
||||
extern const unsigned long BOOT_SAFE_MS;
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
extern const unsigned long IMU_STREAM_RATE_MS;
|
||||
#endif
|
||||
extern const float BATT_FULL;
|
||||
extern const float BATT_EMPTY;
|
||||
extern const float BATT_CRITICAL;
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
extern const unsigned long CLICK_HOLD_MS;
|
||||
#endif
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
extern const float TEMP_COMP_COEFF_DPS_C;
|
||||
#endif
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
extern const unsigned long AUTO_RECAL_MS;
|
||||
#endif
|
||||
|
||||
// ─── Global state ─────────────────────────────────────────────────────────────
|
||||
extern float angleX, angleY;
|
||||
extern float accumX, accumY;
|
||||
extern float biasGX, biasGY, biasGZ;
|
||||
extern float calTempC;
|
||||
extern float cachedTempC;
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
extern bool clickButtonDown;
|
||||
extern uint8_t clickButton;
|
||||
extern unsigned long clickDownMs;
|
||||
extern uint32_t statLeftClicks;
|
||||
extern uint32_t statRightClicks;
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
extern bool imuStreamEnabled;
|
||||
#endif
|
||||
|
||||
extern bool pendingCal;
|
||||
extern bool pendingReset;
|
||||
extern ChargeStatus lastChargeStatus;
|
||||
extern int idleFrames;
|
||||
extern unsigned long idleStartMs;
|
||||
extern unsigned long lastTime;
|
||||
extern unsigned long lastBattTime;
|
||||
extern unsigned long lastHeartbeat;
|
||||
extern unsigned long lastTelemetry;
|
||||
extern unsigned long bootStartMs;
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
extern unsigned long lastImuStream;
|
||||
#endif
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
extern uint16_t statRecalCount;
|
||||
extern float statBiasRms;
|
||||
#endif
|
||||
|
||||
extern bool safeMode;
|
||||
extern bool bootCountCleared;
|
||||
60
source/imu.cpp
Normal file
60
source/imu.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include "imu.h"
|
||||
#include "Wire.h"
|
||||
|
||||
LSM6DS3 imu(I2C_MODE, 0x6A);
|
||||
|
||||
// ─── I2C helpers ──────────────────────────────────────────────────────────────
|
||||
void imuWriteReg(uint8_t reg, uint8_t val) {
|
||||
// LSM6DS3 is on Wire1 (internal I2C, SDA=P0.17, SCL=P0.16), NOT Wire (external pins 4/5)
|
||||
Wire1.beginTransmission(0x6A); Wire1.write(reg); Wire1.write(val); Wire1.endTransmission();
|
||||
}
|
||||
|
||||
uint8_t imuReadReg(uint8_t reg) {
|
||||
Wire1.beginTransmission(0x6A); Wire1.write(reg); Wire1.endTransmission(false);
|
||||
Wire1.requestFrom((uint8_t)0x6A, (uint8_t)1);
|
||||
return Wire1.available() ? Wire1.read() : 0;
|
||||
}
|
||||
|
||||
// ─── Temperature ──────────────────────────────────────────────────────────────
|
||||
float readIMUTemp() {
|
||||
int16_t raw = (int16_t)((imuReadReg(REG_OUT_TEMP_H) << 8) | imuReadReg(REG_OUT_TEMP_L));
|
||||
return 25.0f + (float)raw / 256.0f;
|
||||
}
|
||||
|
||||
// ─── Calibration ──────────────────────────────────────────────────────────────
|
||||
void calibrateGyroBias() {
|
||||
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)(sx/BIAS_SAMPLES);
|
||||
biasGY = (float)(sy/BIAS_SAMPLES);
|
||||
biasGZ = (float)(sz/BIAS_SAMPLES);
|
||||
calTempC = readIMUTemp();
|
||||
angleX = angleY = accumX = accumY = 0.0f;
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
statRecalCount++;
|
||||
float bxr = biasGX*(PI/180.f), byr = biasGY*(PI/180.f), bzr = biasGZ*(PI/180.f);
|
||||
statBiasRms = sqrtf((bxr*bxr + byr*byr + bzr*bzr) / 3.0f);
|
||||
#endif
|
||||
|
||||
digitalWrite(LED_BLUE, HIGH);
|
||||
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 ? 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); }
|
||||
12
source/imu.h
Normal file
12
source/imu.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include "config.h"
|
||||
#include "LSM6DS3.h"
|
||||
|
||||
extern LSM6DS3 imu;
|
||||
|
||||
void imuWriteReg(uint8_t reg, uint8_t val);
|
||||
uint8_t imuReadReg(uint8_t reg);
|
||||
float readIMUTemp();
|
||||
void calibrateGyroBias();
|
||||
float applyCurve(float v);
|
||||
float applyAcceleration(float d);
|
||||
384
source/main.cpp
Normal file
384
source/main.cpp
Normal file
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* IMU BLE Mouse — Seeed XIAO nRF52840 Sense (v3.4)
|
||||
* ================================================================
|
||||
* Feature flags — comment out any line to disable that feature.
|
||||
* ATT table size is computed automatically from enabled features.
|
||||
* Start with minimal flags to isolate the SoftDevice RAM issue,
|
||||
* then re-enable one at a time.
|
||||
*
|
||||
* MINIMUM (just working mouse, no BLE config):
|
||||
* leave only FEATURE_BATTERY_MONITOR + FEATURE_BOOT_LOOP_DETECT
|
||||
*
|
||||
* RECOMMENDED first test:
|
||||
* enable FEATURE_CONFIG_SERVICE, keep TAP + STREAM + TELEMETRY off
|
||||
*
|
||||
* ── Feature flag index ───────────────────────────────────────────
|
||||
* FEATURE_CONFIG_SERVICE Custom GATT service (ConfigBlob + Command)
|
||||
* FEATURE_TELEMETRY +24-byte notify characteristic, 1 Hz
|
||||
* FEATURE_IMU_STREAM +14-byte notify characteristic, ~100 Hz
|
||||
* FEATURE_TAP_DETECTION LSM6DS3 hardware tap engine → L/R clicks
|
||||
* FEATURE_TEMP_COMPENSATION Gyro drift correction by temperature delta
|
||||
* FEATURE_AUTO_RECAL Recalibrate after AUTO_RECAL_MS idle
|
||||
* FEATURE_BATTERY_MONITOR ADC battery read + BLE Battery Service
|
||||
* FEATURE_BOOT_LOOP_DETECT .noinit crash counter → safe mode
|
||||
*
|
||||
* Dependencies:
|
||||
* FEATURE_TELEMETRY requires FEATURE_CONFIG_SERVICE
|
||||
* FEATURE_IMU_STREAM requires FEATURE_CONFIG_SERVICE
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
#include "imu.h"
|
||||
#include "ble_config.h"
|
||||
#include "battery.h"
|
||||
#include "tap.h"
|
||||
#include <bluefruit.h>
|
||||
#include <Adafruit_LittleFS.h>
|
||||
#include <InternalFileSystem.h>
|
||||
#include "Wire.h"
|
||||
|
||||
// ─── Boot-loop detection ──────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
static uint32_t __attribute__((section(".noinit"))) bootCount;
|
||||
static uint32_t __attribute__((section(".noinit"))) bootMagic;
|
||||
#endif
|
||||
|
||||
// ─── BLE Standard Services ────────────────────────────────────────────────────
|
||||
BLEDis bledis;
|
||||
BLEHidAdafruit blehid;
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
BLEBas blebas;
|
||||
#endif
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
File cfgFile(InternalFS);
|
||||
|
||||
// ─── Config definitions ───────────────────────────────────────────────────────
|
||||
Config cfg;
|
||||
const Config CFG_DEFAULTS = {
|
||||
CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW
|
||||
};
|
||||
|
||||
// ─── Telemetry definition ─────────────────────────────────────────────────────
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
TelemetryPacket telem = {};
|
||||
#endif
|
||||
|
||||
// ─── Tuning constants ─────────────────────────────────────────────────────────
|
||||
const float ALPHA = 0.96f;
|
||||
const int LOOP_RATE_MS = 10;
|
||||
const int BIAS_SAMPLES = 200;
|
||||
const int IDLE_FRAMES = 150;
|
||||
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 BOOT_SAFE_MS = 5000;
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
const unsigned long IMU_STREAM_RATE_MS = 50;
|
||||
#endif
|
||||
const float BATT_FULL = 4.20f;
|
||||
const float BATT_EMPTY = 3.00f;
|
||||
const float BATT_CRITICAL = 3.10f;
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
const unsigned long CLICK_HOLD_MS = 60;
|
||||
#endif
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
const float TEMP_COMP_COEFF_DPS_C = 0.004f;
|
||||
#endif
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
const unsigned long AUTO_RECAL_MS = 5UL * 60UL * 1000UL;
|
||||
#endif
|
||||
|
||||
// ─── Global state definitions ─────────────────────────────────────────────────
|
||||
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;
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
bool clickButtonDown = false;
|
||||
uint8_t clickButton = 0;
|
||||
unsigned long clickDownMs= 0;
|
||||
uint32_t statLeftClicks = 0;
|
||||
uint32_t statRightClicks = 0;
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
bool imuStreamEnabled = false;
|
||||
#endif
|
||||
|
||||
bool pendingCal = false;
|
||||
bool pendingReset = false;
|
||||
|
||||
ChargeStatus lastChargeStatus = CHGSTAT_DISCHARGING;
|
||||
|
||||
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;
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
unsigned long lastImuStream = 0;
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
uint16_t statRecalCount = 0;
|
||||
float statBiasRms = 0.0f;
|
||||
#endif
|
||||
|
||||
bool safeMode = false;
|
||||
bool bootCountCleared = false;
|
||||
|
||||
// ─── Advertising ─────────────────────────────────────────────────────────────
|
||||
static void startAdvertising() {
|
||||
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
|
||||
Bluefruit.Advertising.addTxPower();
|
||||
Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_MOUSE);
|
||||
Bluefruit.Advertising.addService(blehid);
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
Bluefruit.Advertising.addService(blebas);
|
||||
#endif
|
||||
Bluefruit.Advertising.addName();
|
||||
Bluefruit.Advertising.restartOnDisconnect(true);
|
||||
Bluefruit.Advertising.setInterval(32, 244);
|
||||
Bluefruit.Advertising.setFastTimeout(30);
|
||||
Bluefruit.Advertising.start(0);
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
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);
|
||||
|
||||
// ── Boot-loop detection ───────────────────────────────────────────────────
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
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 — safe mode (no config service)");
|
||||
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); }
|
||||
}
|
||||
#endif
|
||||
|
||||
loadConfig();
|
||||
applyChargeMode(cfg.chargeMode);
|
||||
|
||||
// configAttrTableSize MUST be called before Bluefruit.begin().
|
||||
// Value must be >= what HID+DIS+BAS+custom services actually need.
|
||||
// Too small crashes just as hard as too large. Floor is 1536.
|
||||
Serial.print("[BLE] ATT table: "); Serial.print(ATT_TABLE_SIZE); Serial.println(" bytes");
|
||||
Bluefruit.configAttrTableSize(ATT_TABLE_SIZE);
|
||||
|
||||
Bluefruit.begin(1, 0);
|
||||
Bluefruit.setTxPower(4);
|
||||
Bluefruit.setName(safeMode ? "IMU Mouse (safe)" : "IMU Mouse");
|
||||
Bluefruit.Periph.setConnInterval(12, 24); // 15-30ms — less aggressive, prevents stream disconnect
|
||||
|
||||
Wire1.begin(); // LSM6DS3 is on internal I2C bus (Wire1), must init before imu.begin()
|
||||
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");
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
setupTapDetection();
|
||||
#endif
|
||||
|
||||
cachedTempC = readIMUTemp();
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
initBatteryADC();
|
||||
updateBattery();
|
||||
#endif
|
||||
|
||||
calibrateGyroBias();
|
||||
|
||||
bledis.setManufacturer("Seeed Studio");
|
||||
bledis.setModel("XIAO nRF52840 Sense");
|
||||
bledis.begin();
|
||||
|
||||
blehid.begin();
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
blebas.begin(); blebas.write(100);
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
if (!safeMode) {
|
||||
setupConfigService();
|
||||
Serial.println("[OK] Config service started");
|
||||
} else {
|
||||
Serial.println("[SAFE] Config service skipped");
|
||||
}
|
||||
#endif
|
||||
|
||||
startAdvertising();
|
||||
Serial.print("[OK] Advertising — features:");
|
||||
#ifdef FEATURE_CONFIG_SERVICE
|
||||
Serial.print(" CFG");
|
||||
#endif
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
Serial.print(" TELEM");
|
||||
#endif
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
Serial.print(" STREAM");
|
||||
#endif
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
Serial.print(" TAP");
|
||||
#endif
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
Serial.print(" TEMPCOMP");
|
||||
#endif
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
Serial.print(" AUTORECAL");
|
||||
#endif
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
Serial.print(" BATT");
|
||||
#endif
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
Serial.print(" BOOTDET");
|
||||
#endif
|
||||
Serial.println();
|
||||
|
||||
bootStartMs = millis();
|
||||
lastTime = lastBattTime = lastHeartbeat = lastTelemetry = millis();
|
||||
}
|
||||
|
||||
// ─── Loop ─────────────────────────────────────────────────────────────────────
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
|
||||
// Clear boot counter after BOOT_SAFE_MS of stable running
|
||||
#ifdef FEATURE_BOOT_LOOP_DETECT
|
||||
if (!bootCountCleared && (now - bootStartMs >= BOOT_SAFE_MS)) {
|
||||
bootCount = 0; bootCountCleared = true;
|
||||
Serial.println("[BOOT] Stable — counter cleared");
|
||||
}
|
||||
#endif
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#ifdef FEATURE_BATTERY_MONITOR
|
||||
if (now - lastBattTime >= BATT_REPORT_MS) { lastBattTime = now; updateBattery(); }
|
||||
#endif
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
processTaps(now);
|
||||
#endif
|
||||
|
||||
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;
|
||||
|
||||
cachedTempC = readIMUTemp();
|
||||
|
||||
#ifdef FEATURE_TELEMETRY
|
||||
if (!safeMode && (now - lastTelemetry >= TELEMETRY_MS)) {
|
||||
lastTelemetry = now; pushTelemetry(now);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Gyro reads with optional temperature compensation
|
||||
float gx, gy, gz;
|
||||
#ifdef FEATURE_TEMP_COMPENSATION
|
||||
float correction = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC);
|
||||
gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f);
|
||||
gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f);
|
||||
gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f);
|
||||
#else
|
||||
gx = (imu.readFloatGyroX() - biasGX) * (PI/180.0f);
|
||||
gy = (imu.readFloatGyroY() - biasGY) * (PI/180.0f);
|
||||
gz = (imu.readFloatGyroZ() - biasGZ) * (PI/180.0f);
|
||||
#endif
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
#ifdef FEATURE_AUTO_RECAL
|
||||
if (idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) {
|
||||
Serial.println("[AUTO-CAL] Long idle — recalibrating...");
|
||||
idleStartMs = 0; calibrateGyroBias(); return;
|
||||
}
|
||||
#endif
|
||||
|
||||
int8_t moveX = 0, moveY = 0;
|
||||
uint8_t flags = 0;
|
||||
|
||||
if (idle) {
|
||||
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);
|
||||
}
|
||||
|
||||
#ifdef FEATURE_IMU_STREAM
|
||||
if (!safeMode && imuStreamEnabled && Bluefruit.connected()
|
||||
&& (now - lastImuStream >= IMU_STREAM_RATE_MS)) {
|
||||
lastImuStream = now;
|
||||
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));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef DEBUG
|
||||
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
|
||||
}
|
||||
49
source/tap.cpp
Normal file
49
source/tap.cpp
Normal file
@@ -0,0 +1,49 @@
|
||||
#include "tap.h"
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
#include "imu.h"
|
||||
#include <bluefruit.h>
|
||||
|
||||
extern BLEHidAdafruit blehid;
|
||||
|
||||
// ─── Tap detection setup ──────────────────────────────────────────────────────
|
||||
void setupTapDetection() {
|
||||
imuWriteReg(REG_CTRL1_XL, 0x60); // ODR=416Hz, FS=±2g
|
||||
imuWriteReg(REG_TAP_CFG, 0x8E); // INT_EN + LIR + TAP_Z/Y/X
|
||||
imuWriteReg(REG_TAP_THS_6D, 0x0C); // threshold 750 mg (was 500 mg — too easy to false-trigger)
|
||||
imuWriteReg(REG_INT_DUR2, 0x7A); // DUR=7(538ms), QUIET=2(19ms), SHOCK=2(38ms)
|
||||
imuWriteReg(REG_WAKE_UP_THS, 0x80); // enable double-tap
|
||||
imuWriteReg(REG_MD1_CFG, 0x48); // route taps to INT1
|
||||
Serial.println("[TAP] Engine configured — single=LEFT, double=RIGHT");
|
||||
}
|
||||
|
||||
// ─── Tap processing ───────────────────────────────────────────────────────────
|
||||
void processTaps(unsigned long now) {
|
||||
// Release held button after CLICK_HOLD_MS
|
||||
if (clickButtonDown && (now - clickDownMs >= CLICK_HOLD_MS)) {
|
||||
blehid.mouseButtonPress(clickButton, false);
|
||||
clickButtonDown = false; clickButton = 0;
|
||||
}
|
||||
if (clickButtonDown) return; // Don't start a new click while one is held
|
||||
|
||||
// The LSM6DS3 (with D_TAP_EN) already disambiguates at hardware level:
|
||||
// SINGLE_TAP is only set after the DUR window expires with no second tap.
|
||||
// DOUBLE_TAP is set immediately when the second tap arrives within DUR.
|
||||
// We trust this directly — no software delay needed.
|
||||
uint8_t tapSrc = imuReadReg(REG_TAP_SRC);
|
||||
if (!(tapSrc & 0x40)) return; // TAP_IA not set — no event
|
||||
|
||||
if (tapSrc & 0x10) { // DOUBLE_TAP → right click
|
||||
Serial.println("[TAP] Double → RIGHT");
|
||||
blehid.mouseButtonPress(MOUSE_BUTTON_RIGHT, true);
|
||||
clickButton = MOUSE_BUTTON_RIGHT; clickButtonDown = true; clickDownMs = now;
|
||||
statRightClicks++;
|
||||
} else if (tapSrc & 0x20) { // SINGLE_TAP → left click
|
||||
Serial.println("[TAP] Single → LEFT");
|
||||
blehid.mouseButtonPress(MOUSE_BUTTON_LEFT, true);
|
||||
clickButton = MOUSE_BUTTON_LEFT; clickButtonDown = true; clickDownMs = now;
|
||||
statLeftClicks++;
|
||||
}
|
||||
}
|
||||
|
||||
#endif // FEATURE_TAP_DETECTION
|
||||
7
source/tap.h
Normal file
7
source/tap.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include "config.h"
|
||||
|
||||
#ifdef FEATURE_TAP_DETECTION
|
||||
void setupTapDetection();
|
||||
void processTaps(unsigned long now);
|
||||
#endif
|
||||
449
web/app.js
Normal file
449
web/app.js
Normal file
@@ -0,0 +1,449 @@
|
||||
// ── 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={}, userDisconnected=false;
|
||||
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');
|
||||
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
||||
|
||||
// ── Connection ───────────────────────────────────────────────────────────────
|
||||
async function doConnect() {
|
||||
if (!navigator.bluetooth) { log('Web Bluetooth not supported.','err'); return; }
|
||||
userDisconnected = false;
|
||||
setStatus('connecting');
|
||||
log('Scanning for IMU Mouse…','info');
|
||||
try {
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters:[{name:'IMU Mouse'},{name:'IMU Mouse (safe)'}],
|
||||
optionalServices:[SVC_UUID,'battery_service']
|
||||
});
|
||||
device.addEventListener('gattserverdisconnected', onDisconnected);
|
||||
log(`Found: ${device.name}`,'ok');
|
||||
server = await device.gatt.connect();
|
||||
log('GATT connected','ok');
|
||||
await discoverServices();
|
||||
setStatus('connected');
|
||||
log('Ready','ok');
|
||||
} catch(e) { log(`Connection failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (device && device.gatt.connected) {
|
||||
userDisconnected = true;
|
||||
log('Disconnecting…','warn');
|
||||
device.gatt.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverServices() {
|
||||
log('Discovering services…','info');
|
||||
try {
|
||||
const svc = await server.getPrimaryService(SVC_UUID);
|
||||
|
||||
chars.configBlob = await svc.getCharacteristic(CHR.configBlob);
|
||||
chars.command = await svc.getCharacteristic(CHR.command);
|
||||
chars.telemetry = await svc.getCharacteristic(CHR.telemetry);
|
||||
chars.imuStream = await svc.getCharacteristic(CHR.imuStream);
|
||||
|
||||
// Read config blob and populate UI
|
||||
await readConfigBlob();
|
||||
|
||||
// Telemetry notify (1 Hz) — also carries chargeStatus
|
||||
chars.telemetry.addEventListener('characteristicvaluechanged', e => parseTelemetry(e.target.value));
|
||||
await chars.telemetry.startNotifications();
|
||||
// Initial read so values show immediately
|
||||
parseTelemetry(await chars.telemetry.readValue());
|
||||
|
||||
// IMU stream notify (~100 Hz)
|
||||
chars.imuStream.addEventListener('characteristicvaluechanged', e => parseImuStream(e.target.value));
|
||||
await chars.imuStream.startNotifications();
|
||||
document.getElementById('vizLive').classList.add('on');
|
||||
log('IMU stream subscribed','ok');
|
||||
|
||||
log('Config service ready (4 chars)','ok');
|
||||
} catch(e) {
|
||||
log(`Service discovery failed: ${e.message}`,'err');
|
||||
// Safe mode device might not have config service
|
||||
if (e.message.includes('not found')) log('Device may be in safe mode — basic mouse only','warn');
|
||||
}
|
||||
|
||||
// Battery service (standard — always present)
|
||||
try {
|
||||
const bsvc = await server.getPrimaryService('battery_service');
|
||||
const bch = await bsvc.getCharacteristic('battery_level');
|
||||
bch.addEventListener('characteristicvaluechanged', e => {
|
||||
currentBattPct = e.target.value.getUint8(0);
|
||||
updateBatteryBar(currentBattPct, currentChargeStatus);
|
||||
});
|
||||
await bch.startNotifications();
|
||||
const v = await bch.readValue();
|
||||
currentBattPct = v.getUint8(0);
|
||||
updateBatteryBar(currentBattPct, currentChargeStatus);
|
||||
log(`Battery: ${currentBattPct}%`,'ok');
|
||||
} catch(e) { log('Battery service unavailable','warn'); }
|
||||
}
|
||||
|
||||
// ── ConfigBlob read / write ──────────────────────────────────────────────────
|
||||
// ConfigBlob layout (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.toFixed(4);
|
||||
document.getElementById('telRecal').textContent = recalCount;
|
||||
const tEl = document.getElementById('telTemp');
|
||||
tEl.className = 'telem-val '+(temp>40?'warn':'accent');
|
||||
|
||||
// chargeStatus is now delivered via telemetry (no separate characteristic)
|
||||
if (chargeStatus !== currentChargeStatus) {
|
||||
currentChargeStatus = chargeStatus;
|
||||
updateChargeUI();
|
||||
}
|
||||
}
|
||||
function formatUptime(s) {
|
||||
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), ss=s%60;
|
||||
return h>0 ? `${h}h ${p2(m)}m` : `${m}m ${p2(ss)}s`;
|
||||
}
|
||||
function clearTelemetry() {
|
||||
['telTemp','telUptime','telLeft','telRight','telBias','telRecal'].forEach(id=>
|
||||
document.getElementById(id).textContent='--');
|
||||
}
|
||||
|
||||
// ── Battery & Charge UI ───────────────────────────────────────────────────────
|
||||
function updateBatteryBar(pct, status) {
|
||||
document.getElementById('battBar').style.display='flex';
|
||||
document.getElementById('battPct').textContent=pct+'%';
|
||||
document.getElementById('ciPct').textContent=pct+'%';
|
||||
document.getElementById('badgeCharging').classList.toggle('show', status===1);
|
||||
document.getElementById('badgeFull').classList.toggle('show', status===2);
|
||||
const cells=document.getElementById('battCells'); cells.innerHTML='';
|
||||
const filled=Math.round(pct/10);
|
||||
for (let i=0;i<10;i++) {
|
||||
const c=document.createElement('div'); c.className='batt-cell';
|
||||
if (i<filled) c.className+=status===1?' f charging':pct<=20?' f crit':pct<=40?' f warn':' f';
|
||||
cells.appendChild(c);
|
||||
}
|
||||
}
|
||||
function updateChargeUI() {
|
||||
const sl=['Discharging','Charging','Full'];
|
||||
const sc=['var(--label)','var(--accent)','var(--ok)'];
|
||||
const el=document.getElementById('ciStatus');
|
||||
el.textContent=sl[currentChargeStatus]??'--';
|
||||
el.style.color=sc[currentChargeStatus]??'var(--label)';
|
||||
if (currentBattPct!==null) updateBatteryBar(currentBattPct, currentChargeStatus);
|
||||
}
|
||||
|
||||
// ── Param display ─────────────────────────────────────────────────────────────
|
||||
function updateDisplay(key, val) {
|
||||
const map = {
|
||||
sensitivity: ['valSensitivity', v=>parseFloat(v).toFixed(0)],
|
||||
deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)],
|
||||
accel: ['valAccel', v=>parseFloat(v).toFixed(2)],
|
||||
};
|
||||
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');
|
||||
const savedDevice = device;
|
||||
chars={}; device=null; server=null;
|
||||
setStatus('disconnected');
|
||||
document.getElementById('battBar').style.display='none';
|
||||
document.getElementById('badgeCharging').classList.remove('show');
|
||||
document.getElementById('badgeFull').classList.remove('show');
|
||||
document.getElementById('vizLive').classList.remove('on');
|
||||
clearTelemetry();
|
||||
if (!userDisconnected && document.getElementById('autoReconnect').checked && savedDevice) {
|
||||
log('Auto-reconnecting…','info');
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
setStatus('connecting');
|
||||
server = await savedDevice.gatt.connect();
|
||||
device = savedDevice;
|
||||
userDisconnected = false;
|
||||
log('GATT reconnected','ok');
|
||||
await discoverServices();
|
||||
setStatus('connected');
|
||||
log('Ready','ok');
|
||||
} catch(e) { log(`Reconnect failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
||||
}, 1000);
|
||||
} else {
|
||||
userDisconnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IMU Stream + Visualiser ──────────────────────────────────────────────────
|
||||
// ImuPacket (14 bytes LE):
|
||||
// int16 gyroY_mDPS [0], int16 gyroZ_mDPS [2]
|
||||
// int16 accelX_mg [4], int16 accelY_mg [6], int16 accelZ_mg [8]
|
||||
// int8 moveX [10], int8 moveY [11], uint8 flags [12], uint8 pad [13]
|
||||
const canvas = document.getElementById('vizCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const TRAIL_LEN = 120;
|
||||
let cursorX = canvas.width/2, cursorY = canvas.height/2, trail = [];
|
||||
|
||||
function parseImuStream(dv) {
|
||||
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=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';
|
||||
}
|
||||
// ── 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();
|
||||
}
|
||||
(function(){
|
||||
const saved = localStorage.getItem('theme') ?? 'auto';
|
||||
themeIdx = Math.max(0, THEMES.indexOf(saved));
|
||||
applyTheme(saved);
|
||||
})();
|
||||
|
||||
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');
|
||||
}
|
||||
732
web/index.html
732
web/index.html
@@ -5,281 +5,7 @@
|
||||
<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;
|
||||
/* updated to match theme-dark overrides, so auto:dark equals manual dark */
|
||||
--dim: #7a8490;
|
||||
--text: #f0f2f5;
|
||||
--label: #9faab7;
|
||||
--mono: 'Share Tech Mono', monospace;
|
||||
--sans: 'Barlow Condensed', sans-serif;
|
||||
--logo-color: #ffffff;
|
||||
/* Component tokens */
|
||||
--header-bg: rgba(10,12,14,0.96);
|
||||
--scanline: rgba(0,0,0,0.07);
|
||||
--hover-desc-color: rgba(10,12,14,0.65);
|
||||
/* Canvas */
|
||||
--canvas-bg: #0d1013;
|
||||
--canvas-fade: rgba(13,16,19,0.25);
|
||||
--canvas-grid: rgba(31,36,40,0.6);
|
||||
--canvas-center: rgba(58,64,80,0.5);
|
||||
--canvas-idle-text: rgba(90,100,128,0.7);
|
||||
--trail-rgb: 0,229,255;
|
||||
--trail-idle-rgb: 58,64,80;
|
||||
--canvas-dot: #00e5ff;
|
||||
--canvas-dot-idle: #3a4050;
|
||||
--canvas-dot-glow: rgba(0,229,255,0.35);
|
||||
/* Tap flashes */
|
||||
--tap-left: rgba(0,229,255,0.35);
|
||||
--tap-right: rgba(255,61,113,0.35);
|
||||
}
|
||||
|
||||
/* ── Light theme (explicit) ──────────────────────────────────────────────── */
|
||||
:root.theme-light {
|
||||
--bg: #f0f2f5;
|
||||
--panel: #ffffff;
|
||||
--panel2: #e8eaed;
|
||||
--border: #b8bec8;
|
||||
--accent: #006699;
|
||||
--accent2: #c01a50;
|
||||
--warn: #b36a00;
|
||||
--ok: #007040;
|
||||
--dim: #848ea4;
|
||||
--text: #0f1118;
|
||||
--label: #4e566e;
|
||||
--logo-color: #0f1118;
|
||||
--header-bg: rgba(240,242,245,0.96);
|
||||
--scanline: rgba(0,0,0,0.03);
|
||||
--hover-desc-color: rgba(240,242,245,0.7);
|
||||
--canvas-bg: #e8eaed;
|
||||
--canvas-fade: rgba(232,234,237,0.35);
|
||||
--canvas-grid: rgba(195,200,210,0.7);
|
||||
--canvas-center: rgba(155,162,178,0.6);
|
||||
--canvas-idle-text: rgba(100,112,140,0.8);
|
||||
--trail-rgb: 0,100,170;
|
||||
--trail-idle-rgb: 155,162,178;
|
||||
--canvas-dot: #0077aa;
|
||||
--canvas-dot-idle: #a8b0c0;
|
||||
--canvas-dot-glow: rgba(0,100,170,0.3);
|
||||
--tap-left: rgba(0,100,170,0.35);
|
||||
--tap-right: rgba(200,30,80,0.35);
|
||||
}
|
||||
|
||||
|
||||
/* ── Auto light (OS hint; explicit class overrides) ──────────────────────── */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not(.theme-dark) {
|
||||
--bg: #f0f2f5;
|
||||
--panel: #ffffff;
|
||||
--panel2: #e8eaed;
|
||||
--border: #b8bec8;
|
||||
--accent: #006699;
|
||||
--accent2: #c01a50;
|
||||
--warn: #b36a00;
|
||||
--ok: #007040;
|
||||
--dim: #848ea4;
|
||||
--text: #0f1118;
|
||||
--label: #4e566e;
|
||||
--logo-color: #0f1118;
|
||||
--header-bg: rgba(240,242,245,0.96);
|
||||
--scanline: rgba(0,0,0,0.03);
|
||||
--hover-desc-color: rgba(240,242,245,0.7);
|
||||
--canvas-bg: #e8eaed;
|
||||
--canvas-fade: rgba(232,234,237,0.35);
|
||||
--canvas-grid: rgba(195,200,210,0.7);
|
||||
--canvas-center: rgba(155,162,178,0.6);
|
||||
--canvas-idle-text: rgba(100,112,140,0.8);
|
||||
--trail-rgb: 0,100,170;
|
||||
--trail-idle-rgb: 155,162,178;
|
||||
--canvas-dot: #0077aa;
|
||||
--canvas-dot-idle: #a8b0c0;
|
||||
--canvas-dot-glow: rgba(0,100,170,0.3);
|
||||
--tap-left: rgba(0,100,170,0.35);
|
||||
--tap-right: rgba(200,30,80,0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* { 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,var(--scanline) 2px,var(--scanline) 4px); }
|
||||
|
||||
header { border-bottom:1px solid var(--border); padding:16px 28px; display:flex; align-items:center; gap:20px; position:sticky; top:0; background:var(--header-bg); backdrop-filter:blur(10px); z-index:100; }
|
||||
.logo { font-family:var(--sans); font-weight:900; font-size:22px; letter-spacing:0.08em; color:var(--logo-color); 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); }
|
||||
.btn-theme { border:1px solid var(--dim); color:var(--label); min-width:72px; text-align:center; }
|
||||
.btn-theme::before { background:var(--text); }
|
||||
|
||||
.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:var(--hover-desc-color); }
|
||||
.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, var(--tap-left) 0%, transparent 70%); }
|
||||
.tap-flash.right { background:radial-gradient(circle at center, var(--tap-right) 0%, transparent 70%); }
|
||||
.tap-flash.show { opacity:1; }
|
||||
.viz-wrap { position:relative; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="disconnected">
|
||||
|
||||
@@ -446,456 +172,8 @@
|
||||
</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={}, userDisconnected=false;
|
||||
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');
|
||||
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
||||
|
||||
// ── Connection ───────────────────────────────────────────────────────────────
|
||||
async function doConnect() {
|
||||
if (!navigator.bluetooth) { log('Web Bluetooth not supported.','err'); return; }
|
||||
userDisconnected = false;
|
||||
setStatus('connecting');
|
||||
log('Scanning for IMU Mouse…','info');
|
||||
try {
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters:[{name:'IMU Mouse'},{name:'IMU Mouse (safe)'}],
|
||||
optionalServices:[SVC_UUID,'battery_service']
|
||||
});
|
||||
device.addEventListener('gattserverdisconnected', onDisconnected);
|
||||
log(`Found: ${device.name}`,'ok');
|
||||
server = await device.gatt.connect();
|
||||
log('GATT connected','ok');
|
||||
await discoverServices();
|
||||
setStatus('connected');
|
||||
log('Ready','ok');
|
||||
} catch(e) { log(`Connection failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (device && device.gatt.connected) {
|
||||
userDisconnected = true;
|
||||
log('Disconnecting…','warn');
|
||||
device.gatt.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverServices() {
|
||||
log('Discovering services…','info');
|
||||
try {
|
||||
const svc = await server.getPrimaryService(SVC_UUID);
|
||||
|
||||
chars.configBlob = await svc.getCharacteristic(CHR.configBlob);
|
||||
chars.command = await svc.getCharacteristic(CHR.command);
|
||||
chars.telemetry = await svc.getCharacteristic(CHR.telemetry);
|
||||
chars.imuStream = await svc.getCharacteristic(CHR.imuStream);
|
||||
|
||||
// Read config blob and populate UI
|
||||
await readConfigBlob();
|
||||
|
||||
// Telemetry notify (1 Hz) — also carries chargeStatus
|
||||
chars.telemetry.addEventListener('characteristicvaluechanged', e => parseTelemetry(e.target.value));
|
||||
await chars.telemetry.startNotifications();
|
||||
// Initial read so values show immediately
|
||||
parseTelemetry(await chars.telemetry.readValue());
|
||||
|
||||
// IMU stream notify (~100 Hz)
|
||||
chars.imuStream.addEventListener('characteristicvaluechanged', e => parseImuStream(e.target.value));
|
||||
await chars.imuStream.startNotifications();
|
||||
document.getElementById('vizLive').classList.add('on');
|
||||
log('IMU stream subscribed','ok');
|
||||
|
||||
log('Config service ready (4 chars)','ok');
|
||||
} catch(e) {
|
||||
log(`Service discovery failed: ${e.message}`,'err');
|
||||
// Safe mode device might not have config service
|
||||
if (e.message.includes('not found')) log('Device may be in safe mode — basic mouse only','warn');
|
||||
}
|
||||
|
||||
// Battery service (standard — always present)
|
||||
try {
|
||||
const bsvc = await server.getPrimaryService('battery_service');
|
||||
const bch = await bsvc.getCharacteristic('battery_level');
|
||||
bch.addEventListener('characteristicvaluechanged', e => {
|
||||
currentBattPct = e.target.value.getUint8(0);
|
||||
updateBatteryBar(currentBattPct, currentChargeStatus);
|
||||
});
|
||||
await bch.startNotifications();
|
||||
const v = await bch.readValue();
|
||||
currentBattPct = v.getUint8(0);
|
||||
updateBatteryBar(currentBattPct, currentChargeStatus);
|
||||
log(`Battery: ${currentBattPct}%`,'ok');
|
||||
} catch(e) { log('Battery service unavailable','warn'); }
|
||||
}
|
||||
|
||||
// ── ConfigBlob read / write ──────────────────────────────────────────────────
|
||||
// ConfigBlob layout (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.toFixed(4);
|
||||
document.getElementById('telRecal').textContent = recalCount;
|
||||
const tEl = document.getElementById('telTemp');
|
||||
tEl.className = 'telem-val '+(temp>40?'warn':'accent');
|
||||
|
||||
// chargeStatus is now delivered via telemetry (no separate characteristic)
|
||||
if (chargeStatus !== currentChargeStatus) {
|
||||
currentChargeStatus = chargeStatus;
|
||||
updateChargeUI();
|
||||
}
|
||||
}
|
||||
function formatUptime(s) {
|
||||
const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), ss=s%60;
|
||||
return h>0 ? `${h}h ${p2(m)}m` : `${m}m ${p2(ss)}s`;
|
||||
}
|
||||
function clearTelemetry() {
|
||||
['telTemp','telUptime','telLeft','telRight','telBias','telRecal'].forEach(id=>
|
||||
document.getElementById(id).textContent='--');
|
||||
}
|
||||
|
||||
// ── Battery & Charge UI ───────────────────────────────────────────────────────
|
||||
function updateBatteryBar(pct, status) {
|
||||
document.getElementById('battBar').style.display='flex';
|
||||
document.getElementById('battPct').textContent=pct+'%';
|
||||
document.getElementById('ciPct').textContent=pct+'%';
|
||||
document.getElementById('badgeCharging').classList.toggle('show', status===1);
|
||||
document.getElementById('badgeFull').classList.toggle('show', status===2);
|
||||
const cells=document.getElementById('battCells'); cells.innerHTML='';
|
||||
const filled=Math.round(pct/10);
|
||||
for (let i=0;i<10;i++) {
|
||||
const c=document.createElement('div'); c.className='batt-cell';
|
||||
if (i<filled) c.className+=status===1?' f charging':pct<=20?' f crit':pct<=40?' f warn':' f';
|
||||
cells.appendChild(c);
|
||||
}
|
||||
}
|
||||
function updateChargeUI() {
|
||||
const sl=['Discharging','Charging','Full'];
|
||||
const sc=['var(--label)','var(--accent)','var(--ok)'];
|
||||
const el=document.getElementById('ciStatus');
|
||||
el.textContent=sl[currentChargeStatus]??'--';
|
||||
el.style.color=sc[currentChargeStatus]??'var(--label)';
|
||||
if (currentBattPct!==null) updateBatteryBar(currentBattPct, currentChargeStatus);
|
||||
}
|
||||
|
||||
// ── Param display ─────────────────────────────────────────────────────────────
|
||||
function updateDisplay(key, val) {
|
||||
const map = {
|
||||
sensitivity: ['valSensitivity', v=>parseFloat(v).toFixed(0)],
|
||||
deadZone: ['valDeadZone', v=>parseFloat(v).toFixed(3)],
|
||||
accel: ['valAccel', v=>parseFloat(v).toFixed(2)],
|
||||
};
|
||||
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');
|
||||
const savedDevice = device;
|
||||
chars={}; device=null; server=null;
|
||||
setStatus('disconnected');
|
||||
document.getElementById('battBar').style.display='none';
|
||||
document.getElementById('badgeCharging').classList.remove('show');
|
||||
document.getElementById('badgeFull').classList.remove('show');
|
||||
document.getElementById('vizLive').classList.remove('on');
|
||||
clearTelemetry();
|
||||
if (!userDisconnected && document.getElementById('autoReconnect').checked && savedDevice) {
|
||||
log('Auto-reconnecting…','info');
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
setStatus('connecting');
|
||||
server = await savedDevice.gatt.connect();
|
||||
device = savedDevice;
|
||||
userDisconnected = false;
|
||||
log('GATT reconnected','ok');
|
||||
await discoverServices();
|
||||
setStatus('connected');
|
||||
log('Ready','ok');
|
||||
} catch(e) { log(`Reconnect failed: ${e.message}`,'err'); setStatus('disconnected'); }
|
||||
}, 1000);
|
||||
} else {
|
||||
userDisconnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IMU Stream + Visualiser ──────────────────────────────────────────────────
|
||||
// ImuPacket (14 bytes LE):
|
||||
// int16 gyroY_mDPS [0], int16 gyroZ_mDPS [2]
|
||||
// int16 accelX_mg [4], int16 accelY_mg [6], int16 accelZ_mg [8]
|
||||
// int8 moveX [10], int8 moveY [11], uint8 flags [12], uint8 pad [13]
|
||||
const canvas = document.getElementById('vizCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const TRAIL_LEN = 120;
|
||||
let cursorX = canvas.width/2, cursorY = canvas.height/2, trail = [];
|
||||
|
||||
function parseImuStream(dv) {
|
||||
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=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';
|
||||
}
|
||||
// ── 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();
|
||||
}
|
||||
(function(){
|
||||
const saved = localStorage.getItem('theme') ?? 'auto';
|
||||
themeIdx = Math.max(0, THEMES.indexOf(saved));
|
||||
applyTheme(saved);
|
||||
})();
|
||||
|
||||
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>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
273
web/style.css
Normal file
273
web/style.css
Normal file
@@ -0,0 +1,273 @@
|
||||
:root {
|
||||
--bg: #0a0c0e;
|
||||
--panel: #111417;
|
||||
--panel2: #0d1013;
|
||||
--border: #1f2428;
|
||||
--accent: #00e5ff;
|
||||
--accent2: #ff3d71;
|
||||
--warn: #ffaa00;
|
||||
--ok: #00e096;
|
||||
/* updated to match theme-dark overrides, so auto:dark equals manual dark */
|
||||
--dim: #7a8490;
|
||||
--text: #f0f2f5;
|
||||
--label: #9faab7;
|
||||
--mono: 'Share Tech Mono', monospace;
|
||||
--sans: 'Barlow Condensed', sans-serif;
|
||||
--logo-color: #ffffff;
|
||||
/* Component tokens */
|
||||
--header-bg: rgba(10,12,14,0.96);
|
||||
--scanline: rgba(0,0,0,0.07);
|
||||
--hover-desc-color: rgba(10,12,14,0.65);
|
||||
/* Canvas */
|
||||
--canvas-bg: #0d1013;
|
||||
--canvas-fade: rgba(13,16,19,0.25);
|
||||
--canvas-grid: rgba(31,36,40,0.6);
|
||||
--canvas-center: rgba(58,64,80,0.5);
|
||||
--canvas-idle-text: rgba(90,100,128,0.7);
|
||||
--trail-rgb: 0,229,255;
|
||||
--trail-idle-rgb: 58,64,80;
|
||||
--canvas-dot: #00e5ff;
|
||||
--canvas-dot-idle: #3a4050;
|
||||
--canvas-dot-glow: rgba(0,229,255,0.35);
|
||||
/* Tap flashes */
|
||||
--tap-left: rgba(0,229,255,0.35);
|
||||
--tap-right: rgba(255,61,113,0.35);
|
||||
}
|
||||
|
||||
/* ── Light theme (explicit) ──────────────────────────────────────────────── */
|
||||
:root.theme-light {
|
||||
--bg: #f0f2f5;
|
||||
--panel: #ffffff;
|
||||
--panel2: #e8eaed;
|
||||
--border: #b8bec8;
|
||||
--accent: #006699;
|
||||
--accent2: #c01a50;
|
||||
--warn: #b36a00;
|
||||
--ok: #007040;
|
||||
--dim: #848ea4;
|
||||
--text: #0f1118;
|
||||
--label: #4e566e;
|
||||
--logo-color: #0f1118;
|
||||
--header-bg: rgba(240,242,245,0.96);
|
||||
--scanline: rgba(0,0,0,0.03);
|
||||
--hover-desc-color: rgba(240,242,245,0.7);
|
||||
--canvas-bg: #e8eaed;
|
||||
--canvas-fade: rgba(232,234,237,0.35);
|
||||
--canvas-grid: rgba(195,200,210,0.7);
|
||||
--canvas-center: rgba(155,162,178,0.6);
|
||||
--canvas-idle-text: rgba(100,112,140,0.8);
|
||||
--trail-rgb: 0,100,170;
|
||||
--trail-idle-rgb: 155,162,178;
|
||||
--canvas-dot: #0077aa;
|
||||
--canvas-dot-idle: #a8b0c0;
|
||||
--canvas-dot-glow: rgba(0,100,170,0.3);
|
||||
--tap-left: rgba(0,100,170,0.35);
|
||||
--tap-right: rgba(200,30,80,0.35);
|
||||
}
|
||||
|
||||
|
||||
/* ── Auto light (OS hint; explicit class overrides) ──────────────────────── */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not(.theme-dark) {
|
||||
--bg: #f0f2f5;
|
||||
--panel: #ffffff;
|
||||
--panel2: #e8eaed;
|
||||
--border: #b8bec8;
|
||||
--accent: #006699;
|
||||
--accent2: #c01a50;
|
||||
--warn: #b36a00;
|
||||
--ok: #007040;
|
||||
--dim: #848ea4;
|
||||
--text: #0f1118;
|
||||
--label: #4e566e;
|
||||
--logo-color: #0f1118;
|
||||
--header-bg: rgba(240,242,245,0.96);
|
||||
--scanline: rgba(0,0,0,0.03);
|
||||
--hover-desc-color: rgba(240,242,245,0.7);
|
||||
--canvas-bg: #e8eaed;
|
||||
--canvas-fade: rgba(232,234,237,0.35);
|
||||
--canvas-grid: rgba(195,200,210,0.7);
|
||||
--canvas-center: rgba(155,162,178,0.6);
|
||||
--canvas-idle-text: rgba(100,112,140,0.8);
|
||||
--trail-rgb: 0,100,170;
|
||||
--trail-idle-rgb: 155,162,178;
|
||||
--canvas-dot: #0077aa;
|
||||
--canvas-dot-idle: #a8b0c0;
|
||||
--canvas-dot-glow: rgba(0,100,170,0.3);
|
||||
--tap-left: rgba(0,100,170,0.35);
|
||||
--tap-right: rgba(200,30,80,0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* { 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,var(--scanline) 2px,var(--scanline) 4px); }
|
||||
|
||||
header { border-bottom:1px solid var(--border); padding:16px 28px; display:flex; align-items:center; gap:20px; position:sticky; top:0; background:var(--header-bg); backdrop-filter:blur(10px); z-index:100; }
|
||||
.logo { font-family:var(--sans); font-weight:900; font-size:22px; letter-spacing:0.08em; color:var(--logo-color); 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); }
|
||||
.btn-theme { border:1px solid var(--dim); color:var(--label); min-width:72px; text-align:center; }
|
||||
.btn-theme::before { background:var(--text); }
|
||||
|
||||
.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:var(--hover-desc-color); }
|
||||
.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, var(--tap-left) 0%, transparent 70%); }
|
||||
.tap-flash.right { background=radial-gradient(circle at center, var(--tap-right) 0%, transparent 70%); }
|
||||
.tap-flash.show { opacity=1; }
|
||||
.viz-wrap { position=relative; }
|
||||
Reference in New Issue
Block a user