From 64b414c07acdbf47034879ad993db985a99a369c Mon Sep 17 00:00:00 2001 From: Nik Rozman Date: Sun, 1 Mar 2026 10:52:36 +0100 Subject: [PATCH] Fix boot timeout --- README.md | 48 ++++ air-mouse.ino | 707 +++++++++++++++++++++++++++++++------------------- 2 files changed, 485 insertions(+), 270 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..a74296d --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Air mouse + +This project is aimed at replacing/open-sourcing the Logitech presenter. It also contains some features not found on the Logitech Spotlight. + +I used a **Seeed Studio XIAO nRF52480 Sense** board, but it required some BSP modification to work with SoftDevice S140 7.3.0. Namely the memory layout: +```c +/* Linker script to configure memory regions. */ + +SEARCH_DIR(.) +GROUP(-lgcc -lc -lnosys) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xED000 - 0x27000 + + /* SRAM required by Softdevice depend on + * - Attribute Table Size (Number of Services and Characteristics) + * - Vendor UUID count + * - Max ATT MTU + * - Concurrent connection peripheral + central + secure links + * - Event Len, HVN queue, Write CMD queue + */ + RAM (rwx) : ORIGIN = 0x2000E000, LENGTH = 0x20040000 - 0x2000E000 +} + +SECTIONS +{ + . = ALIGN(4); + .svc_data : + { + PROVIDE(__start_svc_data = .); + KEEP(*(.svc_data)) + PROVIDE(__stop_svc_data = .); + } > RAM + + .fs_data : + { + PROVIDE(__start_fs_data = .); + KEEP(*(.fs_data)) + PROVIDE(__stop_fs_data = .); + } > RAM +} INSERT AFTER .data; + +INCLUDE "nrf52_common.ld" +``` +in `cores/linker/nrf52840_s140_v7.ld` + +You can use the `web-config.html` page to configure this device. \ No newline at end of file diff --git a/air-mouse.ino b/air-mouse.ino index 22e16d4..0f6ac3d 100644 --- a/air-mouse.ino +++ b/air-mouse.ino @@ -1,92 +1,128 @@ /* - * IMU BLE Mouse — Seeed XIAO nRF52840 Sense (v3.3) + * IMU BLE Mouse — Seeed XIAO nRF52840 Sense (v3.4) * ================================================================ - * Changes vs v3.2: - * - 10 characteristics → 4 (fixes SoftDevice RAM overflow) - * - configAttrTableSize reduced to 1024 - * - All config params merged into one 16-byte ConfigBlob (0x1235) - * - chargeStatus merged into TelemetryPacket._pad (0x1237) - * - ImuStream (0x1238) and Command (0x1236) unchanged - * - Boot-loop detection retained + * 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. * - * ── BLE Config Service (UUID 0x1234) ─────────────────────────────── - * UUID | Len | R/W/N | Description - * ───────|─────|───────|────────────────────────────────────────── - * 0x1235 | 16 | R/W | ConfigBlob — all settings in one write - * 0x1236 | 1 | W | Command: 0x01=Cal 0xFF=FactoryReset - * 0x1237 | 24 | R/N | TelemetryPacket, notified 1 Hz - * 0x1238 | 14 | N | ImuPacket, notified ~100 Hz + * MINIMUM (just working mouse, no BLE config): + * leave only FEATURE_BATTERY_MONITOR + FEATURE_BOOT_LOOP_DETECT * - * ── ConfigBlob (16 bytes, little-endian) ──────────────────────────── - * float sensitivity [0] - * float deadZone [4] - * float accelStrength [8] - * uint8_t curve [12] 0=LINEAR 1=SQUARE 2=SQRT - * uint8_t axisFlip [13] bit0=flipX bit1=flipY - * uint8_t chargeMode [14] 0=OFF 1=50mA 2=100mA - * uint8_t _pad [15] + * RECOMMENDED first test: + * enable FEATURE_CONFIG_SERVICE, keep TAP + STREAM + TELEMETRY off * - * ── TelemetryPacket (24 bytes, little-endian) ──────────────────────── - * uint32_t uptimeSeconds [0] - * uint32_t leftClicks [4] - * uint32_t rightClicks [8] - * float tempCelsius [12] - * float biasRmsRadS [16] - * uint16_t recalCount [20] - * uint8_t chargeStatus [22] 0=discharging 1=charging 2=full - * uint8_t _pad [23] + * ── 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 * - * ── ImuPacket (14 bytes, little-endian) ───────────────────────────── - * int16_t gyroY_mDPS [0] - * int16_t gyroZ_mDPS [2] - * int16_t accelX_mg [4] - * int16_t accelY_mg [6] - * int16_t accelZ_mg [8] - * int8_t moveX [10] - * int8_t moveY [11] - * uint8_t flags [12] bit0=idle bit1=singleTap bit2=doubleTap - * uint8_t _pad [13] + * 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" -// ─── Debug ──────────────────────────────────────────────────────────────────── -// #define DEBUG - // ─── Boot-loop detection ────────────────────────────────────────────────────── -static uint32_t __attribute__((section(".noinit"))) bootCount; -static uint32_t __attribute__((section(".noinit"))) bootMagic; +#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; -BLEBas blebas; +#ifdef FEATURE_BATTERY_MONITOR + BLEBas blebas; +#endif -// ─── BLE Config Service — 4 characteristics only ───────────────────────────── -BLEService cfgService (0x1234); -BLECharacteristic cfgBlob (0x1235); // ConfigBlob R/W 16 bytes -BLECharacteristic cfgCommand (0x1236); // Command W 1 byte -BLECharacteristic cfgTelemetry (0x1237); // Telemetry R/N 24 bytes 1 Hz -BLECharacteristic cfgImuStream (0x1238); // ImuStream N 14 bytes ~100 Hz +// ─── 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 LSM6DS3_CTRL1_XL 0x10 -#define LSM6DS3_TAP_CFG 0x58 -#define LSM6DS3_TAP_THS_6D 0x59 -#define LSM6DS3_INT_DUR2 0x5A -#define LSM6DS3_WAKE_UP_THS 0x5B -#define LSM6DS3_MD1_CFG 0x5E -#define LSM6DS3_TAP_SRC 0x1C -#define LSM6DS3_OUT_TEMP_L 0x20 -#define LSM6DS3_OUT_TEMP_H 0x21 +#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) @@ -96,7 +132,7 @@ LSM6DS3 imu(I2C_MODE, 0x6A); // ─── Persistence ────────────────────────────────────────────────────────────── #define CONFIG_FILENAME "/imu_mouse_cfg.bin" -#define CONFIG_MAGIC 0xDEAD1238UL // bumped — struct layout unchanged but version tag updated +#define CONFIG_MAGIC 0xDEAD1239UL using namespace Adafruit_LittleFS_Namespace; File cfgFile(InternalFS); @@ -106,7 +142,7 @@ 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 ─────────────────────────────────────────────────────────────────── +// ─── Config (stored in flash) ───────────────────────────────────────────────── struct Config { uint32_t magic; float sensitivity; @@ -117,64 +153,78 @@ struct Config { ChargeMode chargeMode; }; Config cfg; -const Config CFG_DEFAULTS = { CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW }; +const Config CFG_DEFAULTS = { + CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW +}; -// ─── ConfigBlob (what goes over BLE — no magic field) ───────────────────────── +// ─── ConfigBlob (over BLE, no magic) ───────────────────────────────────────── struct __attribute__((packed)) ConfigBlob { - float sensitivity; - float deadZone; - float accelStrength; - uint8_t curve; - uint8_t axisFlip; - uint8_t chargeMode; - uint8_t _pad; + 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; - uint32_t leftClicks; - uint32_t rightClicks; - float tempCelsius; - float biasRmsRadS; - uint16_t recalCount; - uint8_t chargeStatus; // replaces old _pad — no extra characteristic needed - uint8_t _pad; + 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; - int16_t gyroZ_mDPS; - int16_t accelX_mg; - int16_t accelY_mg; - int16_t accelZ_mg; - int8_t moveX; - int8_t moveY; - uint8_t flags; - uint8_t _pad; + 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 float TEMP_COMP_COEFF_DPS_C = 0.004f; -const unsigned long AUTO_RECAL_MS = 5UL * 60UL * 1000UL; -const unsigned long BATT_REPORT_MS = 10000; -const unsigned long TELEMETRY_MS = 1000; -const unsigned long HEARTBEAT_MS = 2000; -const int HEARTBEAT_DUR = 30; -const unsigned long CLICK_HOLD_MS = 40; -const unsigned long DOUBLE_TAP_WINDOW_MS = 400; -const unsigned long BOOT_SAFE_MS = 5000; -const float BATT_FULL = 4.20f; -const float BATT_EMPTY = 3.00f; -const float BATT_CRITICAL = 3.10f; +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; +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 = 40; + const unsigned long DOUBLE_TAP_WINDOW_MS = 400; +#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; @@ -183,18 +233,25 @@ float biasGX = 0, biasGY = 0, biasGZ = 0; float calTempC = 25.0f; float cachedTempC = 25.0f; -TelemetryPacket telem = {}; +#ifdef FEATURE_TAP_DETECTION + bool tapPending = false; + bool clickButtonDown = false; + uint8_t clickButton = 0; + unsigned long tapSeenMs = 0; + unsigned long clickDownMs= 0; + uint32_t statLeftClicks = 0; + uint32_t statRightClicks = 0; +#endif -bool imuStreamEnabled = false; -bool tapPending = false; -bool clickButtonDown = false; -uint8_t clickButton = 0; -unsigned long tapSeenMs = 0; -unsigned long clickDownMs = 0; +#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; @@ -203,33 +260,73 @@ unsigned long lastHeartbeat = 0; unsigned long lastTelemetry = 0; unsigned long bootStartMs = 0; +#ifdef FEATURE_TELEMETRY + uint16_t statRecalCount = 0; + float statBiasRms = 0.0f; +#endif + // ─── I2C helpers ────────────────────────────────────────────────────────────── void imuWriteReg(uint8_t reg, uint8_t val) { - Wire.beginTransmission(0x6A); Wire.write(reg); Wire.write(val); Wire.endTransmission(); + // 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) { - Wire.beginTransmission(0x6A); Wire.write(reg); Wire.endTransmission(false); - Wire.requestFrom((uint8_t)0x6A, (uint8_t)1); - return Wire.available() ? Wire.read() : 0; + 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(LSM6DS3_OUT_TEMP_H) << 8) | imuReadReg(LSM6DS3_OUT_TEMP_L)); + 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(LSM6DS3_CTRL1_XL, 0x60); - imuWriteReg(LSM6DS3_TAP_CFG, 0x8E); - imuWriteReg(LSM6DS3_TAP_THS_6D, 0x08); - imuWriteReg(LSM6DS3_INT_DUR2, 0x77); - imuWriteReg(LSM6DS3_WAKE_UP_THS, 0x80); - imuWriteReg(LSM6DS3_MD1_CFG, 0x48); + 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, 0x08); // threshold 500 mg + imuWriteReg(REG_INT_DUR2, 0x77); // DUR=7, QUIET=01, SHOCK=11 + 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) { + if (clickButtonDown && (now - clickDownMs >= CLICK_HOLD_MS)) { + blehid.mouseButtonPress(clickButton, false); + clickButtonDown = false; clickButton = 0; + } + uint8_t tapSrc = imuReadReg(REG_TAP_SRC); + bool singleTap = (tapSrc & 0x20) != 0; + bool doubleTap = (tapSrc & 0x10) != 0; + bool tapEvent = (tapSrc & 0x40) != 0; + if (!tapEvent) { + if (tapPending && (now - tapSeenMs >= DOUBLE_TAP_WINDOW_MS)) { + tapPending = false; + if (!clickButtonDown) { + Serial.println("[TAP] Single → LEFT"); + blehid.mouseButtonPress(MOUSE_BUTTON_LEFT, true); + clickButton = MOUSE_BUTTON_LEFT; clickButtonDown = true; clickDownMs = now; + statLeftClicks++; + } + } + return; + } + if (doubleTap && !clickButtonDown) { + tapPending = false; + Serial.println("[TAP] Double → RIGHT"); + blehid.mouseButtonPress(MOUSE_BUTTON_RIGHT, true); + clickButton = MOUSE_BUTTON_RIGHT; clickButtonDown = true; clickDownMs = now; + statRightClicks++; + return; + } + if (singleTap && !tapPending && !clickButtonDown) { tapPending = true; tapSeenMs = now; } +} +#endif // FEATURE_TAP_DETECTION + // ─── Charge mode ────────────────────────────────────────────────────────────── void applyChargeMode(ChargeMode mode) { switch (mode) { @@ -247,8 +344,11 @@ void loadConfig() { 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"); } + 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)"); } } @@ -256,47 +356,49 @@ 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"); } + else { Serial.println("[CFG] ERROR: write failed"); } } -// Push current config as a ConfigBlob to the BLE characteristic +// ─── ConfigBlob push ───────────────────────────────────────────────────────── +#ifdef FEATURE_CONFIG_SERVICE void pushConfigBlob() { - ConfigBlob blob; - blob.sensitivity = cfg.sensitivity; - blob.deadZone = cfg.deadZone; - blob.accelStrength = cfg.accelStrength; - blob.curve = (uint8_t)cfg.curve; - blob.axisFlip = cfg.axisFlip; - blob.chargeMode = (uint8_t)cfg.chargeMode; - blob._pad = 0; - cfgBlob.write((uint8_t*)&blob, sizeof(blob)); + 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); - if (!safeMode) pushConfigBlob(); - telem = {}; + #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 ──────────────────────────────────────────────────────────── -// Single callback handles the whole config blob +#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; + 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); - } + if (b->chargeMode <= 2) { cfg.chargeMode = (ChargeMode)b->chargeMode; applyChargeMode(cfg.chargeMode); } saveConfig(); - Serial.print("[CFG] Blob written — sens="); Serial.print(cfg.sensitivity); - Serial.print(" dz="); Serial.print(cfg.deadZone, 3); + 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); } @@ -307,16 +409,19 @@ void onCommandWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) { 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(); - // ConfigBlob — R/W 16 bytes cfgBlob.setProperties(CHR_PROPS_READ | CHR_PROPS_WRITE); cfgBlob.setPermission(SECMODE_OPEN, SECMODE_OPEN); cfgBlob.setFixedLen(sizeof(ConfigBlob)); @@ -324,29 +429,43 @@ void setupConfigService() { cfgBlob.begin(); pushConfigBlob(); - // Command — W 1 byte cfgCommand.setProperties(CHR_PROPS_WRITE); cfgCommand.setPermission(SECMODE_OPEN, SECMODE_OPEN); cfgCommand.setFixedLen(1); cfgCommand.setWriteCallback(onCommandWrite); cfgCommand.begin(); - // Telemetry — R/N 24 bytes - cfgTelemetry.setProperties(CHR_PROPS_READ | CHR_PROPS_NOTIFY); - cfgTelemetry.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); - cfgTelemetry.setFixedLen(sizeof(TelemetryPacket)); - cfgTelemetry.begin(); - cfgTelemetry.write((uint8_t*)&telem, sizeof(telem)); + #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 - // ImuStream — N 14 bytes - cfgImuStream.setProperties(CHR_PROPS_NOTIFY); - cfgImuStream.setPermission(SECMODE_OPEN, SECMODE_NO_ACCESS); - cfgImuStream.setFixedLen(sizeof(ImuPacket)); - cfgImuStream.setCccdWriteCallback(onImuStreamCccd); - cfgImuStream.begin(); + #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); @@ -357,21 +476,26 @@ float readBatteryVoltage() { 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); } +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); - // chargeStatus is now pushed via telemetry packet — no separate characteristic - telem.chargeStatus = (uint8_t)status; + 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() { @@ -379,14 +503,20 @@ void calibrateGyroBias() { double sx=0, sy=0, sz=0; for (int i=0; i= 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(6, 12); + 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"); - setupTapDetection(); + #ifdef FEATURE_TAP_DETECTION + setupTapDetection(); + #endif + cachedTempC = readIMUTemp(); - updateBattery(); + + #ifdef FEATURE_BATTERY_MONITOR + updateBattery(); + #endif + calibrateGyroBias(); bledis.setManufacturer("Seeed Studio"); @@ -508,19 +627,47 @@ void setup() { bledis.begin(); blehid.begin(); - blebas.begin(); blebas.write(100); - if (!safeMode) { - setupConfigService(); - Serial.println("[OK] Config service started (4 characteristics)"); - } else { - Serial.println("[SAFE] Config service skipped — basic mouse only"); - } + #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 as '"); - Serial.print(safeMode ? "IMU Mouse (safe)" : "IMU Mouse"); - Serial.println("'"); + 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(); @@ -530,10 +677,13 @@ void setup() { void loop() { unsigned long now = millis(); - if (!bootCountCleared && (now - bootStartMs >= BOOT_SAFE_MS)) { - bootCount = 0; bootCountCleared = true; - Serial.println("[BOOT] Stable — boot counter cleared"); - } + // 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(); } @@ -545,9 +695,13 @@ void loop() { digitalWrite(led, LOW); delay(HEARTBEAT_DUR); digitalWrite(led, HIGH); } - if (now - lastBattTime >= BATT_REPORT_MS) { lastBattTime = now; updateBattery(); } + #ifdef FEATURE_BATTERY_MONITOR + if (now - lastBattTime >= BATT_REPORT_MS) { lastBattTime = now; updateBattery(); } + #endif - processTaps(now); + #ifdef FEATURE_TAP_DETECTION + processTaps(now); + #endif if (now - lastTime < (unsigned long)LOOP_RATE_MS) return; float dt = (now - lastTime) / 1000.0f; @@ -556,21 +710,30 @@ void loop() { cachedTempC = readIMUTemp(); - if (!safeMode && (now - lastTelemetry >= TELEMETRY_MS)) { - lastTelemetry = now; pushTelemetry(now); - } + #ifdef FEATURE_TELEMETRY + if (!safeMode && (now - lastTelemetry >= TELEMETRY_MS)) { + lastTelemetry = now; pushTelemetry(now); + } + #endif - float tempDelta = cachedTempC - calTempC; - float correction = TEMP_COMP_COEFF_DPS_C * tempDelta; - - float gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f); - float gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f); - float gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f); + // 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)); @@ -582,10 +745,12 @@ void loop() { else { idleFrames++; if (idleStartMs == 0) idleStartMs = now; } bool idle = (idleFrames >= IDLE_FRAMES); - if (idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) { - Serial.println("[AUTO-CAL] Long idle — recalibrating..."); - idleStartMs = 0; calibrateGyroBias(); return; - } + #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; @@ -605,25 +770,27 @@ void loop() { if (Bluefruit.connected() && (moveX != 0 || moveY != 0)) blehid.mouseMove(moveX, moveY); } - if (!safeMode && imuStreamEnabled && Bluefruit.connected()) { - ImuPacket pkt; - pkt.gyroY_mDPS = (int16_t)constrain(gy*(180.f/PI)*1000.f, -32000, 32000); - pkt.gyroZ_mDPS = (int16_t)constrain(gz*(180.f/PI)*1000.f, -32000, 32000); - pkt.accelX_mg = (int16_t)constrain(ax*1000.f, -32000, 32000); - pkt.accelY_mg = (int16_t)constrain(ay*1000.f, -32000, 32000); - pkt.accelZ_mg = (int16_t)constrain(az*1000.f, -32000, 32000); - pkt.moveX = moveX; - pkt.moveY = moveY; - pkt.flags = flags; - pkt._pad = 0; - cfgImuStream.notify((uint8_t*)&pkt, sizeof(pkt)); - } + #ifdef FEATURE_IMU_STREAM + if (!safeMode && imuStreamEnabled && Bluefruit.connected()) { + ImuPacket pkt; + pkt.gyroY_mDPS = (int16_t)constrain(gy*(180.f/PI)*1000.f, -32000, 32000); + pkt.gyroZ_mDPS = (int16_t)constrain(gz*(180.f/PI)*1000.f, -32000, 32000); + pkt.accelX_mg = (int16_t)constrain(ax*1000.f, -32000, 32000); + pkt.accelY_mg = (int16_t)constrain(ay*1000.f, -32000, 32000); + pkt.accelZ_mg = (int16_t)constrain(az*1000.f, -32000, 32000); + pkt.moveX = moveX; + pkt.moveY = moveY; + pkt.flags = flags; + pkt._pad = 0; + cfgImuStream.notify((uint8_t*)&pkt, sizeof(pkt)); + } + #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 + #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 }