Files
wadamesh/src/ui-touch/BleKeyboard.cpp
T
Kaj SchittecatandClaude Opus 5 793a673c64 touch: Bluetooth LE keyboards on every S3 board
Bluetooth serves either the phone app or a keyboard (Settings > Bluetooth).
Pairing lists keyboards in pairing mode and pairs with or without a code;
the paired keyboard reconnects by itself.

Keys are read the way phones and computers read them: the keyboard's Report
Map says which report carries the keys and how, so media keys and touchpads
are left alone, and the boot protocol is only the fallback. Key positions are
translated with the chosen layout (US, UK, German, French, Belgian), with
AltGr and dead keys.

While a keyboard is connected:
- text goes into the focused field, and touchscreen-only boards keep the
  on-screen keyboard down (a second tap on a field brings it up anyway)
- the arrows, Tab, Enter and Page Up/Down drive the focus highlight
- Esc is the back button; a "Back key" setting picks another key for
  keyboards whose Esc key types a character
- tab hotkeys and their hints over the tab bar are switched on
- Command tapped alone opens the emoji picker in a chat
- the status bar shows a keyboard instead of the Bluetooth glyph

The Bluetooth page is now a flex column with the choice on top, and the
settings page refit keeps it scrollable when its content grows.

Settings: prefs v61 (mode, layout, paired keyboard) and v62 (Back key).
Tests: test/test_hid_report_map.cpp, test_touch_prefs_schema.cpp.
Not on the P4 boards (Tanmatsu, T-Display P4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:52:21 +02:00

1268 lines
41 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
//
// External Bluetooth LE keyboard: a HID-over-GATT host on NimBLE's central
// role. See BleKeyboard.h for the threading contract.
//
// Keys are read the way phones and computers read them: the keyboard's Report
// Map says which report carries the keys and how they are laid out (a six-key
// array, or one bit per key), so only those reports are subscribed and media
// keys or a touchpad on the same keyboard are left alone. A keyboard whose map
// cannot be used is switched to the boot protocol, whose 8-byte report has one
// fixed layout; one that ignores that request is read in the same layout on
// its report characteristics. The keys are then translated with the layout
// the user picks, because a keyboard only sends key positions: an AZERTY
// keyboard and a QWERTY keyboard send the same code for the key left of Z/W.
#include "BleKeyboard.h"
#if CAP_BLE_KEYBOARD
#include <Arduino.h>
#include <NimBLEDevice.h>
#if defined(CONFIG_NIMBLE_CPP_IDF)
# include "host/ble_hs.h"
#else
# include "nimble/nimble/host/include/host/ble_hs.h"
#endif
#include <cstring>
#include "esp_heap_caps.h"
#include "esp_system.h" // esp_random()
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include <helpers/input/HidReportMap.h>
namespace BleKbd {
namespace {
constexpr uint16_t kUuidHid = 0x1812;
constexpr uint16_t kUuidReport = 0x2A4D;
constexpr uint16_t kUuidProtocolMode = 0x2A4E;
constexpr uint16_t kUuidBootKbdIn = 0x2A22;
constexpr uint16_t kUuidBootKbdOut = 0x2A32;
constexpr uint16_t kUuidReportRef = 0x2908;
constexpr uint16_t kUuidReportMap = 0x2A4B;
constexpr uint32_t kRepeatDelayMs = 500;
constexpr uint32_t kRepeatRateMs = 40;
constexpr uint32_t kRetryMs = 3000;
constexpr uint32_t kScanSeconds = 30;
portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED;
// Shared between the UI, the worker and the NimBLE host task (under s_mux).
bool s_active = false;
State s_state = State::Off;
Error s_error = Error::None;
uint32_t s_gen = 0;
uint32_t s_code = 0;
Device s_peer = {};
bool s_have_peer = false;
bool s_new_pairing = false;
bool s_forgotten = false;
Layout s_layout = Layout::US;
uint8_t s_back_usage = 0; // a second Back key, 0 = none
bool s_capture = false; // the next key pressed is kept, not typed
bool s_have_captured = false;
uint8_t s_captured = 0;
constexpr int kMaxDevices = 12;
Device s_devices[kMaxDevices];
int s_device_count = 0;
// Key ring: filled by the NimBLE host task, drained by the UI loop.
constexpr int kRing = 64;
int s_ring[kRing];
uint8_t s_head = 0;
uint8_t s_tail = 0;
// Typematic repeat of the last key still held (under s_mux).
int s_held_code = 0;
uint8_t s_held_usage = 0;
uint32_t s_held_since = 0;
uint32_t s_next_repeat = 0;
// This link's reports. The worker fills these in setupHid() before it
// subscribes, and the NimBLE host task only reads them afterwards.
HidReportMapInfo s_map = {};
bool s_map_ok = false;
uint16_t s_boot_handle = 0; // boot keyboard input report, 0 when not subscribed
constexpr int kMaxReportChars = 8;
struct ReportChar {
uint16_t handle;
int8_t layout; // index into s_map.kbd, or -1: no usable map, read as a boot report
};
ReportChar s_report_chars[kMaxReportChars];
uint8_t s_report_char_count = 0;
// Report decoding (NimBLE host task only).
// When a keyboard ignores the switch to the boot protocol, the same keys
// could arrive on both channels; the first one that delivers keys is used for
// the rest of the link.
enum : uint8_t { kSrcNone, kSrcBoot, kSrcReport };
uint8_t s_src = kSrcNone;
#if BLE_KBD_TRACE
uint32_t s_report_logs = 0; // the first reports of each link are logged
#endif
uint8_t s_prev_keys[6] = {};
// Command (GUI) tapped on its own, with nothing else pressed while it was down.
bool s_gui_down = false;
bool s_gui_alone = false;
uint32_t s_gui_since = 0;
constexpr uint32_t kGuiTapMs = 1000; // held longer than this it was not a tap
bool s_caps = false;
uint32_t s_dead = 0; // pending dead-key accent, 0 = none
// Worker.
enum class CmdType : uint8_t { Activate, Deactivate, ScanOn, ScanOff, Pair, Forget, SyncLeds };
struct Cmd { CmdType type; int8_t index; bool with_code; };
TaskHandle_t s_task = nullptr;
QueueHandle_t s_cmdq = nullptr;
NimBLEClient* s_client = nullptr;
// Caps Lock LED: the boot output report, or the output report of the report
// protocol, whichever matches the channel the keys arrive on. Both point into
// the client's attribute list, which connect() rebuilds, so they are cleared
// before every connect.
NimBLERemoteCharacteristic* s_led_boot = nullptr;
NimBLERemoteCharacteristic* s_led_report = nullptr;
volatile bool s_connecting = false; // a connect() is waiting; ble_gap_conn_cancel() ends it
volatile bool s_suspended = false; // off the air for now; the user's choice is unchanged
volatile bool s_busy = false;
void bumpLocked() { s_gen++; }
void setState(State st) {
portENTER_CRITICAL(&s_mux);
s_state = st;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
}
void setError(Error e) {
portENTER_CRITICAL(&s_mux);
s_error = e;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
}
State getState() {
portENTER_CRITICAL(&s_mux);
const State st = s_state;
portEXIT_CRITICAL(&s_mux);
return st;
}
bool havePeer(Device* out) {
portENTER_CRITICAL(&s_mux);
const bool have = s_have_peer;
if (have && out) *out = s_peer;
portEXIT_CRITICAL(&s_mux);
return have;
}
bool isLinkUp() { return s_client && s_client->isConnected(); }
// ---- key output -------------------------------------------------------------
void clearHeld() {
portENTER_CRITICAL(&s_mux);
s_held_code = 0;
s_held_usage = 0;
portEXIT_CRITICAL(&s_mux);
}
void pushKey(int code) {
const uint8_t next = (uint8_t)((s_head + 1) % kRing);
if (next == s_tail) return; // full: drop rather than block the host task
s_ring[s_head] = code;
s_head = next;
}
void emit(int code, uint8_t usage, bool repeatable) {
pushKey(code);
const uint32_t now = millis();
portENTER_CRITICAL(&s_mux);
if (repeatable) {
s_held_code = code;
s_held_usage = usage;
s_held_since = now;
s_next_repeat = now + kRepeatDelayMs;
} else {
s_held_code = 0;
s_held_usage = 0;
}
portEXIT_CRITICAL(&s_mux);
}
// ---- layouts ------------------------------------------------------------------
constexpr uint16_t DK = 0x8000; // table flag: dead key (accent waits for the next key)
constexpr uint32_t kDeadFlag = 0x80000000;
struct KeyMap { uint8_t usage; uint16_t base, shift, altgr; };
// US: every non-letter key. Other layouts list what differs; unlisted
// non-letters fall back to this table and letters to their US position.
constexpr KeyMap kUS[] = {
{0x1E,'1','!',0}, {0x1F,'2','@',0}, {0x20,'3','#',0}, {0x21,'4','$',0}, {0x22,'5','%',0},
{0x23,'6','^',0}, {0x24,'7','&',0}, {0x25,'8','*',0}, {0x26,'9','(',0}, {0x27,'0',')',0},
{0x2D,'-','_',0}, {0x2E,'=','+',0}, {0x2F,'[','{',0}, {0x30,']','}',0}, {0x31,'\\','|',0},
{0x32,'\\','|',0}, {0x33,';',':',0}, {0x34,'\'','"',0}, {0x35,'`','~',0}, {0x36,',','<',0},
{0x37,'.','>',0}, {0x38,'/','?',0}, {0x64,'\\','|',0},
};
constexpr KeyMap kUK[] = {
{0x1F,'2','"',0}, {0x20,'3',0xA3,0}, {0x21,'4','$',0x20AC},
{0x31,'#','~',0}, {0x32,'#','~',0}, {0x34,'\'','@',0}, {0x35,'`',0xAC,0xA6},
{0x64,'\\','|',0},
};
constexpr KeyMap kDE[] = {
{0x1C,'z','Z',0}, {0x1D,'y','Y',0},
{0x14,'q','Q','@'}, {0x08,'e','E',0x20AC}, {0x10,'m','M',0xB5},
{0x1E,'1','!',0}, {0x1F,'2','"',0xB2}, {0x20,'3',0xA7,0xB3}, {0x21,'4','$',0},
{0x22,'5','%',0}, {0x23,'6','&',0}, {0x24,'7','/','{'}, {0x25,'8','(','['},
{0x26,'9',')',']'}, {0x27,'0','=','}'},
{0x2D,0xDF,'?','\\'}, {0x2E,DK|0xB4,DK|'`',0}, {0x2F,0xFC,0xDC,0}, {0x30,'+','*','~'},
{0x31,'#','\'',0}, {0x32,'#','\'',0}, {0x33,0xF6,0xD6,0}, {0x34,0xE4,0xC4,0},
{0x35,DK|'^',0xB0,0}, {0x36,',',';',0}, {0x37,'.',':',0}, {0x38,'-','_',0},
{0x64,'<','>','|'},
};
constexpr KeyMap kFR[] = {
{0x04,'q','Q',0}, {0x14,'a','A',0}, {0x1A,'z','Z',0}, {0x1D,'w','W',0}, {0x33,'m','M',0},
{0x08,'e','E',0x20AC},
{0x10,',','?',0}, {0x36,';','.',0}, {0x37,':','/',0}, {0x38,'!',0xA7,0},
{0x1E,'&','1',0}, {0x1F,0xE9,'2',DK|'~'}, {0x20,'"','3','#'}, {0x21,'\'','4','{'},
{0x22,'(','5','['}, {0x23,'-','6','|'}, {0x24,0xE8,'7',DK|'`'}, {0x25,'_','8','\\'},
{0x26,0xE7,'9','^'}, {0x27,0xE0,'0','@'},
{0x2D,')',0xB0,']'}, {0x2E,'=','+','}'}, {0x2F,DK|'^',DK|0xA8,0}, {0x30,'$',0xA3,0xA4},
{0x31,'*',0xB5,0}, {0x32,'*',0xB5,0}, {0x34,0xF9,'%',0}, {0x35,0xB2,0,0},
{0x64,'<','>',0},
};
constexpr KeyMap kBE[] = {
{0x04,'q','Q',0}, {0x14,'a','A',0}, {0x1A,'z','Z',0}, {0x1D,'w','W',0}, {0x33,'m','M',0},
{0x08,'e','E',0x20AC},
{0x10,',','?',0}, {0x36,';','.',0}, {0x37,':','/',0}, {0x38,'=','+',DK|'~'},
{0x1E,'&','1','|'}, {0x1F,0xE9,'2','@'}, {0x20,'"','3','#'}, {0x21,'\'','4',0},
{0x22,'(','5',0}, {0x23,0xA7,'6','^'}, {0x24,0xE8,'7',0}, {0x25,'!','8',0},
{0x26,0xE7,'9','{'}, {0x27,0xE0,'0','}'},
{0x2D,')',0xB0,0}, {0x2E,'-','_',0}, {0x2F,DK|'^',DK|0xA8,'['}, {0x30,'$','*',']'},
{0x31,0xB5,0xA3,DK|'`'}, {0x32,0xB5,0xA3,DK|'`'}, {0x34,0xF9,'%',DK|0xB4},
{0x35,0xB2,0xB3,0}, {0x64,'<','>','\\'},
};
struct LayoutDef { const char* name; const KeyMap* keys; size_t count; };
constexpr LayoutDef kLayouts[] = {
{"US", kUS, sizeof(kUS) / sizeof(kUS[0])},
{"UK", kUK, sizeof(kUK) / sizeof(kUK[0])},
{"DE", kDE, sizeof(kDE) / sizeof(kDE[0])},
{"FR", kFR, sizeof(kFR) / sizeof(kFR[0])},
{"BE", kBE, sizeof(kBE) / sizeof(kBE[0])},
};
const KeyMap* findKey(const KeyMap* table, size_t count, uint8_t usage) {
for (size_t i = 0; i < count; i++)
if (table[i].usage == usage) return &table[i];
return nullptr;
}
// A key whose shifted value is its own capital is a letter, so Caps Lock flips it.
bool isCasePair(uint16_t lower, uint16_t upper) {
if (lower >= 'a' && lower <= 'z') return upper == lower - 32;
if (lower >= 0xE0 && lower <= 0xFE && lower != 0xF7) return upper == lower - 0x20;
return false;
}
// Code point for a key, kDeadFlag | accent for a dead key, 0 for nothing.
uint32_t mapKey(Layout layout, uint8_t usage, bool shift, bool altgr, bool caps) {
const LayoutDef& def = kLayouts[(size_t)layout < (size_t)Layout::Count ? (size_t)layout : 0];
const KeyMap* km = findKey(def.keys, def.count, usage);
if (!km && def.keys != kUS) km = findKey(kUS, sizeof(kUS) / sizeof(kUS[0]), usage);
if (km) {
uint16_t v = altgr ? km->altgr : (shift ? km->shift : km->base);
if (!v) return 0;
if (caps && !altgr && !(v & DK) && isCasePair(km->base, km->shift))
v = shift ? km->base : km->shift;
return (v & DK) ? (kDeadFlag | (uint32_t)(v & 0x7FFF)) : v;
}
if (usage >= 0x04 && usage <= 0x1D) {
if (altgr) return 0;
const char c = (char)('a' + (usage - 0x04));
return (shift != caps) ? (uint32_t)(c - 32) : (uint32_t)c;
}
return 0;
}
struct Compose { uint16_t accent; char base; uint16_t out; };
constexpr Compose kCompose[] = {
{'^','a',0xE2}, {'^','e',0xEA}, {'^','i',0xEE}, {'^','o',0xF4}, {'^','u',0xFB},
{'^','A',0xC2}, {'^','E',0xCA}, {'^','I',0xCE}, {'^','O',0xD4}, {'^','U',0xDB},
{0xA8,'a',0xE4}, {0xA8,'e',0xEB}, {0xA8,'i',0xEF}, {0xA8,'o',0xF6}, {0xA8,'u',0xFC},
{0xA8,'y',0xFF}, {0xA8,'A',0xC4}, {0xA8,'E',0xCB}, {0xA8,'I',0xCF}, {0xA8,'O',0xD6},
{0xA8,'U',0xDC},
{0xB4,'a',0xE1}, {0xB4,'e',0xE9}, {0xB4,'i',0xED}, {0xB4,'o',0xF3}, {0xB4,'u',0xFA},
{0xB4,'y',0xFD}, {0xB4,'A',0xC1}, {0xB4,'E',0xC9}, {0xB4,'I',0xCD}, {0xB4,'O',0xD3},
{0xB4,'U',0xDA}, {0xB4,'Y',0xDD},
{'`','a',0xE0}, {'`','e',0xE8}, {'`','i',0xEC}, {'`','o',0xF2}, {'`','u',0xF9},
{'`','A',0xC0}, {'`','E',0xC8}, {'`','I',0xCC}, {'`','O',0xD2}, {'`','U',0xD9},
{'~','a',0xE3}, {'~','n',0xF1}, {'~','o',0xF5}, {'~','A',0xC3}, {'~','N',0xD1},
{'~','O',0xD5},
};
// Accent + key. Space gives the accent itself; 0 means "no such letter".
uint32_t compose(uint32_t accent, uint32_t cp) {
if (cp == ' ') return accent;
for (const Compose& c : kCompose)
if (c.accent == accent && (uint8_t)c.base == cp) return c.out;
return 0;
}
// ---- reports ------------------------------------------------------------------
void requestLedSync() {
if (!s_cmdq) return;
const Cmd c{CmdType::SyncLeds, 0, false};
xQueueSend(s_cmdq, &c, 0);
}
void pressKey(uint8_t usage, uint8_t mod) {
portENTER_CRITICAL(&s_mux);
const bool capture = s_capture;
if (capture) {
s_capture = false;
s_captured = usage;
s_have_captured = true;
bumpLocked();
}
const uint8_t back = s_back_usage;
portEXIT_CRITICAL(&s_mux);
if (capture) return; // the settings page is picking a key
if (back && usage == back) {
s_dead = 0;
emit(BLE_KEY_ESC, usage, false);
return;
}
const bool shift = (mod & 0x22) != 0;
const bool altgr = (mod & 0x40) != 0 || (mod & 0x05) == 0x05; // right Alt, or Ctrl+Alt
const bool ctrl = (mod & 0x11) != 0 && !altgr;
const bool alt = (mod & 0x04) != 0 && !altgr;
const bool gui = (mod & 0x88) != 0;
int special = 0;
bool repeat = true;
switch (usage) {
case 0x28: case 0x58: special = BLE_KEY_ENTER; repeat = false; break;
case 0x29: special = BLE_KEY_ESC; repeat = false; break;
case 0x2A: special = BLE_KEY_BACKSPACE; break;
case 0x2B: special = shift ? BLE_KEY_SHIFT_TAB : BLE_KEY_TAB; repeat = false; break;
case 0x39: s_caps = !s_caps; requestLedSync(); return;
case 0x4A: special = BLE_KEY_HOME; repeat = false; break;
case 0x4B: special = BLE_KEY_PAGE_UP; break;
case 0x4C: special = BLE_KEY_DELETE; break;
case 0x4D: special = BLE_KEY_END; repeat = false; break;
case 0x4E: special = BLE_KEY_PAGE_DOWN; break;
case 0x4F: special = BLE_KEY_RIGHT; break;
case 0x50: special = BLE_KEY_LEFT; break;
case 0x51: special = BLE_KEY_DOWN; break;
case 0x52: special = BLE_KEY_UP; break;
default: break;
}
if (special) {
s_dead = 0;
emit(special, usage, repeat);
return;
}
if (gui || ctrl || alt) return; // shortcuts, not text
portENTER_CRITICAL(&s_mux);
const Layout layout = s_layout;
portEXIT_CRITICAL(&s_mux);
uint32_t cp = 0;
if (usage == 0x2C) {
cp = ' ';
} else if (usage >= 0x54 && usage <= 0x63) {
static constexpr char kPad[] = "/*-+\n1234567890."; // 0x58 (keypad Enter) is handled above
cp = (uint8_t)kPad[usage - 0x54];
} else {
cp = mapKey(layout, usage, shift, altgr, s_caps);
}
if (!cp) return;
if (cp & kDeadFlag) {
if (s_dead) emit(BLE_KEY_TEXT | (int)s_dead, usage, false); // a second accent: type the first
s_dead = cp & ~kDeadFlag;
return;
}
if (s_dead) {
const uint32_t accent = s_dead;
s_dead = 0;
const uint32_t composed = compose(accent, cp);
if (composed) {
cp = composed;
} else {
pushKey(BLE_KEY_TEXT | (int)accent); // no such letter: the accent, then the key
}
}
emit(BLE_KEY_TEXT | (int)cp, usage, true);
}
// The fixed boot layout: modifiers, a reserved byte, six key usages.
bool bootReport(const uint8_t* data, size_t len, uint8_t* mod, uint8_t keys[6]) {
if (len < 8) return false;
if (data[2] >= 0x01 && data[2] <= 0x03) return false; // rollover error: the state is unknown
*mod = data[0];
memcpy(keys, data + 2, 6);
return true;
}
// -2: not a keyboard report of this link.
int reportLayout(uint16_t handle) {
for (uint8_t i = 0; i < s_report_char_count; i++)
if (s_report_chars[i].handle == handle) return s_report_chars[i].layout;
return -2;
}
void onReport(NimBLERemoteCharacteristic* chr, uint8_t* data, size_t len, bool) {
if (!chr) return;
const uint16_t handle = chr->getHandle();
const bool from_boot = s_boot_handle != 0 && handle == s_boot_handle;
#if BLE_KBD_TRACE
if (s_report_logs < 400) {
s_report_logs++;
char hex[3 * 12 + 1] = "";
for (size_t i = 0; i < len && i < 12; i++) snprintf(hex + 3 * i, 4, "%02X ", data[i]);
Serial.printf("[blekbd] report %u len=%u: %s\n", (unsigned)handle, (unsigned)len, hex);
}
#endif
uint8_t mod = 0;
uint8_t keys[6] = {};
bool ok = false;
if (from_boot) {
ok = bootReport(data, len, &mod, keys);
} else {
const int layout = reportLayout(handle);
if (layout >= 0) ok = hidDecodeKeyboard(s_map.kbd[layout], data, len, &mod, keys);
else if (layout == -1 && len == 8) ok = bootReport(data, len, &mod, keys);
}
if (!ok) return;
const uint8_t src = from_boot ? kSrcBoot : kSrcReport;
if (s_src == kSrcNone) s_src = src;
else if (s_src != src) return; // the same keys on the other channel
bool any_key = false;
for (int i = 0; i < 6; i++) any_key |= keys[i] >= 0x04;
const bool gui = (mod & 0x88) != 0;
const bool others = any_key || (mod & ~0x88) != 0;
if (gui && !s_gui_down) {
s_gui_alone = !others;
s_gui_since = millis();
} else if (gui && others) {
s_gui_alone = false; // used as a modifier
} else if (!gui && s_gui_down && s_gui_alone && millis() - s_gui_since < kGuiTapMs) {
emit(BLE_KEY_EMOJI, 0, false);
}
s_gui_down = gui;
portENTER_CRITICAL(&s_mux);
const uint8_t held = s_held_usage;
portEXIT_CRITICAL(&s_mux);
bool held_down = false;
for (int i = 0; i < 6; i++) held_down |= (held != 0 && keys[i] == held);
if (!held_down) clearHeld();
for (int i = 0; i < 6; i++) {
const uint8_t k = keys[i];
if (k < 0x04) continue;
bool was_down = false;
for (int j = 0; j < 6; j++) was_down |= (s_prev_keys[j] == k);
if (!was_down) pressKey(k, mod);
}
memcpy(s_prev_keys, keys, sizeof s_prev_keys);
}
// ---- link -----------------------------------------------------------------------
class ClientCallbacks : public NimBLEClientCallbacks {
void onDisconnect(NimBLEClient*) override {
memset(s_prev_keys, 0, sizeof s_prev_keys);
s_gui_down = false;
s_gui_alone = false;
s_dead = 0;
clearHeld();
portENTER_CRITICAL(&s_mux);
if (s_state == State::Connected) s_state = s_active ? State::Waiting : State::Off;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
}
};
ClientCallbacks s_client_cb;
class ScanCallbacks : public NimBLEAdvertisedDeviceCallbacks {
void onResult(NimBLEAdvertisedDevice* adv) override {
const bool hid = adv->isAdvertisingService(NimBLEUUID(kUuidHid));
const uint16_t appearance = adv->haveAppearance() ? adv->getAppearance() : 0;
const bool hid_appearance = appearance >= 0x03C0 && appearance <= 0x03CF;
if (!hid && !hid_appearance) return;
Device dev = {};
if (adv->haveName()) {
const std::string n = adv->getName();
strncpy(dev.name, n.c_str(), sizeof dev.name - 1);
}
const NimBLEAddress addr = adv->getAddress();
memcpy(dev.addr, addr.getNative(), 6);
dev.addr_type = addr.getType();
const int rssi = adv->getRSSI();
dev.rssi = (int8_t)(rssi < -127 ? -127 : (rssi > 0 ? 0 : rssi));
dev.keyboard = appearance == 0x03C1;
portENTER_CRITICAL(&s_mux);
int slot = -1;
for (int i = 0; i < s_device_count; i++)
if (memcmp(s_devices[i].addr, dev.addr, 6) == 0) { slot = i; break; }
const bool fresh = slot < 0;
if (slot < 0 && s_device_count < kMaxDevices) slot = s_device_count++;
if (slot >= 0) {
if (!dev.name[0]) memcpy(dev.name, s_devices[slot].name, sizeof dev.name);
s_devices[slot] = dev;
bumpLocked();
}
portEXIT_CRITICAL(&s_mux);
if (fresh && slot >= 0)
Serial.printf("[blekbd] found %s %s appearance=0x%04X hid=%d rssi=%d\n",
dev.name[0] ? dev.name : "?", addr.toString().c_str(), appearance, hid ? 1 : 0,
(int)dev.rssi);
}
};
ScanCallbacks s_scan_cb;
State restingState() {
if (isLinkUp()) return State::Connected;
portENTER_CRITICAL(&s_mux);
const State st = !s_active ? State::Off : (s_have_peer ? State::Waiting : State::Idle);
portEXIT_CRITICAL(&s_mux);
return st;
}
void onScanDone(NimBLEScanResults) {
if (getState() == State::Scanning) setState(restingState());
}
void stopScan() {
NimBLEScan* scan = NimBLEDevice::getScan();
if (scan->isScanning()) scan->stop();
}
// Device::addr holds the stack's own byte order (NimBLEAddress::getNative()).
// Rebuild through ble_addr_t: the uint8_t[6] constructor expects display order
// and would reverse it.
NimBLEAddress toAddress(const Device& dev) {
ble_addr_t a;
a.type = dev.addr_type;
memcpy(a.val, dev.addr, sizeof a.val);
return NimBLEAddress(a);
}
bool ensureClient() {
if (!NimBLEDevice::getInitialized()) {
s_client = nullptr;
return false;
}
if (!s_client) {
s_client = NimBLEDevice::createClient();
if (!s_client) return false;
s_client->setClientCallbacks(&s_client_cb, false);
// 15-30 ms interval, 4 s supervision timeout; while connecting, listen
// 30 ms of every 100 ms so the radio still has room for Wi-Fi.
s_client->setConnectionParams(12, 24, 0, 400, 160, 48);
}
return true;
}
void disconnectAndWait() {
if (!isLinkUp()) return;
s_client->disconnect();
for (int i = 0; i < 40 && s_client->isConnected(); i++) vTaskDelay(pdMS_TO_TICKS(50));
}
struct SavedSecurity { uint8_t bonding, mitm, sc, io_cap; uint32_t passkey; };
SavedSecurity saveSecurity() {
return SavedSecurity{(uint8_t)ble_hs_cfg.sm_bonding, (uint8_t)ble_hs_cfg.sm_mitm,
(uint8_t)ble_hs_cfg.sm_sc, (uint8_t)ble_hs_cfg.sm_io_cap,
NimBLEDevice::getSecurityPasskey()};
}
// The phone-app link configures these globally; put its values back so a later
// phone pairing still asks for its own code.
void restoreSecurity(const SavedSecurity& s) {
NimBLEDevice::setSecurityAuth(s.bonding != 0, s.mitm != 0, s.sc != 0);
NimBLEDevice::setSecurityIOCap(s.io_cap);
NimBLEDevice::setSecurityPasskey(s.passkey);
}
// Report Reference descriptor: [report id, type] with type 1 = input, 2 = output.
bool reportRef(NimBLERemoteCharacteristic* chr, uint8_t* id, uint8_t* type) {
NimBLERemoteDescriptor* ref = chr->getDescriptor(NimBLEUUID(kUuidReportRef));
if (!ref) return false;
const NimBLEAttValue v = ref->readValue();
if (v.length() < 2) return false;
*id = v.data()[0];
*type = v.data()[1];
return true;
}
// Internal RAM left once a keyboard is linked: the number that decides which
// boards can afford a keyboard next to everything else.
// Called on the worker, so the stack figure is its own least-free headroom.
void logLinked(const char* what) {
Serial.printf("[blekbd] %s, internal heap %u free, largest block %u, worker stack %u unused\n", what,
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
(unsigned)uxTaskGetStackHighWaterMark(nullptr));
}
void forgetAttributes() {
s_led_boot = nullptr;
s_led_report = nullptr;
}
bool setupHid() {
forgetAttributes();
s_src = kSrcNone;
#if BLE_KBD_TRACE
s_report_logs = 0;
#endif
s_boot_handle = 0;
s_report_char_count = 0;
s_map_ok = false;
s_map.kbd_count = 0;
s_map.have_leds = false;
NimBLERemoteService* hid = s_client->getService(NimBLEUUID(kUuidHid));
if (!hid) {
Serial.println("[blekbd] HID: no HID service");
return false;
}
// Discover every characteristic once and take them all from this list: a
// later refresh deletes these objects, along with the notify callbacks set
// on them.
const std::vector<NimBLERemoteCharacteristic*>* chars = hid->getCharacteristics(true);
if (!chars || chars->empty()) {
Serial.println("[blekbd] HID: no characteristics");
return false;
}
NimBLERemoteCharacteristic* boot = nullptr;
NimBLERemoteCharacteristic* mode = nullptr;
NimBLERemoteCharacteristic* boot_out = nullptr;
NimBLERemoteCharacteristic* report_map = nullptr;
for (NimBLERemoteCharacteristic* chr : *chars) {
const NimBLEUUID uuid = chr->getUUID();
if (uuid == NimBLEUUID(kUuidBootKbdIn)) boot = chr;
else if (uuid == NimBLEUUID(kUuidProtocolMode)) mode = chr;
else if (uuid == NimBLEUUID(kUuidBootKbdOut)) boot_out = chr;
else if (uuid == NimBLEUUID(kUuidReportMap)) report_map = chr;
}
if (report_map) {
const NimBLEAttValue desc = report_map->readValue();
s_map_ok = hidParseReportMap(desc.data(), desc.length(), &s_map);
Serial.printf("[blekbd] HID: report map %u bytes, keyboard reports %d, leds %d\n",
(unsigned)desc.length(), s_map.kbd_count, s_map.have_leds ? 1 : 0);
#if BLE_KBD_TRACE
for (size_t off = 0; off < desc.length(); off += 32) {
char hex[32 * 3 + 1] = "";
for (size_t i = 0; i < 32 && off + i < desc.length(); i++)
snprintf(hex + 3 * i, 4, "%02X ", desc.data()[off + i]);
Serial.printf("[blekbd] HID: map %s\n", hex);
}
#endif
for (int i = 0; i < s_map.kbd_count; i++) {
const HidKeyboardReport& k = s_map.kbd[i];
Serial.printf("[blekbd] HID: id %u, %u bits: mods@%d keys@%d x%u bitmap@%d x%u\n",
(unsigned)k.report_id, (unsigned)k.bits, k.mod_bit, k.keys_bit,
(unsigned)k.keys_count, k.bitmap_bit, (unsigned)k.bitmap_count);
}
}
// Input reports that carry keys, and the output report with the LEDs.
int key_reports = 0;
NimBLERemoteCharacteristic* led_report = nullptr;
for (NimBLERemoteCharacteristic* chr : *chars) {
if (chr->getUUID() != NimBLEUUID(kUuidReport)) continue;
uint8_t id = 0;
uint8_t type = 0;
if (!reportRef(chr, &id, &type)) continue;
#if BLE_KBD_TRACE
Serial.printf("[blekbd] HID: report handle=%u id=%u type=%u notify=%d\n", (unsigned)chr->getHandle(),
(unsigned)id, (unsigned)type, chr->canNotify() ? 1 : 0);
#endif
if (type == 2) {
if (s_map_ok ? (s_map.have_leds && id == s_map.leds.report_id) : !led_report) led_report = chr;
continue;
}
if (type != 1 || !chr->canNotify()) continue;
int layout = -1;
for (int i = 0; i < s_map.kbd_count; i++)
if (s_map.kbd[i].report_id == id) layout = i;
#if BLE_KBD_TRACE
if (s_map_ok && layout < 0) layout = -3; // media keys and the like: logged, never typed
#else
if (s_map_ok && layout < 0) continue; // media keys, a touchpad, ...
#endif
if (s_report_char_count >= kMaxReportChars) break;
// The entry exists before the first notification can arrive.
s_report_chars[s_report_char_count] = ReportChar{chr->getHandle(), (int8_t)layout};
s_report_char_count++;
const bool sub = chr->subscribe(true, onReport, true);
if (sub && layout != -3) key_reports++;
Serial.printf("[blekbd] HID: report id %u on handle %u, layout %d, subscribed %d\n", (unsigned)id,
(unsigned)chr->getHandle(), layout, sub ? 1 : 0);
}
bool boot_ok = false;
if (s_map_ok && key_reports > 0) {
// Report protocol, as phones and computers use it.
if (mode) {
const uint8_t report_protocol = 1;
mode->writeValue(&report_protocol, 1, false);
}
} else if (boot && boot->canNotify()) {
if (mode) {
const uint8_t boot_protocol = 0;
mode->writeValue(&boot_protocol, 1, false);
}
s_boot_handle = boot->getHandle();
boot_ok = boot->subscribe(true, onReport, true);
if (!boot_ok) s_boot_handle = 0;
else s_led_boot = boot_out;
}
if (key_reports > 0) s_led_report = led_report;
Serial.printf("[blekbd] HID: %s protocol, key reports=%d boot=%d leds=%d/%d\n",
s_map_ok && key_reports > 0 ? "report" : "boot", key_reports, boot_ok ? 1 : 0,
s_led_boot ? 1 : 0, s_led_report ? 1 : 0);
return boot_ok || key_reports > 0;
}
// Caps Lock as toggled here, Num Lock always on: the keypad always types digits.
void syncLeds() {
if (!isLinkUp()) return;
const bool via_report = s_src == kSrcReport || (s_src == kSrcNone && !s_led_boot);
NimBLERemoteCharacteristic* chr = via_report ? s_led_report : s_led_boot;
if (!chr) chr = s_led_boot ? s_led_boot : s_led_report;
if (!chr) return;
uint8_t buf[8] = {};
size_t n = 0;
if (chr == s_led_report && s_map_ok && s_map.have_leds) {
n = hidEncodeLeds(s_map.leds, true, s_caps, buf, sizeof buf);
} else {
buf[0] = (uint8_t)(0x01 | (s_caps ? 0x02 : 0x00)); // boot layout: bit 0 Num Lock, bit 1 Caps Lock
n = 1;
}
if (n) chr->writeValue(buf, n, !chr->canWriteNoResponse());
}
void doPair(int index, bool with_code) {
Device dev = {};
if (!deviceAt(index, &dev)) return;
if (!ensureClient()) { setError(Error::NotRunning); setState(State::Failed); return; }
stopScan();
disconnectAndWait();
const NimBLEAddress addr = toAddress(dev);
// Pairing again has to start from scratch, or the old keys are reused silently.
if (NimBLEDevice::isBonded(addr)) NimBLEDevice::deleteBond(addr);
const SavedSecurity saved = saveSecurity();
uint32_t code = 0;
if (with_code) {
code = 100000 + (esp_random() % 900000);
NimBLEDevice::setSecurityPasskey(code);
NimBLEDevice::setSecurityAuth(true, true, true);
NimBLEDevice::setSecurityIOCap(BLE_HS_IO_DISPLAY_ONLY);
} else {
NimBLEDevice::setSecurityAuth(true, false, true);
NimBLEDevice::setSecurityIOCap(BLE_HS_IO_NO_INPUT_OUTPUT);
}
Serial.printf("[blekbd] pairing %s (%s, type %u) %s code\n", dev.name[0] ? dev.name : "?",
addr.toString().c_str(), (unsigned)dev.addr_type, with_code ? "with" : "without");
setError(Error::None);
setState(State::Connecting);
s_client->setConnectTimeout(12);
forgetAttributes();
s_connecting = true;
bool ok = s_client->connect(addr, true);
s_connecting = false;
Serial.printf("[blekbd] connect: %s rc=%d\n", ok ? "ok" : "failed", s_client->getLastError());
if (ok) {
portENTER_CRITICAL(&s_mux);
s_code = code;
s_state = State::Pairing;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
ok = s_client->secureConnection();
Serial.printf("[blekbd] pairing: %s rc=%d\n", ok ? "ok" : "failed", s_client->getLastError());
portENTER_CRITICAL(&s_mux);
s_code = 0;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
if (!ok) setError(with_code ? Error::PairingCode : Error::PairingNoCode);
} else {
setError(Error::Connect);
}
restoreSecurity(saved);
if (ok && !setupHid()) {
ok = false;
setError(Error::NotKeyboard);
}
if (!ok) {
disconnectAndWait();
setState(State::Failed);
return;
}
logLinked("paired");
portENTER_CRITICAL(&s_mux);
s_peer = dev;
s_have_peer = true;
s_new_pairing = true;
s_state = State::Connected;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
syncLeds();
}
// Background reconnect to the paired keyboard. The stored bond encrypts the
// link without any prompt; a keyboard that lost its half needs pairing again.
bool reconnectOnce() {
Device dev = {};
if (!havePeer(&dev) || !ensureClient()) return false;
const NimBLEAddress addr = toAddress(dev);
if (!NimBLEDevice::isBonded(addr)) {
setError(Error::NeedsPairing);
return false;
}
s_client->setConnectTimeout(10);
forgetAttributes();
s_connecting = true;
bool ok = s_client->connect(addr, true);
s_connecting = false;
if (!ok) return false;
if (!s_client->secureConnection()) {
Serial.printf("[blekbd] reconnect: encryption failed rc=%d\n", s_client->getLastError());
disconnectAndWait();
return false;
}
if (!setupHid()) {
disconnectAndWait();
return false;
}
logLinked("reconnected");
setError(Error::None);
setState(State::Connected);
syncLeds();
return true;
}
bool wantReconnect() {
if (isLinkUp()) return false;
const State st = getState();
if (st != State::Waiting) return false;
portENTER_CRITICAL(&s_mux);
const bool want = s_active && s_have_peer && s_error != Error::NeedsPairing;
portEXIT_CRITICAL(&s_mux);
return want && !s_suspended && NimBLEDevice::getInitialized();
}
void handle(const Cmd& c) {
switch (c.type) {
case CmdType::Activate:
if (!ensureClient()) { setError(Error::NotRunning); setState(State::Failed); break; }
setError(Error::None);
setState(restingState());
break;
case CmdType::Deactivate:
if (NimBLEDevice::getInitialized()) {
stopScan();
disconnectAndWait();
}
clearHeld();
setState(State::Off);
break;
case CmdType::ScanOn: {
if (!s_active || !ensureClient()) break;
portENTER_CRITICAL(&s_mux);
s_device_count = 0;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
NimBLEScan* scan = NimBLEDevice::getScan();
scan->setAdvertisedDeviceCallbacks(&s_scan_cb, true);
scan->setMaxResults(0); // keep nothing; the callback copies what it needs
scan->setActiveScan(true);
scan->setInterval(100);
scan->setWindow(60);
scan->clearResults();
setError(Error::None);
if (scan->start(kScanSeconds, onScanDone, false)) setState(State::Scanning);
break;
}
case CmdType::ScanOff: {
// Also closes out a failed attempt: leaving the pairing screen resumes
// reconnecting to the keyboard that was paired before.
if (NimBLEDevice::getInitialized()) stopScan();
const State st = getState();
if (st == State::Scanning || st == State::Failed) {
if (st == State::Failed) setError(Error::None);
setState(restingState());
}
break;
}
case CmdType::Pair:
doPair(c.index, c.with_code);
break;
case CmdType::Forget: {
Device dev = {};
const bool had = havePeer(&dev);
if (NimBLEDevice::getInitialized()) {
disconnectAndWait();
if (had) {
const NimBLEAddress addr = toAddress(dev);
if (NimBLEDevice::isBonded(addr)) NimBLEDevice::deleteBond(addr);
}
}
portENTER_CRITICAL(&s_mux);
s_have_peer = false;
s_peer = Device{};
s_forgotten = true;
s_error = Error::None;
portEXIT_CRITICAL(&s_mux);
setState(restingState());
break;
}
case CmdType::SyncLeds:
syncLeds();
break;
}
}
void worker(void*) {
uint32_t next_try = 0;
Cmd c;
for (;;) {
TickType_t wait = portMAX_DELAY;
if (wantReconnect()) {
const uint32_t now = millis();
wait = ((int32_t)(now - next_try) >= 0) ? 0 : pdMS_TO_TICKS(next_try - now);
}
// Busy is raised before the command leaves the queue and before the
// reconnect check, so stackGoingDown() never sees an idle worker that is
// about to touch the client.
if (xQueuePeek(s_cmdq, &c, wait) == pdTRUE) {
s_busy = true;
xQueueReceive(s_cmdq, &c, 0);
handle(c);
s_busy = false;
continue;
}
s_busy = true;
if (wantReconnect()) next_try = reconnectOnce() ? 0 : millis() + kRetryMs;
s_busy = false;
}
}
void post(CmdType type, int index = 0, bool with_code = false) {
if (!s_cmdq) return;
const Cmd c{type, (int8_t)index, with_code};
xQueueSend(s_cmdq, &c, 0);
}
// A connect holds the worker for up to its timeout; anything the user asks for
// should not wait behind it.
void interruptConnect() {
if (s_connecting) ble_gap_conn_cancel();
}
bool ensureWorker() {
if (s_task) return true;
s_cmdq = xQueueCreate(8, sizeof(Cmd));
if (!s_cmdq) return false;
// Internal-RAM stack: NimBLE may write bonds to flash from this task.
if (xTaskCreatePinnedToCore(worker, "blekbd", 6144, nullptr, 1, &s_task, 0) != pdPASS) {
vQueueDelete(s_cmdq);
s_cmdq = nullptr;
s_task = nullptr;
return false;
}
return true;
}
} // namespace
void setActive(bool on) {
portENTER_CRITICAL(&s_mux);
const bool changed = (s_active != on);
s_active = on;
if (changed) bumpLocked();
portEXIT_CRITICAL(&s_mux);
if (!changed) return;
if (on) {
if (ensureWorker()) post(CmdType::Activate);
} else if (s_task) {
interruptConnect();
// Wake a pairing or reconnect that is waiting on the keyboard.
if (NimBLEDevice::getInitialized() && isLinkUp()) s_client->disconnect();
post(CmdType::Deactivate);
}
}
bool active() {
portENTER_CRITICAL(&s_mux);
const bool on = s_active;
portEXIT_CRITICAL(&s_mux);
return on;
}
State state() { return getState(); }
Error lastError() {
portENTER_CRITICAL(&s_mux);
const Error e = s_error;
portEXIT_CRITICAL(&s_mux);
return e;
}
uint32_t generation() {
portENTER_CRITICAL(&s_mux);
const uint32_t g = s_gen;
portEXIT_CRITICAL(&s_mux);
return g;
}
void setPaired(const Device* dev) {
portENTER_CRITICAL(&s_mux);
if (dev) {
s_peer = *dev;
s_peer.name[sizeof s_peer.name - 1] = '\0';
s_have_peer = true;
} else {
s_peer = Device{};
s_have_peer = false;
}
bumpLocked();
portEXIT_CRITICAL(&s_mux);
}
bool paired(Device* out) { return havePeer(out); }
void scan(bool on) {
if (!s_task) return;
if (on) interruptConnect();
post(on ? CmdType::ScanOn : CmdType::ScanOff);
}
int deviceCount() {
portENTER_CRITICAL(&s_mux);
const int n = s_device_count;
portEXIT_CRITICAL(&s_mux);
return n;
}
bool deviceAt(int i, Device* out) {
portENTER_CRITICAL(&s_mux);
const bool ok = i >= 0 && i < s_device_count;
if (ok && out) *out = s_devices[i];
portEXIT_CRITICAL(&s_mux);
return ok;
}
void pair(int i, bool with_code) {
if (!s_task) return;
interruptConnect();
post(CmdType::Pair, i, with_code);
}
uint32_t pairingCode() {
portENTER_CRITICAL(&s_mux);
const uint32_t code = s_code;
portEXIT_CRITICAL(&s_mux);
return code;
}
void forget() {
if (!s_task) {
portENTER_CRITICAL(&s_mux);
s_have_peer = false;
s_peer = Device{};
s_forgotten = true;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
return;
}
interruptConnect();
post(CmdType::Forget);
}
void setLayout(Layout l) {
if ((size_t)l >= (size_t)Layout::Count) l = Layout::US;
portENTER_CRITICAL(&s_mux);
s_layout = l;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
}
Layout layout() {
portENTER_CRITICAL(&s_mux);
const Layout l = s_layout;
portEXIT_CRITICAL(&s_mux);
return l;
}
const char* layoutName(Layout l) {
const size_t i = (size_t)l < (size_t)Layout::Count ? (size_t)l : 0;
return kLayouts[i].name;
}
void setBackKey(uint8_t usage) {
portENTER_CRITICAL(&s_mux);
s_back_usage = usage;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
}
uint8_t backKey() {
portENTER_CRITICAL(&s_mux);
const uint8_t u = s_back_usage;
portEXIT_CRITICAL(&s_mux);
return u;
}
void captureKey(bool on) {
portENTER_CRITICAL(&s_mux);
s_capture = on;
if (on) s_have_captured = false;
bumpLocked();
portEXIT_CRITICAL(&s_mux);
}
bool capturingKey() {
portENTER_CRITICAL(&s_mux);
const bool on = s_capture;
portEXIT_CRITICAL(&s_mux);
return on;
}
bool takeCapturedKey(uint8_t* usage) {
portENTER_CRITICAL(&s_mux);
const bool have = s_have_captured;
s_have_captured = false;
if (have && usage) *usage = s_captured;
portEXIT_CRITICAL(&s_mux);
return have;
}
void keyName(uint8_t usage, char* out, size_t cap) {
if (!out || !cap) return;
switch (usage) {
case 0x28: snprintf(out, cap, "Enter"); return;
case 0x29: snprintf(out, cap, "Esc"); return;
case 0x2A: snprintf(out, cap, "Backspace"); return;
case 0x2B: snprintf(out, cap, "Tab"); return;
case 0x2C: snprintf(out, cap, "Space"); return;
case 0x49: snprintf(out, cap, "Insert"); return;
case 0x4A: snprintf(out, cap, "Home"); return;
case 0x4B: snprintf(out, cap, "Page Up"); return;
case 0x4C: snprintf(out, cap, "Delete"); return;
case 0x4D: snprintf(out, cap, "End"); return;
case 0x4E: snprintf(out, cap, "Page Down"); return;
default: break;
}
if (usage >= 0x3A && usage <= 0x45) { snprintf(out, cap, "F%u", (unsigned)(usage - 0x3A + 1)); return; }
if (usage >= 0x68 && usage <= 0x73) { snprintf(out, cap, "F%u", (unsigned)(usage - 0x68 + 13)); return; }
uint32_t cp = mapKey(layout(), usage, false, false, false) & ~kDeadFlag;
if (cp >= 0x20 && cp != 0x7F) { // the character it types, as UTF-8
char u[5] = {};
if (cp < 0x80) {
u[0] = (char)cp;
} else if (cp < 0x800) {
u[0] = (char)(0xC0 | (cp >> 6));
u[1] = (char)(0x80 | (cp & 0x3F));
} else {
u[0] = (char)(0xE0 | (cp >> 12));
u[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
u[2] = (char)(0x80 | (cp & 0x3F));
}
snprintf(out, cap, "%s", u);
return;
}
snprintf(out, cap, "0x%02X", (unsigned)usage);
}
int readKey() {
if (s_tail != s_head) {
const int code = s_ring[s_tail];
s_tail = (uint8_t)((s_tail + 1) % kRing);
return code;
}
const uint32_t now = millis();
int code = 0;
portENTER_CRITICAL(&s_mux);
if (s_held_code && (uint32_t)(now - s_held_since) >= kRepeatDelayMs &&
(int32_t)(now - s_next_repeat) >= 0) {
code = s_held_code;
s_next_repeat = now + kRepeatRateMs;
}
portEXIT_CRITICAL(&s_mux);
return code;
}
bool takeNewPairing(Device* out) {
portENTER_CRITICAL(&s_mux);
const bool fresh = s_new_pairing;
if (fresh) {
s_new_pairing = false;
if (out) *out = s_peer;
}
portEXIT_CRITICAL(&s_mux);
return fresh;
}
bool takeForgotten() {
portENTER_CRITICAL(&s_mux);
const bool gone = s_forgotten;
s_forgotten = false;
portEXIT_CRITICAL(&s_mux);
return gone;
}
void suspend(bool on) {
if (s_suspended == on) return;
s_suspended = on;
if (on) {
interruptConnect();
if (NimBLEDevice::getInitialized() && isLinkUp()) s_client->disconnect();
} else if (s_cmdq) {
post(CmdType::Activate); // wakes the worker, which reconnects
}
}
bool linkUp() { return NimBLEDevice::getInitialized() && isLinkUp(); }
void stackGoingDown() {
if (!s_task) return;
setActive(false);
// Wait for the worker to let go of the client before NimBLE deletes it.
for (int i = 0; i < 40 && (s_busy || uxQueueMessagesWaiting(s_cmdq) > 0); i++)
vTaskDelay(pdMS_TO_TICKS(50));
s_client = nullptr;
forgetAttributes();
}
} // namespace BleKbd
#endif // CAP_BLE_KEYBOARD