#pragma once #include #include #include #include #include #include #include #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(); 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(); 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 sid = doc["session_id"].as(); 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(); 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(); 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(); _hostSeen = doc["host_seen"].as(); _hostSessionId = doc["host_session_id"].as(); 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()) { if (*count >= MAX_BLE_VARS) break; strlcpy(arr[*count].name, kv.key().c_str(), BLE_VAR_NAME_LEN); const char* v = kv.value().as(); 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); }