1558 lines
63 KiB
C++
1558 lines
63 KiB
C++
#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);
|
||
}
|