From 5c36aa041ece6d300a6c098a4aae1cd573b8cdd6 Mon Sep 17 00:00:00 2001 From: Nik Rozman Date: Tue, 3 Mar 2026 11:44:38 +0100 Subject: [PATCH] Initial physical button mapping implementation #4 --- source/ble_config.cpp | 11 +++ source/buttons.cpp | 52 +++++++++++++ source/buttons.h | 7 ++ source/config.h | 14 +++- source/main.cpp | 15 +++- web/app.js | 53 ++++++++++++- web/index.html | 176 +++++++++++++++++++++++++++++++++++------- web/style.css | 26 ++++++- 8 files changed, 321 insertions(+), 33 deletions(-) create mode 100644 source/buttons.cpp create mode 100644 source/buttons.h diff --git a/source/ble_config.cpp b/source/ble_config.cpp index 758abcf..6f883cf 100644 --- a/source/ble_config.cpp +++ b/source/ble_config.cpp @@ -1,6 +1,7 @@ #include "ble_config.h" #include "tap.h" #include "battery.h" +#include "buttons.h" #include #include @@ -77,6 +78,9 @@ void pushConfigBlob() { b.tapFreezeEnabled = cfg.tapFreezeEnabled; b.jerkThreshold = cfg.jerkThreshold; b.featureFlags = cfg.featureFlags; + b.btnLeftPin = cfg.btnLeftPin; + b.btnRightPin = cfg.btnRightPin; + b.btnMiddlePin = cfg.btnMiddlePin; cfgBlob.write((uint8_t*)&b, sizeof(b)); } #endif @@ -122,6 +126,13 @@ void onConfigBlobWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) cfg.tapFreezeEnabled = b->tapFreezeEnabled ? 1 : 0; if (b->jerkThreshold >= 100.0f && b->jerkThreshold <= 50000.0f) cfg.jerkThreshold = b->jerkThreshold; cfg.featureFlags = b->featureFlags & (FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG_AUTO_RECAL_ENABLED); + // btnXPin: accept BTN_PIN_NONE (0xFF) or a valid Arduino pin number (0-10 = D0-D10) + cfg.btnLeftPin = (b->btnLeftPin <= 10 || b->btnLeftPin == BTN_PIN_NONE) ? b->btnLeftPin : BTN_PIN_NONE; + cfg.btnRightPin = (b->btnRightPin <= 10 || b->btnRightPin == BTN_PIN_NONE) ? b->btnRightPin : BTN_PIN_NONE; + cfg.btnMiddlePin = (b->btnMiddlePin <= 10 || b->btnMiddlePin == BTN_PIN_NONE) ? b->btnMiddlePin : BTN_PIN_NONE; + #ifdef FEATURE_PHYSICAL_BUTTONS + setupPhysicalButtons(); // reconfigure pins immediately (no restart needed) + #endif saveConfig(); Serial.print("[CFG] Written — sens="); Serial.print(cfg.sensitivity,0); Serial.print(" dz="); Serial.print(cfg.deadZone,3); diff --git a/source/buttons.cpp b/source/buttons.cpp new file mode 100644 index 0000000..bfd4f43 --- /dev/null +++ b/source/buttons.cpp @@ -0,0 +1,52 @@ +#include "buttons.h" + +#ifdef FEATURE_PHYSICAL_BUTTONS +#include + +extern BLEHidAdafruit blehid; + +static uint8_t physBtnMask = 0; // bitmask of currently-pressed physical buttons + +// ─── Setup ──────────────────────────────────────────────────────────────────── +void setupPhysicalButtons() { + // Release any held physical buttons before reconfiguring + if (physBtnMask && Bluefruit.connected()) { blehid.mouseButtonRelease(); } + physBtnMask = 0; + + if (cfg.btnLeftPin != BTN_PIN_NONE) pinMode(cfg.btnLeftPin, INPUT_PULLUP); + if (cfg.btnRightPin != BTN_PIN_NONE) pinMode(cfg.btnRightPin, INPUT_PULLUP); + if (cfg.btnMiddlePin != BTN_PIN_NONE) pinMode(cfg.btnMiddlePin, INPUT_PULLUP); + + bool any = (cfg.btnLeftPin != BTN_PIN_NONE) || (cfg.btnRightPin != BTN_PIN_NONE) + || (cfg.btnMiddlePin != BTN_PIN_NONE); + if (any) { + Serial.print("[BTN] L="); + cfg.btnLeftPin == BTN_PIN_NONE ? Serial.print("--") : Serial.print(cfg.btnLeftPin); + Serial.print(" R="); + cfg.btnRightPin == BTN_PIN_NONE ? Serial.print("--") : Serial.print(cfg.btnRightPin); + Serial.print(" M="); + cfg.btnMiddlePin == BTN_PIN_NONE ? Serial.print("--") : Serial.print(cfg.btnMiddlePin); + Serial.println(); + } +} + +// ─── Poll and report ────────────────────────────────────────────────────────── +// Called every loop iteration (before rate limiter) for immediate response. +// Uses active-low logic: INPUT_PULLUP, button connects pin to GND. +void processPhysicalButtons() { + if (!Bluefruit.connected()) return; + + uint8_t newMask = 0; + if (cfg.btnLeftPin != BTN_PIN_NONE && digitalRead(cfg.btnLeftPin) == LOW) newMask |= MOUSE_BUTTON_LEFT; + if (cfg.btnRightPin != BTN_PIN_NONE && digitalRead(cfg.btnRightPin) == LOW) newMask |= MOUSE_BUTTON_RIGHT; + if (cfg.btnMiddlePin != BTN_PIN_NONE && digitalRead(cfg.btnMiddlePin) == LOW) newMask |= MOUSE_BUTTON_MIDDLE; + + if (newMask != physBtnMask) { + physBtnMask = newMask; + if (physBtnMask) blehid.mouseButtonPress(physBtnMask); + else blehid.mouseButtonRelease(); + Serial.print("[BTN] mask=0x"); Serial.println(physBtnMask, HEX); + } +} + +#endif // FEATURE_PHYSICAL_BUTTONS diff --git a/source/buttons.h b/source/buttons.h new file mode 100644 index 0000000..485d148 --- /dev/null +++ b/source/buttons.h @@ -0,0 +1,7 @@ +#pragma once +#include "config.h" + +#ifdef FEATURE_PHYSICAL_BUTTONS +void setupPhysicalButtons(); +void processPhysicalButtons(); +#endif diff --git a/source/config.h b/source/config.h index 3afd5ec..0faae6f 100644 --- a/source/config.h +++ b/source/config.h @@ -10,6 +10,7 @@ #define FEATURE_AUTO_RECAL #define FEATURE_BATTERY_MONITOR #define FEATURE_BOOT_LOOP_DETECT +#define FEATURE_PHYSICAL_BUTTONS // ─── Debug ──────────────────────────────────────────────────────────────────── // #define DEBUG @@ -53,7 +54,10 @@ // ─── Persistence ────────────────────────────────────────────────────────────── #define CONFIG_FILENAME "/imu_mouse_cfg.bin" -#define CONFIG_MAGIC 0xDEAD123CUL +#define CONFIG_MAGIC 0xDEAD123DUL + +// ─── Physical button sentinel ───────────────────────────────────────────────── +#define BTN_PIN_NONE 0xFF // Stored in btn*Pin when that button is disabled // ─── Runtime feature-override flags (cfg.featureFlags bitmask) ─────────────── // These mirror the compile-time FEATURE_* defines but can be toggled at runtime @@ -94,6 +98,9 @@ struct Config { float jerkThreshold; // jerk² threshold for tap-freeze detection uint8_t tapFreezeEnabled; // 1 = enable jerk-based cursor freeze during taps uint8_t featureFlags; // bitmask of FLAG_* — runtime feature overrides + uint8_t btnLeftPin; // BTN_PIN_NONE or Arduino pin number (0-10 = D0-D10) + uint8_t btnRightPin; + uint8_t btnMiddlePin; }; extern Config cfg; extern const Config CFG_DEFAULTS; @@ -113,8 +120,11 @@ struct __attribute__((packed)) ConfigBlob { uint8_t tapFreezeEnabled; // [19] 1 = enable jerk-based cursor freeze during taps float jerkThreshold; // [20] jerk² tap-freeze threshold uint8_t featureFlags; // [24] FLAG_* bitmask — runtime feature overrides + uint8_t btnLeftPin; // [25] BTN_PIN_NONE or Arduino pin (0-10 = D0-D10) + uint8_t btnRightPin; // [26] + uint8_t btnMiddlePin; // [27] }; -static_assert(sizeof(ConfigBlob) == 25, "ConfigBlob must be 25 bytes"); +static_assert(sizeof(ConfigBlob) == 28, "ConfigBlob must be 28 bytes"); // ─── TelemetryPacket (24 bytes) ─────────────────────────────────────────────── #ifdef FEATURE_TELEMETRY diff --git a/source/main.cpp b/source/main.cpp index 24d8f4b..e375fec 100644 --- a/source/main.cpp +++ b/source/main.cpp @@ -33,6 +33,7 @@ #include "ble_config.h" #include "battery.h" #include "tap.h" +#include "buttons.h" #include #include #include @@ -60,7 +61,8 @@ Config cfg; const Config CFG_DEFAULTS = { CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW, /*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0, - /*jerkThreshold=*/2000.0f, /*tapFreezeEnabled=*/1, /*featureFlags=*/FLAG_ALL_DEFAULT + /*jerkThreshold=*/2000.0f, /*tapFreezeEnabled=*/1, /*featureFlags=*/FLAG_ALL_DEFAULT, + /*btnLeftPin=*/BTN_PIN_NONE, /*btnRightPin=*/BTN_PIN_NONE, /*btnMiddlePin=*/BTN_PIN_NONE }; // ─── Telemetry definition ───────────────────────────────────────────────────── @@ -224,6 +226,10 @@ void setup() { if (cfg.featureFlags & FLAG_TAP_ENABLED) setupTapDetection(); #endif + #ifdef FEATURE_PHYSICAL_BUTTONS + setupPhysicalButtons(); + #endif + cachedTempC = readIMUTemp(); #ifdef FEATURE_BATTERY_MONITOR @@ -280,6 +286,9 @@ void setup() { #ifdef FEATURE_BOOT_LOOP_DETECT Serial.print(" BOOTDET"); #endif + #ifdef FEATURE_PHYSICAL_BUTTONS + Serial.print(" PHYSBTN"); + #endif Serial.println(); bootStartMs = millis(); @@ -323,6 +332,10 @@ void loop() { if (cfg.featureFlags & FLAG_TAP_ENABLED) processTaps(now); #endif + #ifdef FEATURE_PHYSICAL_BUTTONS + processPhysicalButtons(); + #endif + if (now - lastTime < (unsigned long)LOOP_RATE_MS) return; float dt = (now - lastTime) / 1000.0f; lastTime = now; diff --git a/web/app.js b/web/app.js index 95d4a8a..d5334c9 100644 --- a/web/app.js +++ b/web/app.js @@ -18,7 +18,8 @@ const FLAG_ALL_DEFAULT = FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG // 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, tapThreshold:12, tapAction:0, tapKey:0, tapMod:0, tapFreezeEnabled:1, jerkThreshold:2000, - featureFlags:FLAG_ALL_DEFAULT }; + featureFlags:FLAG_ALL_DEFAULT, + btnLeftPin:0xFF, btnRightPin:0xFF, btnMiddlePin:0xFF }; let device=null, server=null, chars={}, userDisconnected=false; let currentChargeStatus=0, currentBattPct=null, currentBattVoltage=null; @@ -239,6 +240,13 @@ async function readConfigBlob() { } else { config.featureFlags = FLAG_ALL_DEFAULT; // old firmware — assume all on } + if (view.byteLength >= 28) { + config.btnLeftPin = view.getUint8(25); + config.btnRightPin = view.getUint8(26); + config.btnMiddlePin = view.getUint8(27); + } else { + config.btnLeftPin = config.btnRightPin = config.btnMiddlePin = 0xFF; // disabled + } applyConfigToUI(); log(`Config loaded — sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} tapThr=${config.tapThreshold}`,'ok'); } catch(e) { log(`Config read error: ${e.message}`,'err'); } @@ -270,6 +278,37 @@ function applyConfigToUI() { document.getElementById('capTapEnabled').checked = !!(config.featureFlags & FLAG_TAP_ENABLED); document.getElementById('capTempComp').checked = !!(config.featureFlags & FLAG_TEMP_COMP_ENABLED); document.getElementById('capAutoRecal').checked = !!(config.featureFlags & FLAG_AUTO_RECAL_ENABLED); + document.getElementById('btnLeftPin').value = config.btnLeftPin; + document.getElementById('btnRightPin').value = config.btnRightPin; + document.getElementById('btnMiddlePin').value = config.btnMiddlePin; + updatePinDiagram(); +} + +// ── XIAO pin diagram ────────────────────────────────────────────────────────── +function updatePinDiagram() { + const st = getComputedStyle(document.documentElement); + const COL_L = st.getPropertyValue('--ok').trim(); + const COL_R = st.getPropertyValue('--accent2').trim(); + const COL_M = st.getPropertyValue('--accent').trim(); + const DEF_F = '#0c1828', DEF_S = '#162234'; + + for (let i = 0; i <= 10; i++) { + const el = document.getElementById(`xiaoPin${i}`); + if (el) { el.setAttribute('fill', DEF_F); el.setAttribute('stroke', DEF_S); } + } + + const apply = (pin, col) => { + if (pin > 10) return; + const el = document.getElementById(`xiaoPin${pin}`); + if (el) { el.setAttribute('fill', col); el.setAttribute('stroke', col); } + }; + + const l = parseInt(document.getElementById('btnLeftPin').value, 10); + const r = parseInt(document.getElementById('btnRightPin').value, 10); + const m = parseInt(document.getElementById('btnMiddlePin').value, 10); + if (l <= 10) apply(l, COL_L); + if (r <= 10) apply(r, COL_R); + if (m <= 10) apply(m, COL_M); } let _writeConfigTimer = null; @@ -298,7 +337,11 @@ async function _doWriteConfigBlob() { | (document.getElementById('capAutoRecal').checked ? FLAG_AUTO_RECAL_ENABLED : 0); // config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly - const buf = new ArrayBuffer(25); + config.btnLeftPin = parseInt(document.getElementById('btnLeftPin').value, 10); + config.btnRightPin = parseInt(document.getElementById('btnRightPin').value, 10); + config.btnMiddlePin = parseInt(document.getElementById('btnMiddlePin').value, 10); + + const buf = new ArrayBuffer(28); const view = new DataView(buf); view.setFloat32(0, config.sensitivity, true); view.setFloat32(4, config.deadZone, true); @@ -313,6 +356,9 @@ async function _doWriteConfigBlob() { view.setUint8(19, config.tapFreezeEnabled); view.setFloat32(20, config.jerkThreshold, true); view.setUint8(24, config.featureFlags); + view.setUint8(25, config.btnLeftPin); + view.setUint8(26, config.btnRightPin); + view.setUint8(27, config.btnMiddlePin); try { await gattWrite(chars.configBlob, buf); @@ -605,7 +651,7 @@ function setStatus(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,#tapKeyHex,.mod-btn input'); + const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn,#tapKeyHex,.mod-btn input,.pin-select'); if (state==='connected') { cBtn.style.display='none'; dBtn.style.display=''; inputs.forEach(el=>el.disabled=false); @@ -972,6 +1018,7 @@ function applyTheme(t) { localStorage.setItem('theme', t); if (!chars.imuStream) drawInitState(); orientUpdateColors(); + updatePinDiagram(); } (function(){ const saved = localStorage.getItem('theme') ?? 'auto'; diff --git a/web/index.html b/web/index.html index fe97476..0c395a7 100644 --- a/web/index.html +++ b/web/index.html @@ -38,6 +38,8 @@
+ +
@@ -97,6 +99,44 @@
+ +
+
+
Flip X Axis
+
Invert left / right
+ +
+
+
Flip Y Axis
+
Invert up / down
+ +
+
+ + +
+
+
Tap Detection
+
Double-tap click action  · restart to apply
+ +
+
+
Temp Compensation
+
Gyro drift correction by temperature
+ +
+
+
Auto Recalibration
+
Recalibrate gyro after long idle period
+ +
+
+ + + + +
+
@@ -141,36 +181,119 @@
- +
-
-
Flip X Axis
-
Invert left / right
- + +
+ + + + + USB·C + + + + + + + + + XIAO nRF52840 Sense + + + ANT + + + nRF52840 + HOLYIOT + + + + + LSM6DS3 + IMU + + + BQ25100 + + + + + LED + + + + + + + + + + + + + + + + + + + + + + + D0D1D2D3D4D5D6 + A0A1A2A3SDASCLTX + + D7D8D9D10 + RXSCKMISOMOSI + + RST + GND + 3V3 + +
+ ● Left + ● Right + ● Middle +
-
-
Flip Y Axis
-
Invert up / down
- +
+
+
Left Click
+
Pin wired to GND when pressed
+
-
- - -
-
-
Tap Detection
-
Double-tap click action  · restart to apply
- +
+
Right Click
+
Pin wired to GND when pressed
+
-
-
Temp Compensation
-
Gyro drift correction by temperature
- +
+
Middle Click
+
Pin wired to GND when pressed
+
-
-
Auto Recalibration
-
Recalibrate gyro after long idle period
- +
+ Pull-up built-in · wire button between chosen pin and GND
@@ -189,8 +312,9 @@
-
+
+
diff --git a/web/style.css b/web/style.css index 7c377a5..4f222e0 100644 --- a/web/style.css +++ b/web/style.css @@ -148,10 +148,32 @@ .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; } + main { max-width:1440px; margin:0 auto; padding:32px 20px 80px; display:grid; grid-template-columns:1fr 1fr 380px; gap:16px; align-items:start; } .col-left { display:grid; gap:12px; } + .col-mid { display:grid; gap:12px; } .col-right { display:grid; gap:12px; position:sticky; top:80px; } + /* ── XIAO pin diagram ─────────────────────────────────────── */ + .xiao-wrap { display:flex; flex-direction:column; align-items:center; padding:8px 0 14px; } + .pin-legend { display:flex; gap:20px; justify-content:center; font-family:var(--mono); font-size:9px; margin-top:10px; letter-spacing:0.08em; } + .pleg.left { color:var(--ok); } + .pleg.right { color:var(--accent2); } + .pleg.mid { color:var(--accent); } + .xiao-divider { border:none; border-top:1px solid var(--border); margin:0 -20px 12px; } + + /* ── Responsive ───────────────────────────────────────────── */ + @media (max-width:1100px) { + main { grid-template-columns:1fr 380px; grid-template-rows:auto auto; } + .col-left { grid-column:1; grid-row:1; } + .col-mid { grid-column:1; grid-row:2; } + .col-right { grid-column:2; grid-row:1/3; } + } + @media (max-width:700px) { + main { grid-template-columns:1fr; } + .col-left, .col-mid, .col-right { grid-column:1; grid-row:auto; } + .col-right { position:static; } + } + .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; } @@ -304,6 +326,8 @@ .mod-btn input:disabled + span { opacity:0.35; cursor:not-allowed; } .restart-note { color:var(--warn); font-family:var(--mono); font-size:9px; } + .pin-select { background:var(--bg); color:var(--text); border:1px solid var(--border); font-family:var(--mono); font-size:11px; padding:3px 6px; cursor:pointer; min-width:80px; } + .pin-select:disabled { color:var(--dim); border-color:var(--dim); cursor:not-allowed; } .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%); }