chore: move firmware skills in-repo + AGENTS.md

- Migrates tactility-firmware and tactility-bluetooth from
  ~/.hermes/skills/ into .claude/skills/ for repo co-location
- Adds AGENTS.md with local workstation context, board table,
  board-direct build rule, Hermes PYTHONPATH pitfall, common
  Tactility pitfalls, and in-repo skill index

Refs: personal Gitea mirror
This commit is contained in:
Adolfo Reyna
2026-07-17 09:51:08 -04:00
parent e7fb5fb38f
commit 12035f2f39
22 changed files with 2362 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
---
name: tactility-bluetooth
description: Use when debugging or extending Bluetooth/BLE in Tactility OS — BLE HID Host (central) for keyboards/mice/combo touchpads, Report ID stripping, mouse report parsing (Fosmon-style standard vs Logitech 12-bit), NimBLE scan cache including nameless/RPA devices, PUBLIC vs RANDOM addr_type fallback, and auto-connect/reconnect retry loops.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [tactility, bluetooth, ble, hid-host, nimble, esp32, esp32s3, combo-keyboard, auto-connect]
related_skills: [tactility-firmware, systematic-debugging]
---
# Tactility Bluetooth / BLE HID Host
## Overview
Tactility's BLE stack uses NimBLE host (ESP-IDF) on ESP32/S3. Central role (HID Host) connects to external keyboards, mice, and combo keyboard+touchpad devices. Relevant for:
- Fosmon Mini BT Keyboard w/ Touchpad (Amazon B00BX0YKX4 / https://a.co/d/0fg4vprm) — keyboard Report ID 1 + mouse/touchpad Report ID 2, 4-5 byte reports `[buttons, x, y, wheel, pan]`
- Generic combo devices that sleep, advertise without name, use RPA (Resolvable Private Address)
Builds require `device.py <id>` to generate sdkconfig; simulator POSIX path has no BT.
## When to Use
- Mouse doesn't move / jumps erratically on combo KB+touchpad
- Bluetooth won't reconnect to previously paired KB/mouse after sleep
- Adding new HID Host behavior, touchpad gestures, consumer reports
- Debugging `BLE_HS_EALREADY`, `addr_type`, bond loss, `hid_host_active` flag
- Changing scan filter, RSSI, adv parsing
Don't use for USB HID (`usb_host_hid`) or classic BT (Tactility uses BLE only for HID host).
## Architecture
### Key Files
- `Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp` — GAP DISC event handler, `scan_results[64]` + `scan_addrs[64]`, `ble_gap_disc_event_handler`, `ble_resolve_next_unnamed_peer`
- `Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp` — advertising helpers, dispatch_enable/disable, `hid_host_active` atomic guard against simultaneous central connect
- `Platforms/platform-esp32/private/bluetooth/esp32_ble_internal.h``BleCtx`, `scan_mutex`, `hid_host_active`
- `Tactility/Source/bluetooth/BluetoothHidHost.cpp` — central connection state machine: service/char/disc, CCCD subscribe chain, Report Map read, type resolution, `NOTIFY_RX` dispatch, LVGL indev registration
- `Tactility/Source/bluetooth/Bluetooth.cpp``bt_event_bridge`, `SCAN_FINISHED -> autoConnectHidHost()`, `PROFILE_STATE_CHANGED IDLE -> rescan`
- `Tactility/Source/app/btmanage/*` + `btpeersettings/*` — UI, manual connect calls `hidHostConnect(addr)` directly
### Flow
```
DISC callback -> cacheScanAddr + scan_results -> BT_EVENT_PEER_FOUND -> getScanResults()
SCAN_FINISHED -> autoConnectHidHost() iterates scanResults, checks PairedDevice autoConnect + profile BT_PROFILE_HID_HOST -> hidHostConnect()
hidHostConnect: getCachedScanAddrType() for addr_type -> ble_gap_connect() -> GAP CONNECT -> security_initiate -> disc_all_svcs -> disc_all_chrs -> disc_all_dscs -> read Report Ref + Report Map -> type resolution -> CCCD subscribe -> ready
NOTIFY_RX: find rpt by valHandle -> switch type -> handleKeyboard/Mouse with reportId
```
## Combo Device Quirks — Fosmon Example
From Amazon B00BX0YKX4 (Fosmon Mini Bluetooth Keyboard QWERTY + Touchpad):
- Dual HID: keyboard (boot-like 8-byte mod+reserved+6keys) + mouse (3-5 byte relative)
- Uses Report IDs: ID 1 keyboard, ID 2 mouse; Report Map has two top-level collections `0x05 0x01 0x09 0x06 A1 01 85 01 ...` and `0x09 0x02 A1 01 85 02 ...`
- Some NimBLE stacks notify raw char value INCLUDING ID byte: `[0x02, buttons, x, y]` vs expected `[buttons, x, y]`
- Sleeps quickly, wakes on keypress with directed advertising (RPA, no name, ~30s window)
## Mouse Report Parsing — Pitfall & Fix (2026-07-13)
### Old Bug
```cpp
if (len >= 5) { // assumed Logitech 12-bit packed
raw_x = data[2] | ((data[3]&0x0F)<<8); sign-extend 12-bit
raw_y = (data[3]>>4) | (data[4]<<4);
}
```
Cheap touchpads send standard 4/5-byte `[btn, x8, y8, wheel, pan]`. Above decodes wheel byte as nibble → huge jumps.
### Fix Pattern (dual-heuristic)
1. Implement `*Internal(data,len,expected_report_id)` variants that strip leading ID if `expected_report_id !=0 && data[0]==id`.
2. For `plen>=5`, try both:
- std: `dx=(int8)p[1], dy=(int8)p[2]`
- 12-bit: `raw_x12 = p[2] | ((p[3]&0x0F)<<8)`, `raw_y12 = (p[3]>>4)|(p[4]<<4)` sign-extend 12-bit
3. Heuristic:
- `std_small = abs(dx)<=48 && abs(dy)<=48`, `s12_large = abs(raw_x12)>128 || abs(raw_y12)>128`
- If std_small && s12_large → prefer std (touchpad with wheel byte)
- If s12_large and raw_12 plausible `<=300` and std huge `>64` or `p[1]==0` → prefer 12-bit (Logitech)
- Else std
4. Always dispatch via Internal with `rpt.reportId` so ID stripped.
See `references/mouse-report-parsing.md`.
## BLE Scan Inclusive Caching — Pitfall & Fix
### Old Bug
`esp32_ble_scan.cpp:90` filtered nameless:
```cpp
if (!found && record.name[0] != '\0' && scan_count<64) // store only named
```
Bonded KB after sleep advertises only appearance+flags or directed (no name), uses RPA that rotates. Result:
- `scan_results` empty for that MAC
- `scan_addr_cache` misses → `getCachedScanAddrType()` fails → default PUBLIC, but device may be RANDOM → `ble_gap_connect rc!=0`
- `autoConnectHidHost()` never finds peer
### Fix
- Store ALL unique advertisers regardless of name. Still set `should_publish = name[0]!= '\0'` for UI cadence if needed, but cache must include nameless.
- On duplicate, always update `scan_addrs[i] = disc.addr` to track RPA rotation.
- `getScanResults()` now returns nameless too — UI shows "Unknown (aabbcc...)" which is useful for debugging; optionally filter weak RSSI in View layer.
See `references/scan-and-reconnect.md`.
## Reconnect — Addr Type & Retry
### Persistent auto-connect on boot (fix 2026-07-12)
Previous `autoConnectHidHost()` only connected if peer was seen in last scan. On reboot Fosmon is asleep → scan empty → no connect, and retry timer was only created inside `hidHostConnect()` so boot chain died after 1st scan.
Fix implemented in `BluetoothHidHost.cpp`:
- `ensureAutoConnTimer()` lazy creates `hid_autoconn_retry_timer`, called from `autoConnectHidHost()` itself (exists at boot)
- `hid_autoconn_empty_scan_count` tracks consecutive empty scans
- Phase 1: if peer in scanResults → connect with fresh addr_type + RSSI log
- Phase 2: not in scan but saved autoConnect peer exists:
- After `HID_AUTOCONN_DIRECT_AFTER=1` empty scan → direct `hidHostConnect(saved_addr)` even without advert — NimBLE does internal 8s scan/connect trying both PUBLIC/RANDOM
- On fail → reschedule timer 3s (fast), 10s, 30s (backoff) forever → catches wake on keypress (~30s directed adv window)
- Counter resets on successful connect, stops timer on connect
User confirmed: "keyboard mouse combo works great now" after mouse parse fix, but "If the board restart, it won't autoconnect" — this persistent direct-connect fixed it.
### Addr Type Fallback
NimBLE store can hold RANDOM, but cache lookup may miss. Pattern:
```cpp
ble_addr.type = PUBLIC; memcpy val
if (getCachedScanAddrType(...,&type)) type= cached;
rc = ble_gap_connect(..., PUBLIC, ...)
if (rc!=0 && type==PUBLIC) retry RANDOM;
if (rc!=0 && cached==RANDOM) retry PUBLIC;
```
Log which type used. Timeout 5s → 8s for slow wake.
### Periodic Retry Timer
Keyboard wakes on keypress for ~30s directed adv. If scan ended just before wake, single rescan misses it. Add `hid_autoconn_retry_timer` esp_timer (ESP_TIMER_TASK):
- Created lazily in `hidHostConnect`
- Stopped on successful connect
- In `autoConnectHidHost()` when device not found but autoConnect peers exist: `esp_timer_start_once(retry_timer, 3*1e6)`
- Callback `hidAutoConnRetryCb` starts scan if not scanning; `SCAN_FINISHED` -> `autoConnectHidHost()` again.
Prevent tight loop after manual disconnect? TODO in code: if user manually disconnects and autoConnect still true, it will keep retrying. Separate toggle or respect manual intent.
### `hid_host_active` Guard
`BleCtx.hid_host_active` prevents simultaneous central connections during `ble_resolve_next_unnamed_peer` name resolution (would fail `BLE_HS_EALREADY`). Set true in `hidHostConnect`, false on disconnect/connect fail.
## Build Env Pitfall (Hermes venv pollution)
From 2026-07-11/13 sessions:
- Hermes desktop venv Python 3.11 `pydantic` (3.11 `pydantic_core`) leaks via `PYTHONPATH` into ESP-IDF 3.9 env → `ModuleNotFoundError: pydantic_core._pydantic_core` or `TypeError: unsupported operand type(s) for |`
- Toolchain xtensa `xtensa-esp32s3-elf-gcc` not in PATH when PATH stripped to only idf env
- Also, if `ESP_IDF_VERSION` env unset, CMakeLists chooses simulator path → `Unknown CMake command idf_build_get_property` + SDL detection `SDL Threads needed` even after `device.py`
Fix wrapper (board-direct per user 2026-07-12 — **never simulator** for BT/driver changes, user said "Don't compile for simulator is a waste of time, build for the board directly"):
```bash
cd firmware
rm -rf build sdkconfig # required when lvgl.fontSize changed or switching python envs
env -u PYTHONPATH -u PYTHONHOME -u ESP_IDF_VERSION -u IDF_PATH \
PATH=$HOME/.espressif/python_env/idf5.3_py3.9_env/bin:$HOME/.espressif/tools/xtensa-esp-elf/esp-13.2.0_20240530/xtensa-esp-elf/bin:/Users/adolforeyna/esp/esp-idf/tools:/opt/homebrew/bin:/usr/bin:/bin \
python device.py waveshare-esp32-s3-rlcd
grep FONT_SIZE sdkconfig # verify 14/18/24 after font bump
# then
env -u PYTHONPATH -u PYTHONHOME \
PATH=...same... IDF_PATH=/Users/adolforeyna/esp/esp-idf ESP_IDF_VERSION=5.3 \
idf.py -p /dev/cu.usbmodem1101 flash
```
User preference: "Don't compile for simulator is a waste of time, build for the board directly." Enforced 2026-07-12.
For font bumps: always `rm -rf build` after editing `device.properties` lvgl.fontSize, else old `TT_LVGL_TEXT_FONT_DEFAULT_SIZE=14` stays cached.
Quick sanity without full build: `grep -rn "hidHostHandleMouseReport\|hid_host_ctx\|scan_results" firmware/ --include="*.cpp" --include="*.h"`
env -u PYTHONPATH -u PYTHONHOME -u ESP_IDF_VERSION -u IDF_PATH \
PATH=$HOME/.espressif/python_env/idf5.3_py3.9_env/bin:$HOME/.espressif/tools/xtensa-esp-elf/esp-13.2.0_20240530/xtensa-esp-elf/bin:/Users/adolforeyna/esp/esp-idf/tools:/opt/homebrew/bin:/usr/bin:/bin \
python device.py waveshare-esp32-s3-rlcd
# then
env -u PYTHONPATH ...same PATH... idf.py build
# flash
env -u PYTHONPATH ... idf.py -p /dev/cu.usbmodem1101 flash
```
User preference: "Don't compile for simulator is a waste of time, build for the board directly." Enforced 2026-07-12.
Quick sanity without full build: `grep -rn "hidHostHandleMouseReport\|hid_host_ctx\|scan_results" firmware/ --include="*.cpp" --include="*.h"`
## Common Pitfalls
1. Assuming 5-byte mouse = 12-bit. Always implement dual heuristic.
2. Forgetting Report ID strip — combo devices include ID. Pass `rpt.reportId` to handler.
3. Filtering scan to named only — breaks bonded auto-connect. Cache all, filter UI only.
4. Not updating `scan_addrs` on duplicate — RPA rotation missed.
5. Only trying PUBLIC — many keyboards use RANDOM RPA. Always fallback.
6. Single scan after disconnect — misses wake window. Use 3s retry timer.
7. `hid_host_active` not cleared on connect fail — blocks future name resolution.
## References
- `references/combo-keyboard-mouse.md` — Fosmon report map structure, ID table, sleep/wake behavior
- `references/mouse-report-parsing.md` — dual-decode code snippet + test vectors
- `references/scan-and-reconnect.md` — inclusive cache patch, addr_type matrix, retry timer pattern
@@ -0,0 +1,39 @@
# Combo Keyboard + Touchpad Devices (Fosmon Example)
## Device
Fosmon Mini Bluetooth Keyboard QWERTY + Touchpad, Amazon ASIN B00BX0YKX4
Short link from user: https://a.co/d/0fg4vprm → redirects to B00BX0YKX4
Compatible with Apple TV, Fire Stick, PS4/5, HTPC, smartphones — cheap BLE HID.
### BLE HID Report Map Structure (typical)
- Top-level Collection 1: Generic Desktop, Usage Keyboard (0x06)
- Report ID 1 (0x85 0x01)
- Input: mod keys, reserved, 6 keys (8 bytes)
- Top-level Collection 2: Generic Desktop, Usage Mouse (0x02)
- Report ID 2 (0x85 0x02)
- Collection Physical → Input: buttons (5 bits + 3 pad), X, Y, Wheel? → 3-4 bytes
- Possibly Consumer (0x0C) for media keys on Report ID 3
Example hex fragments:
```
05 01 09 06 A1 01 85 01 ... 95 06 75 08 81 00 C0
05 01 09 02 A1 01 85 02 09 01 A1 00 05 09 19 01 29 05 15 00 25 01 75 01 95 05 81 02 75 03 95 01 81 01 05 01 09 30 09 31 09 38 15 81 25 7F 75 08 95 03 81 06 C0 C0
```
### Behavior
- Sleeps after ~5-10 min idle, stops advertising
- Wakes on any key/touch → sends directed advertising to bonded host for ~30s (RPA, often no complete name, only flags + maybe appearance 0x03C1 keyboard or 0x03C2 mouse or 0x03C0 combo)
- Some cheap firmwares use PUBLIC address, others use RANDOM resolvable (RPA rotates every ~15 min)
- After wake, expects central to connect quickly; if missed, sleeps again → need periodic scan retry
### Tactility Specifics
- `hid_host_active` atomic in BleCtx prevents simultaneous central connect with name resolution chain `ble_resolve_next_unnamed_peer` (would get BLE_HS_EALREADY)
- `getScanResults()` originally filtered nameless → missed directed adv → no auto-connect
- `applyReportMapTypes` depth logic only considers depth 0 Collection type; works for Fosmon because collections are top-level, but nested mouse would fail
### Testing Checklist
- Pair → verify Report Map log shows `Report val_handle=... reportId=1 type=0/1 etc` for 2 reports
- Move touchpad slowly → cursor should move 1-5 px per packet, not jump 200+
- Press keyboard keys → modifier handling (shift) works
- Let KB sleep 10 min → press key → Tactility should scan within 3s and reconnect (log `Auto-connect retry timer fired``Connecting... addr_type=`)
- Manual Connect from Paired screen with unknown addr_type should try PUBLIC then RANDOM fallback and log rc
@@ -0,0 +1,46 @@
# Mouse Report Parsing for Combo KB+Touchpad
## Context
Session 2026-07-12, user device Fosmon Mini BT Keyboard + Touchpad (Amazon B00BX0YKX4, short link https://a.co/d/0fg4vprm). Field fix for Tactility HID Host.
### Typical Reports
- Keyboard: 8 bytes boot `[mod, 0x00, key0..key5]` optionally prefixed with Report ID 1 → 9 bytes `[0x01, mod, 0, k0..k5]`
- Mouse/touchpad: 3-5 bytes `[buttons, x_rel, y_rel, wheel, pan]` optionally `[ID, ...]`
- Fosmon: likely `[btn, x, y, wheel]` 4 bytes, Report ID 2 → notify `[0x02, btn, x, y]` or `[btn, x, y, wheel]`
- Logitech high-res: 5 bytes packed 12-bit `[btn, ?, x_low, x_hi_nibble|y_low_nibble, y_high]`
### Old Code Failure
```cpp
if (len >=5) {
raw_x = data[2] | ((data[3] & 0x0F)<<8); // treats wheel as nibble
...
}
```
For standard packet `[0x01 btn, 0x05 dx, 0xFE dy (-2), 0x00 wheel, 0x00]` → raw_x = 0xFE | (0x00<<8)=254 → sign-extend → large jump.
### Fixed Code Snippet
```cpp
static void hidHostHandleMouseReportInternal(const uint8_t* data, uint16_t len, uint8_t expected_report_id) {
const uint8_t *p=data; uint16_t plen=len;
if (expected_report_id!=0 && len>=2 && data[0]==expected_report_id) { p=data+1; plen=len-1; }
if (plen<3) return;
if (plen>=5) {
int8_t std_dx=(int8_t)p[1], std_dy=(int8_t)p[2];
int16_t raw_x12 = p[2] | ((p[3]&0x0F)<<8); if (raw_x12&0x0800) raw_x12|=0xF000;
int16_t raw_y12 = (int16_t)((p[3]>>4)|(p[4]<<4)); if (raw_y12&0x0800) raw_y12|=0xF000;
bool std_small = abs(std_dx)<=48 && abs(std_dy)<=48;
bool s12_large = abs(raw_x12)>128 || abs(raw_y12)>128;
// heuristic: small std + large 12 → std (touchpad)
// large std pathological + plausible 12 <=300 → 12-bit (Logitech)
} else { btn=p[0]&1; dx=(int8_t)p[1]; dy=(int8_t)p[2]; }
}
```
### Keyboard ID Strip
Same pattern for keyboardInternal.
### NOTIFY_RX Dispatch Update
Pass `rpt.reportId` to Internal variants. For Unknown type, compute effective_len stripping ID, then route by len >=8 keyboard else >=3 mouse.
### Files Changed
- Tactility/Source/bluetooth/BluetoothHidHost.cpp: added Internal variants, heuristic, ID strip, retry timer `hid_autoconn_retry_timer`, dual addr_type try
@@ -0,0 +1,73 @@
# BLE Scan & Reconnect Fixes
## Problem 1 — Nameless Filter Dropped Bonded Keyboards
### Location
`Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp:ble_gap_disc_event_handler`
### Old
```cpp
if (!found && record.name[0] != '\0' && ctx->scan_count < 64) { store }
```
### Fixed
```cpp
if (!found && ctx->scan_count < 64) {
// Store ALL unique advertisers, not only named
// Bonded keyboards (e.g. Fosmon combo) may advertise with only Appearance+flags or directed RPA without name
ctx->scan_results[ctx->scan_count]=record;
ctx->scan_addrs[ctx->scan_count]=disc.addr;
ctx->scan_count++;
should_publish = true; // publish even nameless for cache
}
if (found) {
// also update addr_type for RPA rotation
ctx->scan_addrs[i]=disc.addr;
}
```
### Impact
- `getScanResults()` now includes unknowns → UI shows "Unknown (aabbcc...)" entries; useful debug. Can filter weak RSSI later.
- `cacheScanAddr()` / `getCachedScanAddrType()` now works for nameless adv, so `hidHostConnect` gets correct type.
## Problem 2 — Addr Type PUBLIC vs RANDOM
### Symptom
`ble_gap_connect` fails rc=2 BLE_HS_EALREADY or other when using PUBLIC for device that uses RANDOM RPA.
### Fix in BluetoothHidHost.cpp:hidHostConnect
- Lookup cache: log type
- Try PUBLIC -> if fail, retry RANDOM
- If cached type was RANDOM and both fail, retry PUBLIC
- Increase timeout 5000 -> 8000ms
- Log final `addr_type`
## Problem 3 — Single Scan Misses Wake Window
### Symptom
Fosmon sleeps, stops advertising. Wakes on keypress with directed adv ~30s. If Tactility scanned just before wake, misses.
### Fix
Add retry timer:
```cpp
static esp_timer_handle_t hid_autoconn_retry_timer = nullptr;
static void hidAutoConnRetryCb(void*) {
if (hidHostIsConnected()) return;
if (!bluetooth_is_scanning(dev)) bluetooth_scan_start(dev);
}
```
In autoConnectHidHost() when not found but autoConnect peers exist:
```cpp
esp_timer_start_once(hid_autoconn_retry_timer, 3*1e6);
```
Stopped in hidHostConnect() when connecting, and on successful ready chain.
Also `bt_event_bridge` SCAN_FINISHED already calls autoConnectHidHost, so loop: scan -> not found -> schedule 3s -> scan -> etc.
### Files Changed
- esp32_ble_scan.cpp inclusive cache + addr update
- BluetoothHidHost.cpp dual addr fallback + retry timer + logging
- No change needed in Bluetooth.cpp bt_event_bridge, but benefits from inclusive cache
## Remaining TODO
- Manual disconnect with autoConnect=true still retriggers immediate rescan (TODO in code "Fix auto reconnect if user manually disconnects"). Could require user to toggle Auto-connect switch off, or add 10s cooldown.