Files
m5stack-automation-tool/firmware/MacroPad/ble_manager.h
T
2026-07-17 15:29:53 -04:00

1558 lines
63 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <NimBLEDevice.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <mbedtls/gcm.h>
#include <esp_mac.h>
#include <esp_random.h>
#include <freertos/FreeRTOS.h>
#include "config.h"
#include "frame_crypto.h"
#include "debug_log.h"
#include "ble_keystore.h"
#include "live_keystroke.h"
struct BLEVariable {
char name[BLE_VAR_NAME_LEN];
char value[BLE_VAR_VALUE_LEN];
};
// In-memory ring buffer for BLE debug events. Safe to call from any task,
// including NimBLE callback context, because it does no I/O — just a
// memcpy under a spinlock. Drained on demand via the `get_ble_log` serial
// command (see serial_protocol.h). 128 entries × 96 bytes = ~12 KB heap-
// independent storage.
class BLERingLog {
public:
static constexpr int CAP = 128;
static constexpr int MSG_LEN = 96;
void log(const char* msg) {
portENTER_CRITICAL(&_mux);
Entry& e = _entries[_head];
e.t = millis();
size_t n = 0;
for (; n < MSG_LEN - 1 && msg[n]; n++) e.msg[n] = msg[n];
e.msg[n] = '\0';
_head = (_head + 1) % CAP;
if (_count < CAP) _count++;
portEXIT_CRITICAL(&_mux);
// Mirror to serial for live monitoring.
Serial.printf("[BLE.dbg %lu] %s\n", millis(), msg);
}
void logf(const char* fmt, ...) {
char buf[MSG_LEN];
va_list a;
va_start(a, fmt);
vsnprintf(buf, sizeof(buf), fmt, a);
va_end(a);
log(buf);
}
// Stream as JSON via the host serial protocol. Snapshots indices
// briefly under the spinlock, then iterates with per-entry brief
// locks; Serial I/O (which can block) happens outside the lock.
void dumpJson() {
Serial.print("{\"rsp\":\"ble_log\",\"entries\":[");
int count, start;
portENTER_CRITICAL(&_mux);
count = _count;
start = (_count == CAP) ? _head : 0;
portEXIT_CRITICAL(&_mux);
for (int i = 0; i < count; i++) {
Entry snap;
portENTER_CRITICAL(&_mux);
snap = _entries[(start + i) % CAP];
portEXIT_CRITICAL(&_mux);
if (i > 0) Serial.print(",");
Serial.print("{\"t\":");
Serial.print(snap.t);
Serial.print(",\"m\":\"");
for (const char* p = snap.msg; *p; p++) {
unsigned char c = (unsigned char)*p;
if (c == '"' || c == '\\') { Serial.write('\\'); Serial.write(c); }
else if (c < 0x20) Serial.printf("\\u%04x", c);
else Serial.write(c);
}
Serial.print("\"}");
}
Serial.println("]}");
Serial.flush();
}
void clear() {
portENTER_CRITICAL(&_mux);
_head = 0;
_count = 0;
portEXIT_CRITICAL(&_mux);
}
// ---- Flash persistence ----
//
// Survival across reboots: we write the full ring to LittleFS at safe
// points (shutdown, before NimBLE init) and reload on boot. A panic
// mid-init then leaves the prior boot's events visible on the next
// pull. Path constant kept here for self-containment.
static constexpr const char* FLASH_PATH = "/ble_dbg.log";
void persistToDisk() {
// CRITICAL: do NOT hold the spinlock across flash I/O. LittleFS
// internally takes FreeRTOS mutexes which are illegal under
// portENTER_CRITICAL; doing so panics the chip (caused a boot
// loop in an earlier revision of this code). Snapshot indices
// briefly, snapshot one entry at a time briefly, write outside.
int count, start;
portENTER_CRITICAL(&_mux);
count = _count;
start = (_count == CAP) ? _head : 0;
portEXIT_CRITICAL(&_mux);
File f = LittleFS.open(FLASH_PATH, "w");
if (!f) return;
for (int i = 0; i < count; i++) {
Entry snap;
portENTER_CRITICAL(&_mux);
snap = _entries[(start + i) % CAP];
portEXIT_CRITICAL(&_mux);
f.printf("%lu\t", (unsigned long)snap.t);
for (const char* p = snap.msg; *p; p++) {
char c = *p;
// Replace tab/newline so split-on-tab in load works.
f.write((c == '\t' || c == '\n' || c == '\r') ? ' ' : c);
}
f.write('\n');
}
f.close();
}
void loadFromDisk() {
if (!LittleFS.exists(FLASH_PATH)) return;
File f = LittleFS.open(FLASH_PATH, "r");
if (!f) return;
while (f.available()) {
String line = f.readStringUntil('\n');
line.trim();
if (line.length() == 0) continue;
int tab = line.indexOf('\t');
if (tab < 0) continue;
uint32_t t = (uint32_t)line.substring(0, tab).toInt();
const char* msg = line.c_str() + tab + 1;
portENTER_CRITICAL(&_mux);
Entry& e = _entries[_head];
e.t = t;
size_t n = 0;
for (; n < MSG_LEN - 1 && msg[n]; n++) e.msg[n] = msg[n];
e.msg[n] = '\0';
_head = (_head + 1) % CAP;
if (_count < CAP) _count++;
portEXIT_CRITICAL(&_mux);
}
f.close();
}
private:
struct Entry { uint32_t t; char msg[MSG_LEN]; };
Entry _entries[CAP];
int _head = 0;
int _count = 0;
portMUX_TYPE _mux = portMUX_INITIALIZER_UNLOCKED;
};
class BLEManager;
// NimBLE server callbacks (connect/disconnect)
class _BLEServerCB : public NimBLEServerCallbacks {
public:
BLEManager* mgr;
_BLEServerCB(BLEManager* m) : mgr(m) {}
void onConnect(NimBLEServer* server, NimBLEConnInfo& connInfo) override;
void onDisconnect(NimBLEServer* server, NimBLEConnInfo& connInfo, int reason) override;
};
// NimBLE write characteristic callback (host -> device)
class _BLEWriteCB : public NimBLECharacteristicCallbacks {
public:
BLEManager* mgr;
_BLEWriteCB(BLEManager* m) : mgr(m) {}
void onWrite(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo) override;
};
// NimBLE notify characteristic callback (subscription tracker)
class _BLENotifyCB : public NimBLECharacteristicCallbacks {
public:
BLEManager* mgr;
_BLENotifyCB(BLEManager* m) : mgr(m) {}
void onSubscribe(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo,
uint16_t subValue) override;
};
// Live-keystroke write callback (host -> device, Write Without Response).
// Different schema (binary, not JSON) so it's routed to a separate
// handler to avoid bloating the var-sync dispatch path.
class _BLELiveWriteCB : public NimBLECharacteristicCallbacks {
public:
BLEManager* mgr;
_BLELiveWriteCB(BLEManager* m) : mgr(m) {}
void onWrite(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo) override;
};
class _BLELiveNotifyCB : public NimBLECharacteristicCallbacks {
public:
BLEManager* mgr;
_BLELiveNotifyCB(BLEManager* m) : mgr(m) {}
void onSubscribe(NimBLECharacteristic* ch, NimBLEConnInfo& connInfo,
uint16_t subValue) override;
};
class BLEManager {
public:
enum ExchangeKind {
EX_NONE = 0,
EX_PULL = 1, // device asks host to push vars to it
EX_PUSH = 2, // device pushes its dev-vars up to host, awaits ack
EX_REQUEST = 3, // device asks host to prompt user, host then pulls back
EX_LIVE = 4, // persistent low-latency keystroke streaming
};
// Live-mode constants — must match host's ble_live.py.
static constexpr uint8_t LIVE_MSG_START = 0x01;
static constexpr uint8_t LIVE_MSG_KEYS = 0x02;
static constexpr uint8_t LIVE_MSG_STOP = 0x03;
static constexpr uint8_t LIVE_MSG_IDENTIFY = 0x04; // host: show BT logo (body[0]=1 on / 0 off)
static constexpr uint8_t LIVE_MSG_MOUSE = 0x05; // host: abs mouse {buttons, x_u16, y_u16, wheel_i8}
static constexpr uint8_t LIVE_MSG_LABEL = 0x06; // host: device label (UTF-8 body)
static constexpr uint8_t LIVE_MSG_ACK = 0x10;
static constexpr uint8_t LIVE_MSG_ERROR = 0x11;
static constexpr uint8_t LIVE_MSG_HELLO = 0x12;
// (shared GCM envelope helpers live in frame_crypto.h)
static constexpr uint8_t LIVE_ERR_BUFFER_FULL = 1;
static constexpr uint8_t LIVE_ERR_NOT_LIVE = 2;
static constexpr uint8_t LIVE_ERR_HID_FAILURE = 3;
static constexpr uint8_t LIVE_ERR_BAD_MSG = 4;
// Lightweight init — caches debug log + keystore, computes the device tag,
// and reloads any persisted variables from flash.
void begin(DebugLog* dlog = nullptr, BLEKeyStore* keystore = nullptr) {
_dlog = dlog;
_keystore = keystore;
_initDeviceTag();
// Per-boot session ID. RAM-only — fresh on every boot/reflash.
// The host treats a new session as a desync-recovery signal and
// resets its replay window for this device gracefully.
_bootId = ((uint64_t)esp_random() << 32) | (uint64_t)esp_random();
Serial.printf("[BLE] boot_id=%llu\n", (unsigned long long)_bootId);
loadDevFromDisk();
loadUniFromDisk();
loadReplayFromDisk();
// Live-session resume flag: if it's set, we lost power mid-session
// and should immediately re-advertise on this boot so the host
// reconnects (the user can cancel with a button hold).
_resumeFlagOnDisk = _readResumeFlag();
_liveResumeBoot = _resumeFlagOnDisk;
Serial.printf("[BLE] live resume flag on boot: %d\n",
(int)_liveResumeBoot);
// Carry forward any debug log entries from the previous boot so
// crashes mid-routine remain visible after the reset.
dbg.loadFromDisk();
dbg.logf("BOOT bootId=%llu millis=%lu",
(unsigned long long)_bootId, millis());
dbg.persistToDisk();
}
const char* deviceTag() const { return _deviceTag; }
// ---- Local-only ops (no BLE) ----
// Set a single (name, value) on the chosen scope ("device" or "universal").
bool setLocal(const char* scope, const char* name, const char* value) {
BLEVariable* arr;
int* count;
if (!_pickScope(scope, &arr, &count)) return false;
for (int i = 0; i < *count; i++) {
if (strcmp(arr[i].name, name) == 0) {
strlcpy(arr[i].value, value, BLE_VAR_VALUE_LEN);
saveScope(scope);
return true;
}
}
if (*count >= MAX_BLE_VARS) return false;
strlcpy(arr[*count].name, name, BLE_VAR_NAME_LEN);
strlcpy(arr[*count].value, value, BLE_VAR_VALUE_LEN);
(*count)++;
saveScope(scope);
return true;
}
// Type Text (VAR{name}) lookup: device-scope first, universal fallback.
// Case-insensitive: a text node referencing (VAR{password}) finds a
// stored variable named "Password" or "PASSWORD" alike.
const char* getVariable(const char* name) const {
for (int i = 0; i < _devCount; i++) {
if (strcasecmp(_devVars[i].name, name) == 0) return _devVars[i].value;
}
for (int i = 0; i < _uniCount; i++) {
if (strcasecmp(_uniVars[i].name, name) == 0) return _uniVars[i].value;
}
return "";
}
// Lookup honoring an explicit scope hint:
// scope="device" -> device-only
// scope="universal" -> universal-only
// anything else -> device first, fall back to universal
// Same case-insensitive semantics as getVariable().
const char* getVariableScoped(const char* name, const char* scope) const {
bool deviceOnly = scope && strcmp(scope, "device") == 0;
bool universalOnly = scope && strcmp(scope, "universal") == 0;
if (!universalOnly) {
for (int i = 0; i < _devCount; i++) {
if (strcasecmp(_devVars[i].name, name) == 0) return _devVars[i].value;
}
if (deviceOnly) return "";
}
for (int i = 0; i < _uniCount; i++) {
if (strcasecmp(_uniVars[i].name, name) == 0) return _uniVars[i].value;
}
return "";
}
// ---- BLE-driven ops ----
// Start BLE and ask the host to push variables for the given scope.
void startPull(const char* scope) {
strlcpy(_pendingScope, scope ? scope : "universal", sizeof(_pendingScope));
_exchangeKind = EX_PULL;
_exchangeDone = false;
_authFailed = false;
dbg.logf("startPull scope=%s sendSeq=%llu hostSeen=%llu",
_pendingScope,
(unsigned long long)_sendSeq,
(unsigned long long)_hostSeen);
_startBLE();
}
// Start BLE and push the device's full _devVars map up to the host.
void startPush() {
_exchangeKind = EX_PUSH;
_exchangeDone = false;
_authFailed = false;
dbg.logf("startPush devCount=%d sendSeq=%llu hostSeen=%llu",
_devCount,
(unsigned long long)_sendSeq,
(unsigned long long)_hostSeen);
_startBLE();
}
// Start BLE and ask the host to prompt for variable values. ``names``
// is a JsonArray-compatible shape; we serialize it on the fly.
void startRequest(JsonArray names) {
_requestNamesJson.clear();
_requestNamesJson.reserve(64);
_requestNamesJson += "[";
bool first = true;
for (JsonVariant v : names) {
const char* n = v.as<const char*>();
if (!n) continue;
if (!first) _requestNamesJson += ",";
first = false;
_requestNamesJson += "\"";
// Best-effort escape — variable names should be bare identifiers.
for (const char* p = n; *p; p++) {
if (*p == '"' || *p == '\\') _requestNamesJson += '\\';
_requestNamesJson += *p;
}
_requestNamesJson += "\"";
}
_requestNamesJson += "]";
_exchangeKind = EX_REQUEST;
_exchangeDone = false;
_authFailed = false;
dbg.logf("startRequest names=%s", _requestNamesJson.c_str());
dbg.persistToDisk();
_startBLE();
}
bool isExchangeDone() const { return _exchangeDone; }
bool isBLEActive() const { return _bleActive; }
bool isClientConnected() const { return _clientConnected; }
ExchangeKind exchangeKind() const { return _exchangeKind; }
// True while the host has asked this device to draw a Bluetooth
// "identify" logo (so the user can tell which physical M5Stack they
// are labeling in the BT Keyboard window). Auto-expires after a
// safety timeout in case the host never sends the off frame.
bool liveIdentify() const { return _liveIdentify; }
// Friendly device label set by the host (LIVE_MSG_LABEL) and shown on
// the live-mode screen. liveLabelVer() bumps on every change so the
// display can detect when to repaint.
const char* liveLabel() const { return _liveLabel; }
uint32_t liveLabelVer() const { return _liveLabelVer; }
// True for this boot if we lost power mid live-session and should
// immediately re-advertise to reconnect. consumeLiveResume() clears the
// in-RAM request (e.g. the user cancelled, or a USB host appeared).
bool liveResumeRequested() const { return _liveResumeBoot; }
void consumeLiveResume() { _liveResumeBoot = false; }
// One-line summary of the live-mode connection state for the
// display. Phrased so the main loop can pass it straight into
// showLiveMode() without any conditional fanout.
const char* liveStatusText() const {
if (_exchangeKind != EX_LIVE) return "Idle";
if (!_clientConnected) return "Waiting for host...";
if (!_liveSubscribed) return "Connecting...";
if (!_liveSawStart) return "Ready";
return "Recording";
}
// Wire the LiveKeystrokeEngine in. Must be called once at boot
// after both objects are constructed; the BLE write callback uses
// it to enqueue keystroke events.
void setLiveEngine(LiveKeystrokeEngine* eng) { _liveEngine = eng; }
// ---- Live-keystroke streaming control ----
// Bring BLE up in live mode (advertise the live service). Idempotent —
// calling while already advertising live is a no-op, so the main loop
// can call it every idle iteration. There is NO on-device gesture to
// enter live mode anymore: the device listens automatically while idle
// and only starts emitting keystrokes once the host sends a valid
// AES-GCM START frame.
void startLive() {
// Called every main-loop iteration when idle, so the already-live
// path must be silent — logging here would spam the dbg ring and
// grind flash on the periodic persist.
if (_exchangeKind == EX_LIVE && _bleActive) {
return;
}
_exchangeKind = EX_LIVE;
_exchangeDone = false;
_authFailed = false;
_liveSawStart = false;
_liveIdentify = false;
if (_liveEngine) _liveEngine->stop(); // ensure clean state
dbg.log("startLive");
dbg.persistToDisk();
_startBLE();
}
// Exit recording — called by the live-write path on receipt of a STOP
// frame (host clicked Close BLE), and from MacroPad.ino right before a
// routine starts. Stops the keystroke engine but keeps the device
// advertising so the host can reconnect.
void stopLive() {
if (_exchangeKind != EX_LIVE) {
// No-op, silent (idempotent path).
return;
}
// Exit "recording" — stop the engine (releases any held keys) and
// clear the START latch — but DON'T tear BLE down. We stay in
// EX_LIVE and keep advertising so the host can immediately
// reconnect / re-record. Full radio teardown happens only when a
// routine starts (MacroPad.ino calls shutdown() in that path).
if (_liveEngine) _liveEngine->stop();
_liveSawStart = false;
// Clean end of the session — clear the resume flag so a later
// power-cycle doesn't auto-reconnect (deferred flash write).
_resumeWantState = 0;
dbg.log("stopLive (exit recording, keep listening)");
}
// Soft shutdown after an exchange completes (or times out).
//
// We deliberately do NOT call NimBLEDevice::deinit() here. Repeated
// deinit/init cycles on ESP32-S3 with concurrent USB-CDC have been
// observed to panic the chip ~mid-second-init, which manifested as
// the device rebooting between consecutive BLE nodes. Instead we just
// stop advertising and reset per-exchange flags; the next BLE node
// restarts advertising on the existing stack.
void shutdown() {
if (!_bleActive) return;
dbg.logf("shutdown begin (exchangeDone=%d)", (int)_exchangeDone);
// A deliberate teardown (routine start / USB upload) is a clean end
// of any live session — clear the resume flag so the next boot
// doesn't auto-reconnect. Safe to write flash here (main task).
_resumeWantState = -1;
if (_resumeFlagOnDisk) _writeResumeFlag(false);
if (_pendingPersistScope[0]) {
saveScope(_pendingPersistScope);
_pendingPersistScope[0] = '\0';
}
if (_replayDirty) {
_replayDirty = false;
saveReplayToDisk();
}
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
if (adv) adv->stop();
// Force-disconnect any peer still on the radio before we touch the
// stack further. We can't trust the host to tear down promptly —
// bleak's BleakClient.__aexit__ on Windows can take 2+ seconds to
// actually drop the link (observed in the dbg ring log: a 500 ms
// passive wait still saw connected=1). If the engine reaches the
// next BLE node and calls _startBLE() -> adv->start() while a peer
// is connected, NimBLE on ESP32-S3 panics and the chip resets,
// which is the entire pull_ble -> request_ble hang we're chasing.
NimBLEServer* server = NimBLEDevice::getServer();
uint16_t connectedAtEntry = server ? server->getConnectedCount() : 0;
if (server && connectedAtEntry > 0) {
for (uint16_t handle : server->getPeerDevices()) {
server->disconnect(handle);
}
uint32_t deadline = millis() + 500;
while (server->getConnectedCount() > 0 &&
(int32_t)(millis() - deadline) < 0) {
delay(10);
}
}
dbg.logf("shutdown disconnected entered=%u settled=%u",
(unsigned)connectedAtEntry,
(unsigned)(server ? server->getConnectedCount() : 0));
_clientConnected = false;
_clientSubscribed = false;
_liveSubscribed = false;
_liveSawStart = false;
_helloSent = false;
_pushSent = false;
_connectMs = 0;
_liveFallbackHelloSent = false;
_liveHelloAfterSub = false;
_liveIdentify = false;
if (_liveEngine) _liveEngine->stop();
_exchangeKind = EX_NONE;
_lastShutdownMs = millis();
Serial.println("[BLE] Soft shutdown — advertising stopped, stack stays up");
if (_dlog) _dlog->log("BLE: soft shutdown");
dbg.logf("shutdown done sendSeq=%llu hostSeen=%llu",
(unsigned long long)_sendSeq,
(unsigned long long)_hostSeen);
// Persist the debug log to flash so crashes that happen before
// the next persist still leave a trace pullable on next boot.
dbg.persistToDisk();
}
// ---- NimBLE callback handlers ----
void onClientConnect() {
// Defense in depth: clear all per-session flags on every fresh
// connect. We've observed cases where NimBLE's onDisconnect
// callback didn't fire for a prior dropped peer, leaving
// _helloSent / _liveFallbackHelloSent stuck at true — which
// then suppressed the hello for the next connection.
_clientConnected = true;
_clientSubscribed = false;
_liveSubscribed = false;
_helloSent = false;
_pushSent = false;
_liveFallbackHelloSent = false;
_liveHelloAfterSub = false;
_liveSawStart = false;
_liveIdentify = false;
_connectMs = millis();
dbg.logf("client connected (exchangeKind=%d millis=%lu)",
(int)_exchangeKind, (unsigned long)_connectMs);
}
void onClientDisconnect() {
bool exDone = _exchangeDone;
_clientConnected = false;
_clientSubscribed = false;
_liveSubscribed = false;
_helloSent = false;
_pushSent = false;
_connectMs = 0;
_liveFallbackHelloSent = false;
_liveHelloAfterSub = false;
// A disconnect during live mode is an unrecoverable session end —
// stop the engine (releases any held keys defensively) and mark
// the exchange done so the main loop reaps BLE.
if (_exchangeKind == EX_LIVE) {
// Live link dropped (clean close after a STOP, or interference).
// Exit recording but KEEP listening: re-advertise so the host
// can reconnect and resume. We deliberately do NOT mark the
// exchange done — that would tear the whole stack down. The
// device stays available for the next/again connection.
if (_liveEngine) _liveEngine->stop();
_liveSawStart = false;
_liveIdentify = false;
_needsReAdvertise = true;
dbg.log("live: client disconnected — re-advertising (keep listening)");
} else if (!exDone) {
_needsReAdvertise = true;
}
dbg.logf("client disconnected (exDone=%d -> reAdv=%d)",
(int)exDone, (int)!exDone);
}
void onClientSubscribe(uint16_t subValue) {
_clientSubscribed = (subValue != 0);
dbg.logf("client subscribe subValue=%u", (unsigned)subValue);
}
void onLiveSubscribe(uint16_t subValue) {
_liveSubscribed = (subValue != 0);
dbg.logf("live subscribe subValue=%u helloSent=%d clientConn=%d",
(unsigned)subValue, (int)_helloSent, (int)_clientConnected);
}
// Live-write callback. Runs on the NimBLE host task — same no-blocking-IO
// rules as onWriteReceived. Decrypts the AES-GCM envelope, validates
// replay counters, then dispatches by msg_type. Keystroke events are
// enqueued for the main loop to drain; no HID work happens here.
void onLiveWriteReceived(const uint8_t* data, size_t len) {
if (!_keystore || !_keystore->hasKey()) {
_authFailed = true;
return;
}
static uint8_t plain[BLE_VAR_BUF_SIZE];
size_t plainLen = 0;
char tag[BLE_DEVICE_TAG_MAX];
if (!_parseFrame(data, len, tag, sizeof(tag), plain, &plainLen)) {
_authFailed = true;
return;
}
if (strcmp(tag, _deviceTag) != 0) {
_wrongTag = true;
return;
}
// Binary header: msg_type(1) + sid(8) + seq(8)
if (plainLen < 17) {
_sendLiveError(LIVE_ERR_BAD_MSG, 0);
return;
}
uint8_t msgType = plain[0];
uint64_t sid = _readLE64(plain + 1);
uint64_t seq = _readLE64(plain + 9);
const uint8_t* body = plain + 17;
size_t bodyLen = plainLen - 17;
const char* reason = "?";
if (!acceptReceived(sid, seq, &reason)) {
dbg.logf("live: replay reject %s sid=%llu seq=%llu",
reason,
(unsigned long long)sid,
(unsigned long long)seq);
return;
}
if (_exchangeKind != EX_LIVE) {
_sendLiveError(LIVE_ERR_NOT_LIVE, seq);
return;
}
switch (msgType) {
case LIVE_MSG_START:
if (_liveEngine) _liveEngine->start();
_liveSawStart = true;
// We're now in an active session — request the resume flag
// be persisted (pollStatus does the actual flash write on
// the main task; we mustn't block on flash here).
_resumeWantState = 1;
_sendLiveAck(seq);
dbg.logf("live: START seq=%llu", (unsigned long long)seq);
break;
case LIVE_MSG_KEYS: {
if (!_liveSawStart) {
_sendLiveError(LIVE_ERR_NOT_LIVE, seq);
break;
}
if (bodyLen < 1) {
_sendLiveError(LIVE_ERR_BAD_MSG, seq);
break;
}
uint8_t count = body[0];
// Each event is { uint8 action, uint8 hid, uint32 t_ms_le } = 6 bytes
if (bodyLen < 1u + (size_t)count * 6u) {
_sendLiveError(LIVE_ERR_BAD_MSG, seq);
break;
}
bool anyDrop = false;
for (uint8_t i = 0; i < count; i++) {
const uint8_t* ev = body + 1 + i * 6;
uint8_t action = ev[0];
uint8_t hidCode = ev[1];
uint32_t tMs = (uint32_t)ev[2]
| ((uint32_t)ev[3] << 8)
| ((uint32_t)ev[4] << 16)
| ((uint32_t)ev[5] << 24);
if (_liveEngine &&
!_liveEngine->enqueue(action, hidCode, tMs)) {
anyDrop = true;
}
}
if (anyDrop) {
_sendLiveError(LIVE_ERR_BUFFER_FULL, seq);
}
// KEYS frames don't get an explicit ACK — too chatty.
// Errors are the only feedback the host receives.
break;
}
case LIVE_MSG_IDENTIFY: {
// Display-only — does not require a prior START. Toggles the
// on-screen Bluetooth identify logo so the user can see
// which device they're labeling.
uint8_t on = (bodyLen >= 1) ? body[0] : 1;
_liveIdentify = (on != 0);
_liveIdentifyMs = millis();
_sendLiveAck(seq);
dbg.logf("live: IDENTIFY %u seq=%llu",
(unsigned)on, (unsigned long long)seq);
break;
}
case LIVE_MSG_MOUSE: {
// Absolute pointer — applied immediately (no cadence buffer).
// body: buttons(1), x(u16 LE), y(u16 LE), wheel(i8) = 6 bytes
if (bodyLen < 6) {
_sendLiveError(LIVE_ERR_BAD_MSG, seq);
break;
}
uint8_t buttons = body[0];
uint16_t x = (uint16_t)body[1] | ((uint16_t)body[2] << 8);
uint16_t y = (uint16_t)body[3] | ((uint16_t)body[4] << 8);
int8_t wheel = (int8_t)body[5];
if (_liveEngine) _liveEngine->enqueueMouse(buttons, x, y, wheel);
// No ACK — mouse is high-rate; errors are the only feedback.
break;
}
case LIVE_MSG_LABEL: {
// Friendly label to show on this device's screen so the user
// can tell which physical M5Stack a host-side slot maps to.
size_t n = bodyLen;
if (n >= sizeof(_liveLabel)) n = sizeof(_liveLabel) - 1;
memcpy(_liveLabel, body, n);
_liveLabel[n] = '\0';
_liveLabelVer++;
_sendLiveAck(seq);
dbg.logf("live: LABEL '%s'", _liveLabel);
break;
}
case LIVE_MSG_STOP:
stopLive();
_sendLiveAck(seq);
dbg.logf("live: STOP seq=%llu", (unsigned long long)seq);
break;
default:
_sendLiveError(LIVE_ERR_BAD_MSG, seq);
break;
}
}
// Host wrote to the write characteristic. Decrypt + dispatch.
//
// Runs on the NimBLE host task. Keep this fast — no flash writes,
// no Serial.printf with large buffers, no malloc-heavy ops.
void onWriteReceived(const uint8_t* data, size_t len) {
dbg.logf("write rx %u bytes", (unsigned)len);
if (!_keystore || !_keystore->hasKey()) {
_authFailed = true;
dbg.log("write rx: no key — drop");
return;
}
static uint8_t plain[BLE_VAR_BUF_SIZE];
size_t plainLen = 0;
char tag[BLE_DEVICE_TAG_MAX];
if (!_parseFrame(data, len, tag, sizeof(tag), plain, &plainLen)) {
_authFailed = true;
dbg.log("write rx: parseFrame failed");
return;
}
if (strcmp(tag, _deviceTag) != 0) {
// Frame addressed to a different device. With a single-key
// shared population this is rare — usually means the host has
// multiple devices in range and routed to the wrong one.
// Silent drop is correct; flag it so the main loop can log.
_wrongTag = true;
dbg.logf("write rx: wrong tag '%s'", tag);
return;
}
plain[plainLen] = '\0';
_dispatchPlaintext((const char*)plain);
}
// ---- Main-loop poll ----
void pollStatus() {
// Safety net: drop the identify logo if the host never sent the
// off frame (dialog crashed, link hiccup, etc.).
if (_liveIdentify &&
(millis() - _liveIdentifyMs) > IDENTIFY_TIMEOUT_MS) {
_liveIdentify = false;
}
// Flush a deferred live-resume-flag write requested from the NimBLE
// callback (START / STOP). Flash writes are safe here on the main
// task; they're illegal in the callback context.
if (_resumeWantState != -1) {
bool want = (_resumeWantState == 1);
_resumeWantState = -1;
if (want != _resumeFlagOnDisk) _writeResumeFlag(want);
}
// Flush deferred replay-counter writes regardless of whether BLE is
// currently up. acceptReceivedSeq sets this from the NimBLE host
// task (where we mustn't block on flash); we drain it here on the
// main task where blocking is fine.
if (_replayDirty) {
_replayDirty = false;
saveReplayToDisk();
}
// (No periodic dbg flush.) An earlier revision flushed every 2 s
// while _bleEverActive was true, but that flag stays true for the
// rest of the boot once any BLE node has run, so it amounted to a
// ~14 KB flash write every 2 s indefinitely — about 16 M writes/yr,
// well above NOR-flash endurance (~100 K cycles per sector even
// with LittleFS wear leveling). The strategic flushes — at boot,
// before each NimBLE init, and on every shutdown — already capture
// the failure-relevant moments. A crash mid-exchange will lose
// only that exchange's events, but boot history and pre-init state
// remain visible after reset.
if (!_bleActive) return;
if (_needsReAdvertise) {
_needsReAdvertise = false;
NimBLEDevice::getAdvertising()->start();
Serial.println("[BLE] Re-advertising (exchange not finished)");
}
// Send the appropriate hello as soon as the host subscribes.
// For EX_LIVE we wait for the live-notify subscription (different
// characteristic); for everything else we wait on the var-sync
// notify subscription.
//
// EX_LIVE special case: Bleak on Windows takes 1.5-2 s between
// connect and finishing the CCCD enable on the host side, so we
// may have fired a "fallback hello" before the host was actually
// subscribed (NimBLE drops the notify when sub=0). Don't gate
// the subscribe-triggered hello on _helloSent in live mode —
// re-send unconditionally when the subscribe finally fires. The
// host's hello_evt is one-shot so the extra hello is harmless.
bool helloSubscribed =
(_exchangeKind == EX_LIVE) ? _liveSubscribed : _clientSubscribed;
bool sendHelloNow;
if (_exchangeKind == EX_LIVE) {
sendHelloNow = _clientConnected && _liveSubscribed && !_liveHelloAfterSub;
} else {
sendHelloNow = _clientConnected && helloSubscribed && !_helloSent;
}
if (sendHelloNow) {
_sendHello();
_helloSent = true;
if (_exchangeKind == EX_LIVE) _liveHelloAfterSub = true;
// Push immediately follows the hello with the actual data frame.
if (_exchangeKind == EX_PUSH && !_pushSent) {
_sendPushPayload();
_pushSent = true;
}
}
// EX_LIVE fallback: if we somehow never observe a subscribe
// (NimBLE version quirk where onSubscribe doesn't fire even
// though Bleak set up the CCCD), still send hello after a
// grace period. The host gets the hello if its CCCD is
// enabled, otherwise the frame is dropped and the subscribe-
// triggered path above will retry once we do see the subscribe.
if (_exchangeKind == EX_LIVE && _clientConnected &&
!_liveFallbackHelloSent && _connectMs != 0 &&
(millis() - _connectMs) >= 1500) {
dbg.logf("live: hello fallback (sub=%d after %lums)",
(int)_liveSubscribed,
(unsigned long)(millis() - _connectMs));
_sendHello();
_helloSent = true;
_liveFallbackHelloSent = true;
}
if (_authFailed) {
_authFailed = false;
Serial.println("[BLE] auth failed — payload rejected (key mismatch or tampered frame)");
if (_dlog) _dlog->log("BLE: auth failed");
}
if (_wrongTag) {
_wrongTag = false;
Serial.println("[BLE] frame for a different device — dropped");
}
}
private:
// ---- Device tag (eFuse base MAC) ----
void _initDeviceTag() {
uint8_t mac[6] = {0};
// Factory-burned base MAC; stable across reflashes.
if (esp_efuse_mac_get_default(mac) != ESP_OK) {
esp_read_mac(mac, ESP_MAC_BT);
}
snprintf(_deviceTag, sizeof(_deviceTag),
"%s%02X:%02X:%02X:%02X:%02X:%02X",
BLE_DEVICE_TAG_PREFIX,
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
Serial.printf("[BLE] device tag: %s\n", _deviceTag);
}
bool _pickScope(const char* scope, BLEVariable** outArr, int** outCount) {
if (scope && strcmp(scope, "device") == 0) {
*outArr = _devVars; *outCount = &_devCount; return true;
}
if (scope && strcmp(scope, "universal") == 0) {
*outArr = _uniVars; *outCount = &_uniCount; return true;
}
return false;
}
// Which service UUID we advertise depends on what flavor of
// exchange is currently active. Live mode advertises its own UUID
// so the host's var-sync scanner (which filters on BLE_SERVICE_UUID)
// can't see — and race for — the device. The actual GATT service
// and characteristics are unchanged; the host's BleakClient finds
// the characteristics by UUID regardless of what was advertised.
const char* _advertisedUuidForExchange() const {
return (_exchangeKind == EX_LIVE) ? BLE_LIVE_SERVICE_UUID
: BLE_SERVICE_UUID;
}
void _applyAdvertisingData(NimBLEAdvertising* adv) {
if (!adv) return;
// Replace whatever the advertisement currently carries with a
// fresh data payload for the current exchange kind. Using
// setAdvertisementData (rather than add/remove of individual
// UUIDs) avoids accumulating stale UUIDs across re-advertise
// cycles, which would defeat the whole separation.
NimBLEAdvertisementData data;
const char* uuid = _advertisedUuidForExchange();
data.setCompleteServices(NimBLEUUID(uuid));
if (_exchangeKind == EX_LIVE) {
data.setName("MacroPad-Live");
} else {
data.setName("MacroPad");
}
adv->setAdvertisementData(data);
}
// ---- BLE startup ----
void _startBLE() {
if (_bleActive) {
// NimBLE already initialized from a prior exchange. Just
// restart advertising on the existing stack — no init/deinit
// dance, which is what crashed the chip on ESP32-S3.
_helloSent = false;
_pushSent = false;
_clientSubscribed = false;
_liveSubscribed = false;
_connectMs = 0;
_liveFallbackHelloSent = false;
_liveHelloAfterSub = false;
// Gate the re-advertise on the prior connection actually being
// gone. shutdown() force-disconnects, but if a peer reconnected
// in the gap (e.g. host's scan loop is fast) we still want to
// kick it before adv->start() — adv->start() on a connected
// NimBLE stack panics the chip on ESP32-S3.
NimBLEServer* server = NimBLEDevice::getServer();
uint16_t connectedAtEntry = server ? server->getConnectedCount() : 0;
if (server && connectedAtEntry > 0) {
for (uint16_t handle : server->getPeerDevices()) {
server->disconnect(handle);
}
uint32_t deadline = millis() + 500;
while (server->getConnectedCount() > 0 &&
(int32_t)(millis() - deadline) < 0) {
delay(10);
}
}
// Persist BEFORE adv->start so the re-advertise path leaves a
// forensic trail. Without this, a crash in NimBLE's adv->start
// wipes the in-RAM dbg ring and we lose all evidence between
// shutdown() (already persisted) and the next first-time init.
dbg.logf("startBLE: pre-readvertise connected entered=%u settled=%u",
(unsigned)connectedAtEntry,
(unsigned)(server ? server->getConnectedCount() : 0));
dbg.persistToDisk();
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
if (adv) {
_applyAdvertisingData(adv);
adv->start();
}
dbg.logf("startBLE: re-advertising on existing stack (uuid=%s)",
_advertisedUuidForExchange());
if (_dlog) _dlog->log("BLE: re-advertising");
return;
}
Serial.println("[BLE] Starting on-demand (first init)...");
dbg.log("startBLE: NimBLEDevice::init (first time)");
dbg.persistToDisk(); // capture pre-init state in case of panic
NimBLEDevice::init("MacroPad");
NimBLEServer* server = NimBLEDevice::createServer();
if (!_serverCB) _serverCB = new _BLEServerCB(this);
server->setCallbacks(_serverCB);
NimBLEService* service = server->createService(BLE_SERVICE_UUID);
_writeChar = service->createCharacteristic(
BLE_VARS_CHAR_UUID,
NIMBLE_PROPERTY::WRITE,
BLE_FRAME_BUF_SIZE);
if (!_writeCB) _writeCB = new _BLEWriteCB(this);
_writeChar->setCallbacks(_writeCB);
_notifyChar = service->createCharacteristic(
BLE_VARS_NOTIFY_UUID,
NIMBLE_PROPERTY::NOTIFY,
BLE_FRAME_BUF_SIZE);
if (!_notifyCB) _notifyCB = new _BLENotifyCB(this);
_notifyChar->setCallbacks(_notifyCB);
// Live-keystroke characteristics. WRITE_NR (Write Without Response)
// is what makes streaming low-latency — no L2CAP ACK round-trip
// per host write. We still authenticate via AES-GCM at the
// application layer.
_liveWriteChar = service->createCharacteristic(
BLE_LIVE_KEYS_WRITE_UUID,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR,
BLE_FRAME_BUF_SIZE);
if (!_liveWriteCB) _liveWriteCB = new _BLELiveWriteCB(this);
_liveWriteChar->setCallbacks(_liveWriteCB);
_liveNotifyChar = service->createCharacteristic(
BLE_LIVE_KEYS_NOTIFY_UUID,
NIMBLE_PROPERTY::NOTIFY,
BLE_FRAME_BUF_SIZE);
if (!_liveNotifyCB) _liveNotifyCB = new _BLELiveNotifyCB(this);
_liveNotifyChar->setCallbacks(_liveNotifyCB);
service->start();
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
_applyAdvertisingData(adv);
adv->enableScanResponse(true);
adv->start();
_bleActive = true;
_bleEverActive = true;
_helloSent = false;
_pushSent = false;
_clientSubscribed = false;
Serial.println("[BLE] Advertising");
if (_dlog) _dlog->log("BLE: advertising");
dbg.log("startBLE: advertising");
}
// ---- Hello / push frame senders ----
void _sendHello() {
// Bail before consuming a seq if we don't actually have an
// exchange to send. Can happen briefly between shutdown() and
// the next start* call.
if (_exchangeKind == EX_NONE) {
dbg.log("sendHello: EX_NONE — skipped");
return;
}
if (_exchangeKind == EX_LIVE) {
// Live channel uses the binary protocol on the dedicated
// notify characteristic — JSON would balloon the per-frame
// overhead for no benefit.
_sendLiveHello();
return;
}
char plaintext[BLE_VAR_BUF_SIZE];
uint64_t seq = nextSendSeq();
const char* kindStr = "?";
switch (_exchangeKind) {
case EX_PULL:
kindStr = "pull";
snprintf(plaintext, sizeof(plaintext),
"{\"op\":\"hello\",\"kind\":\"pull\",\"scope\":\"%s\","
"\"seq\":%llu,\"session_id\":%llu}",
_pendingScope,
(unsigned long long)seq,
(unsigned long long)_bootId);
break;
case EX_PUSH:
kindStr = "push";
snprintf(plaintext, sizeof(plaintext),
"{\"op\":\"hello\",\"kind\":\"push\","
"\"seq\":%llu,\"session_id\":%llu}",
(unsigned long long)seq,
(unsigned long long)_bootId);
break;
case EX_REQUEST:
kindStr = "request";
snprintf(plaintext, sizeof(plaintext),
"{\"op\":\"hello\",\"kind\":\"request\",\"names\":%s,"
"\"seq\":%llu,\"session_id\":%llu}",
_requestNamesJson.c_str(),
(unsigned long long)seq,
(unsigned long long)_bootId);
break;
default: return;
}
dbg.logf("sendHello kind=%s seq=%llu sid=%llu",
kindStr,
(unsigned long long)seq,
(unsigned long long)_bootId);
_notifyEncrypted(plaintext);
}
void _sendPushPayload() {
// Serialize _devVars as JSON object, embed in {"op":"push","vars":{...}}
JsonDocument doc;
doc["op"] = "push";
uint64_t seq = nextSendSeq();
doc["seq"] = seq;
doc["session_id"] = _bootId;
JsonObject vars = doc["vars"].to<JsonObject>();
for (int i = 0; i < _devCount; i++) {
vars[_devVars[i].name] = _devVars[i].value;
}
char buf[BLE_VAR_BUF_SIZE];
size_t n = serializeJson(doc, buf, sizeof(buf));
if (n == 0 || n >= sizeof(buf)) {
dbg.log("push payload: serialize failed");
return;
}
dbg.logf("sendPush seq=%llu vars=%d bytes=%u",
(unsigned long long)seq, _devCount, (unsigned)n);
_notifyEncrypted(buf);
}
// ---- Live-mode binary helpers ----
static uint64_t _readLE64(const uint8_t* p) {
uint64_t v = 0;
for (int i = 0; i < 8; i++) v |= ((uint64_t)p[i]) << (i * 8);
return v;
}
static void _writeLE64(uint8_t* p, uint64_t v) {
for (int i = 0; i < 8; i++) p[i] = (uint8_t)(v >> (i * 8));
}
void _notifyEncryptedBin(NimBLECharacteristic* ch,
const uint8_t* plain, size_t plainLen) {
if (!ch) {
dbg.log("live notify: char is null");
return;
}
uint8_t frame[BLE_FRAME_BUF_SIZE];
size_t frameLen = 0;
if (!_buildFrame(plain, plainLen, frame, sizeof(frame), &frameLen)) {
dbg.log("live notify: buildFrame failed");
return;
}
ch->setValue(frame, frameLen);
bool ok = ch->notify();
dbg.logf("live notify: sent %u bytes (ok=%d connected=%d sub=%d)",
(unsigned)frameLen, (int)ok,
(int)_clientConnected, (int)_liveSubscribed);
}
void _sendLiveHello() {
if (!_liveNotifyChar) return;
uint8_t buf[17];
uint64_t seq = nextSendSeq();
buf[0] = LIVE_MSG_HELLO;
_writeLE64(buf + 1, _bootId);
_writeLE64(buf + 9, seq);
dbg.logf("sendLiveHello seq=%llu", (unsigned long long)seq);
_notifyEncryptedBin(_liveNotifyChar, buf, sizeof(buf));
}
void _sendLiveAck(uint64_t refSeq) {
if (!_liveNotifyChar) return;
uint8_t buf[25];
uint64_t seq = nextSendSeq();
buf[0] = LIVE_MSG_ACK;
_writeLE64(buf + 1, _bootId);
_writeLE64(buf + 9, seq);
_writeLE64(buf + 17, refSeq);
_notifyEncryptedBin(_liveNotifyChar, buf, sizeof(buf));
}
void _sendLiveError(uint8_t errCode, uint64_t refSeq) {
if (!_liveNotifyChar) return;
uint8_t buf[26];
uint64_t seq = nextSendSeq();
buf[0] = LIVE_MSG_ERROR;
_writeLE64(buf + 1, _bootId);
_writeLE64(buf + 9, seq);
buf[17] = errCode;
_writeLE64(buf + 18, refSeq);
_notifyEncryptedBin(_liveNotifyChar, buf, sizeof(buf));
}
void _notifyEncrypted(const char* plaintext) {
if (!_notifyChar) {
dbg.log("notify: no char");
return;
}
uint8_t frame[BLE_FRAME_BUF_SIZE];
size_t frameLen = 0;
if (!_buildFrame((const uint8_t*)plaintext, strlen(plaintext),
frame, sizeof(frame), &frameLen)) {
dbg.log("notify: buildFrame failed");
return;
}
_notifyChar->setValue(frame, frameLen);
bool ok = _notifyChar->notify();
dbg.logf("notify: sent %u bytes (ok=%d connected=%d sub=%d)",
(unsigned)frameLen, (int)ok,
(int)_clientConnected, (int)_clientSubscribed);
}
// ---- Plaintext dispatch (host -> device) ----
//
// Runs on the NimBLE host task — see acceptReceivedSeq for the
// no-blocking-IO rule.
void _dispatchPlaintext(const char* json) {
JsonDocument doc;
DeserializationError jerr = deserializeJson(doc, json);
if (jerr) {
dbg.logf("dispatch: bad JSON (%s)", jerr.c_str());
return;
}
if (doc["seq"].isNull() || doc["session_id"].isNull()) {
dbg.log("dispatch: missing seq/session_id");
return;
}
uint64_t seq = doc["seq"].as<uint64_t>();
uint64_t sid = doc["session_id"].as<uint64_t>();
const char* reason = "?";
if (!acceptReceived(sid, seq, &reason)) {
dbg.logf("dispatch: reject %s sid=%llu seq=%llu (haveSid=%llu seen=%llu)",
reason,
(unsigned long long)sid,
(unsigned long long)seq,
(unsigned long long)_hostSessionId,
(unsigned long long)_hostSeen);
return;
}
if (strcmp(reason, "fresh_session") == 0) {
dbg.logf("dispatch: fresh_session sid=%llu (window reset)",
(unsigned long long)sid);
}
const char* op = doc["op"] | "";
if (strcmp(op, "pull") == 0) {
const char* scope = doc["scope"] | "universal";
JsonObject vars = doc["vars"].as<JsonObject>();
int varCount = 0;
for (JsonPair _p : vars) { (void)_p; varCount++; }
_applyPullPayload(scope, vars);
_exchangeDone = true;
dbg.logf("dispatch: pull(%s) seq=%llu vars=%d -> exDone",
scope, (unsigned long long)seq, varCount);
} else if (strcmp(op, "ack") == 0) {
_exchangeDone = true;
dbg.logf("dispatch: ack seq=%llu -> exDone",
(unsigned long long)seq);
} else {
dbg.logf("dispatch: unknown op '%s'", op);
}
}
void _applyPullPayload(const char* scope, JsonObject vars) {
BLEVariable* arr;
int* count;
if (!_pickScope(scope, &arr, &count)) return;
*count = 0;
for (JsonPair kv : vars) {
if (*count >= MAX_BLE_VARS) break;
strlcpy(arr[*count].name, kv.key().c_str(), BLE_VAR_NAME_LEN);
const char* v = kv.value().as<const char*>();
strlcpy(arr[*count].value, v ? v : "", BLE_VAR_VALUE_LEN);
(*count)++;
}
// Defer the disk write to shutdown() so we don't block the NimBLE
// host task.
strlcpy(_pendingPersistScope, scope, sizeof(_pendingPersistScope));
}
// ---- Frame build / parse with GCM + AAD ----
// Thin wrappers over frame_crypto.h (the format is shared with the
// ESP-NOW mesh layer, which encrypts under a session group key).
bool _buildFrame(const uint8_t* plaintext, size_t plainLen,
uint8_t* out, size_t outCap, size_t* outLen) {
if (!_keystore || !_keystore->hasKey()) return false;
return frameCryptoBuild(_keystore->key(), _deviceTag,
plaintext, plainLen, out, outCap, outLen);
}
// Verify and decrypt an inbound frame. Writes the recovered tag (NUL-term)
// and plaintext into the caller's buffers. Returns false silently on any
// malformed/auth-failed input.
bool _parseFrame(const uint8_t* in, size_t inLen,
char* outTag, size_t outTagCap,
uint8_t* outPlain, size_t* outPlainLen) {
if (!_keystore || !_keystore->hasKey()) return false;
return frameCryptoParse(_keystore->key(), in, inLen,
outTag, outTagCap,
outPlain, BLE_VAR_BUF_SIZE, outPlainLen);
}
// ---- Persistence ----
void saveScope(const char* scope) {
BLEVariable* arr;
int* count;
if (!_pickScope(scope, &arr, &count)) return;
const char* path = (strcmp(scope, "device") == 0)
? BLE_DEV_VARS_PATH : BLE_UNI_VARS_PATH;
File f = LittleFS.open(path, "w");
if (!f) {
Serial.printf("[BLE] save %s: open failed\n", path);
return;
}
JsonDocument doc;
for (int i = 0; i < *count; i++) {
doc[arr[i].name] = arr[i].value;
}
if (serializeJson(doc, f) == 0) {
Serial.printf("[BLE] save %s: serialize failed\n", path);
}
f.close();
}
void loadDevFromDisk() { _loadFile(BLE_DEV_VARS_PATH, _devVars, &_devCount); }
void loadUniFromDisk() { _loadFile(BLE_UNI_VARS_PATH, _uniVars, &_uniCount); }
// ---- Live-session resume flag ----
bool _readResumeFlag() {
if (!LittleFS.exists(BLE_LIVE_RESUME_PATH)) return false;
File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "r");
if (!f) return false;
int c = f.read();
f.close();
return c == '1';
}
void _writeResumeFlag(bool on) {
File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "w");
if (!f) return;
f.write(on ? '1' : '0');
f.close();
_resumeFlagOnDisk = on;
}
// ---- Replay-protection counters ----
void loadReplayFromDisk() {
_sendSeq = 0;
_hostSeen = 0;
_hostSessionId = 0;
if (!LittleFS.exists(BLE_REPLAY_STATE_PATH)) return;
File f = LittleFS.open(BLE_REPLAY_STATE_PATH, "r");
if (!f) return;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) return;
_sendSeq = doc["send_seq"].as<uint64_t>();
_hostSeen = doc["host_seen"].as<uint64_t>();
_hostSessionId = doc["host_session_id"].as<uint64_t>();
Serial.printf("[BLE] replay state: send=%llu hostSeen=%llu hostSid=%llu\n",
(unsigned long long)_sendSeq,
(unsigned long long)_hostSeen,
(unsigned long long)_hostSessionId);
}
void saveReplayToDisk() {
File f = LittleFS.open(BLE_REPLAY_STATE_PATH, "w");
if (!f) {
Serial.println("[BLE] save replay: open failed");
return;
}
JsonDocument doc;
doc["send_seq"] = _sendSeq;
doc["host_seen"] = _hostSeen;
doc["host_session_id"] = _hostSessionId;
serializeJson(doc, f);
f.close();
}
// Reserve the next outgoing seq. This is called from the main-loop
// task (via _sendHello / _sendPushPayload in pollStatus), so we can
// safely block on the flash write here — it persists BEFORE the
// frame is even built, so a power loss can't reuse a counter on
// next boot.
uint64_t nextSendSeq() {
_sendSeq++;
saveReplayToDisk();
return _sendSeq;
}
// Validate an inbound (host_session_id, seq). Returns false silently
// for replays or stale-session frames. Accepts gracefully when the
// host's session_id changes (host restart / wipe), resetting the
// seq window for that new session.
//
// CRITICAL: this runs on the NimBLE host task (callback context).
// We MUST NOT block on a LittleFS write here — flash GC can take
// seconds and that would either trigger the task watchdog (panic
// reset / "random crash") or break BLE protocol timing (dropped
// frames / "vars not found"). Update RAM only and set a dirty flag
// for the main loop to flush in pollStatus().
bool acceptReceived(uint64_t hostSid, uint64_t seq, const char** reason) {
if (hostSid == 0) {
// Pre-session-id frames are no longer accepted — forces
// both sides onto the new schema. Caller will log.
*reason = "no_session_id";
return false;
}
if (hostSid != _hostSessionId) {
// Host restarted/wiped — accept fresh.
_hostSessionId = hostSid;
_hostSeen = seq;
_replayDirty = true;
*reason = "fresh_session";
return true;
}
// Same session: enforce monotonic seq.
if (seq <= _hostSeen) {
*reason = "regressed_seq";
return false;
}
_hostSeen = seq;
_replayDirty = true;
*reason = "monotonic";
return true;
}
void _loadFile(const char* path, BLEVariable* arr, int* count) {
*count = 0;
if (!LittleFS.exists(path)) return;
File f = LittleFS.open(path, "r");
if (!f) return;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) return;
for (JsonPair kv : doc.as<JsonObject>()) {
if (*count >= MAX_BLE_VARS) break;
strlcpy(arr[*count].name, kv.key().c_str(), BLE_VAR_NAME_LEN);
const char* v = kv.value().as<const char*>();
strlcpy(arr[*count].value, v ? v : "", BLE_VAR_VALUE_LEN);
(*count)++;
}
Serial.printf("[BLE] Restored %d vars from %s\n", *count, path);
}
// ---- State ----
char _deviceTag[BLE_DEVICE_TAG_MAX] = {0};
BLEVariable _devVars[MAX_BLE_VARS];
BLEVariable _uniVars[MAX_BLE_VARS];
int _devCount = 0;
int _uniCount = 0;
// In-memory debug ring (publicly accessible so the serial protocol
// handler can call dumpJson/clear).
public:
BLERingLog dbg;
private:
// Per-boot session ID for THIS device. RAM-only. Sent on every
// outgoing frame so the host can detect device reset / reflash.
uint64_t _bootId = 0;
// The host's session ID we last accepted, plus the highest seq within
// that session. Persisted to flash; on new host session we reset
// _hostSeen and accept the first frame from the new session.
uint64_t _hostSessionId = 0;
// Replay-protection counters (persisted to LittleFS).
uint64_t _sendSeq = 0; // largest seq we've ever sent
uint64_t _hostSeen = 0; // largest seq we've ever accepted from host
// Set true by acceptReceivedSeq (NimBLE callback context); cleared by
// the main-loop pollStatus() after flushing to flash. See the comment
// on acceptReceivedSeq for why this isn't written inline.
volatile bool _replayDirty = false;
// millis() at last shutdown — startBLE waits 1 s past this before
// re-init'ing NimBLE so the radio fully tears down. Rapid init/deinit
// cycles on ESP32-S3 with concurrent USB-CDC are a known instability.
uint32_t _lastShutdownMs = 0;
bool _bleActive = false;
bool _bleEverActive = false; // true once NimBLE has been initialized
volatile bool _clientConnected = false;
volatile bool _clientSubscribed = false;
volatile bool _exchangeDone = false;
volatile bool _authFailed = false;
volatile bool _wrongTag = false;
volatile bool _needsReAdvertise = false;
bool _helloSent = false;
bool _pushSent = false;
ExchangeKind _exchangeKind = EX_NONE;
char _pendingScope[16] = {0};
String _requestNamesJson;
char _pendingPersistScope[16] = {0};
DebugLog* _dlog = nullptr;
BLEKeyStore* _keystore = nullptr;
NimBLECharacteristic* _writeChar = nullptr;
NimBLECharacteristic* _notifyChar = nullptr;
NimBLECharacteristic* _liveWriteChar = nullptr;
NimBLECharacteristic* _liveNotifyChar = nullptr;
_BLEServerCB* _serverCB = nullptr;
_BLEWriteCB* _writeCB = nullptr;
_BLENotifyCB* _notifyCB = nullptr;
_BLELiveWriteCB* _liveWriteCB = nullptr;
_BLELiveNotifyCB* _liveNotifyCB = nullptr;
LiveKeystrokeEngine* _liveEngine = nullptr;
volatile bool _liveSubscribed = false;
volatile bool _liveSawStart = false;
// millis() at last onClientConnect — used by the EX_LIVE pollStatus
// path to send a fallback hello if NimBLE's onSubscribe callback
// doesn't fire (we've observed this with Bleak on Windows even
// though the host has clearly enabled the CCCD on its side).
volatile uint32_t _connectMs = 0;
// Set true once the EX_LIVE fallback hello has fired so we don't
// spam the channel.
volatile bool _liveFallbackHelloSent = false;
// Set true once we've sent the hello AFTER observing onLiveSubscribe.
// This is the "canonical" hello — the one Bleak is guaranteed to
// have CCCD-enabled by the time we send it. Separate from _helloSent
// because the fallback (which may fire before subscribe) sets that
// one; we want to re-send when subscribe eventually arrives.
volatile bool _liveHelloAfterSub = false;
// Identify-logo state: set by a LIVE_MSG_IDENTIFY frame, auto-expires
// IDENTIFY_TIMEOUT_MS after the last on/off frame as a safety net.
volatile bool _liveIdentify = false;
volatile uint32_t _liveIdentifyMs = 0;
static constexpr uint32_t IDENTIFY_TIMEOUT_MS = 60000;
// Host-assigned label shown on the live screen. Persists in RAM across
// reconnects; the host re-sends it on connect so a reboot recovers it.
char _liveLabel[40] = {0};
volatile uint32_t _liveLabelVer = 0;
// Live-session resume flag. _resumeFlagOnDisk mirrors the LittleFS file;
// _liveResumeBoot is the value read at boot (consumed by the main loop);
// _resumeWantState is a deferred write request from the NimBLE callback
// (-1 = none, 0 = clear, 1 = set), flushed to flash in pollStatus().
bool _resumeFlagOnDisk = false;
bool _liveResumeBoot = false;
volatile int _resumeWantState = -1;
};
// --- NimBLE callback implementations ---
inline void _BLEServerCB::onConnect(NimBLEServer*, NimBLEConnInfo&) {
mgr->onClientConnect();
}
inline void _BLEServerCB::onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) {
mgr->onClientDisconnect();
}
inline void _BLEWriteCB::onWrite(NimBLECharacteristic* ch, NimBLEConnInfo&) {
const NimBLEAttValue& val = ch->getValue();
mgr->onWriteReceived(val.data(), val.size());
}
inline void _BLENotifyCB::onSubscribe(NimBLECharacteristic*, NimBLEConnInfo&,
uint16_t subValue) {
mgr->onClientSubscribe(subValue);
}
inline void _BLELiveWriteCB::onWrite(NimBLECharacteristic* ch, NimBLEConnInfo&) {
const NimBLEAttValue& val = ch->getValue();
mgr->onLiveWriteReceived(val.data(), val.size());
}
inline void _BLELiveNotifyCB::onSubscribe(NimBLECharacteristic*, NimBLEConnInfo&,
uint16_t subValue) {
mgr->onLiveSubscribe(subValue);
}