From 0ad07ac8ceb53ccdb7e21d92de32cea066a45c6f Mon Sep 17 00:00:00 2001 From: Nik Rozman Date: Sun, 1 Mar 2026 18:24:12 +0100 Subject: [PATCH] Restructure project --- air-mouse.ino | 797 ----------------------- pointer.FCMacro => model/pointer.FCMacro | 0 platformio.ini | 3 + source/battery.cpp | 46 ++ source/battery.h | 9 + source/ble_config.cpp | 169 +++++ source/ble_config.h | 29 + source/config.h | 184 ++++++ source/imu.cpp | 60 ++ source/imu.h | 12 + source/main.cpp | 384 +++++++++++ source/tap.cpp | 49 ++ source/tap.h | 7 + web/app.js | 449 +++++++++++++ web/index.html | 732 +-------------------- web/style.css | 273 ++++++++ 16 files changed, 1679 insertions(+), 1524 deletions(-) delete mode 100644 air-mouse.ino rename pointer.FCMacro => model/pointer.FCMacro (100%) create mode 100644 source/battery.cpp create mode 100644 source/battery.h create mode 100644 source/ble_config.cpp create mode 100644 source/ble_config.h create mode 100644 source/config.h create mode 100644 source/imu.cpp create mode 100644 source/imu.h create mode 100644 source/main.cpp create mode 100644 source/tap.cpp create mode 100644 source/tap.h create mode 100644 web/app.js create mode 100644 web/style.css diff --git a/air-mouse.ino b/air-mouse.ino deleted file mode 100644 index eba1e3a..0000000 --- a/air-mouse.ino +++ /dev/null @@ -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 -#include -#include -#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= 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 -} diff --git a/pointer.FCMacro b/model/pointer.FCMacro similarity index 100% rename from pointer.FCMacro rename to model/pointer.FCMacro diff --git a/platformio.ini b/platformio.ini index 53a49ff..afb13d0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/source/battery.cpp b/source/battery.cpp new file mode 100644 index 0000000..ca41331 --- /dev/null +++ b/source/battery.cpp @@ -0,0 +1,46 @@ +#include "battery.h" + +#ifdef FEATURE_BATTERY_MONITOR +#include + +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 diff --git a/source/battery.h b/source/battery.h new file mode 100644 index 0000000..4e7dcff --- /dev/null +++ b/source/battery.h @@ -0,0 +1,9 @@ +#pragma once +#include "config.h" + +#ifdef FEATURE_BATTERY_MONITOR +void initBatteryADC(); +float readBatteryVoltage(); +int batteryPercent(float v); +void updateBattery(); +#endif diff --git a/source/ble_config.cpp b/source/ble_config.cpp new file mode 100644 index 0000000..2a811b5 --- /dev/null +++ b/source/ble_config.cpp @@ -0,0 +1,169 @@ +#include "ble_config.h" +#include +#include + +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 diff --git a/source/ble_config.h b/source/ble_config.h new file mode 100644 index 0000000..98cedf7 --- /dev/null +++ b/source/ble_config.h @@ -0,0 +1,29 @@ +#pragma once +#include "config.h" +#include + +#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(); diff --git a/source/config.h b/source/config.h new file mode 100644 index 0000000..329aa97 --- /dev/null +++ b/source/config.h @@ -0,0 +1,184 @@ +#pragma once +#include + +// ─── 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; diff --git a/source/imu.cpp b/source/imu.cpp new file mode 100644 index 0000000..5216380 --- /dev/null +++ b/source/imu.cpp @@ -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= 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); } diff --git a/source/imu.h b/source/imu.h new file mode 100644 index 0000000..74aea5b --- /dev/null +++ b/source/imu.h @@ -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); diff --git a/source/main.cpp b/source/main.cpp new file mode 100644 index 0000000..127bef8 --- /dev/null +++ b/source/main.cpp @@ -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 +#include +#include +#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 +} diff --git a/source/tap.cpp b/source/tap.cpp new file mode 100644 index 0000000..3187de7 --- /dev/null +++ b/source/tap.cpp @@ -0,0 +1,49 @@ +#include "tap.h" + +#ifdef FEATURE_TAP_DETECTION +#include "imu.h" +#include + +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 diff --git a/source/tap.h b/source/tap.h new file mode 100644 index 0000000..8165638 --- /dev/null +++ b/source/tap.h @@ -0,0 +1,7 @@ +#pragma once +#include "config.h" + +#ifdef FEATURE_TAP_DETECTION +void setupTapDetection(); +void processTaps(unsigned long now); +#endif diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..c106ef6 --- /dev/null +++ b/web/app.js @@ -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=`${ts}${msg}`; + 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 (iparseFloat(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;xel.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

⚠ Web Bluetooth Not Supported

Use Chrome or Edge on desktop.
Linux: enable chrome://flags/#enable-web-bluetooth

`; +} else { + log('Web Bluetooth ready. Click CONNECT to pair your IMU Mouse.','info'); +} diff --git a/web/index.html b/web/index.html index aedb5ad..76f21ca 100644 --- a/web/index.html +++ b/web/index.html @@ -5,281 +5,7 @@ IMU Mouse // Config Terminal - + @@ -446,456 +172,8 @@ - + - \ No newline at end of file + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..571f1fd --- /dev/null +++ b/web/style.css @@ -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; } \ No newline at end of file