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

1120 lines
41 KiB
C++

#pragma once
// EspNowManager — the live-keyboard mesh transport.
//
// Replaces the per-device BLE GATT live channel: one USB-attached device
// is switched into HUB mode by the host app and bridges USB-CDC <->
// ESP-NOW; every other device idle-listens as a NODE. ESP-NOW is pure
// connectionless WiFi (STA mode, fixed channel, no AP/SoftAP anywhere),
// so 10-30 mixed AtomS3 / AtomS3 Lite units can share one room with a
// single airtime slot per keystroke batch (hub broadcasts).
//
// Roles (one state machine, same universal binary everywhere):
//
// OFF ──────────► NODE_LISTEN ──JOIN──► NODE_JOINED
// ▲ │ ▲ │
// └──── radio off ◄──┘ └────── STOP ◄─────┘
// ▲
// └── HUB (entered ONLY via the espnow_hub serial command; reverts
// to OFF when the host goes quiet for MESH_HUB_HOST_TIMEOUT_MS)
//
// Zero-loss design (keystrokes / mouse buttons / wheel):
// * The host assigns every reliable frame a transport seq and the hub
// caches it in a 128-slot ring. DATA frames are broadcast.
// * Nodes deliver strictly in cum order; out-of-order frames sit in a
// 32-slot reorder buffer; gaps older than MESH_NACK_AFTER_MS trigger
// unicast NACKs; the hub rebroadcasts from the ring (RETX flag).
// * Nodes ACK cumulatively (every MESH_ACK_EVERY_N frames or
// MESH_ACK_MAX_DELAY_MS, jittered per-node so 30 ACKs don't collide).
// * The hub proactively rebroadcasts when a node's cum stalls — lost
// NACKs can't wedge the stream.
// * The device-side 100 ms cadence buffer (LiveKeystrokeEngine) absorbs
// the whole retransmit RTT, so recovered keystrokes still emit on the
// host's original typing cadence.
// * Pure absolute mouse moves ride the unreliable DATA_U lane —
// latest-wins (self-correcting), never retransmitted. Anything that
// must not be lost (buttons, wheel) goes on the reliable lane.
//
// Crypto: identical AES-256-GCM envelope as BLE (frame_crypto.h).
// * JOIN / JOIN_ACK — under this node's per-device key (the same key
// config/.ble_keys.json already holds, keyed by the STA MAC). JOIN
// carries the per-session group key.
// * DATA / DATA_U — under the session group key, tag = the hub's
// device tag. One ciphertext serves every node (broadcast).
// * The hub itself never holds the group key — it routes on the
// plaintext transport header only. ACK/NACK/BEACON/ERR are plaintext
// (sequence numbers and liveness only; a radio-local attacker could
// at worst provoke retransmits, never inject or read input).
//
// Threading: ESP-NOW callbacks run on the WiFi task. They ONLY copy the
// frame into a FreeRTOS queue; everything else (decrypt, dispatch, flash
// writes, Serial) happens in tick() on the main loop — the same pattern
// the BLE manager uses for NimBLE callbacks.
#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_now.h>
#include <esp_mac.h>
#include <esp_idf_version.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
#include "config.h"
#include "settings.h"
#include "frame_crypto.h"
#include "ble_keystore.h"
#include "live_keystroke.h"
#include "debug_log.h"
// Mesh frames are capped to the ESP-NOW v1 payload (250 B) so the design
// never depends on v2 long frames; a 16-event KEYS batch is ~183 B.
#define MESH_MAX_FRAME 250
// Inner (encrypted) live-protocol message types. 0x01-0x06 are byte-for-
// byte the BLE live protocol (ble_manager.h / ble_live.py); 0x07/0x08 are
// mesh-only per-node mute control.
#define MESH_IN_START 0x01
#define MESH_IN_KEYS 0x02
#define MESH_IN_STOP 0x03
#define MESH_IN_IDENTIFY 0x04
#define MESH_IN_MOUSE 0x05
#define MESH_IN_LABEL 0x06
#define MESH_IN_PAUSE 0x07
#define MESH_IN_RESUME 0x08
// Plaintext node->hub error codes (mirrors LIVE_ERR_* in ble_manager.h)
#define MESH_ERR_BUFFER_FULL 1
#define MESH_ERR_BAD_MSG 4
class EspNowManager {
public:
enum Role : uint8_t { OFF = 0, NODE_LISTEN, NODE_JOINED, HUB };
// Host-bound sink for hub mode: SerialProtocol wraps the payload in
// the binary CDC framing (magic + len + CRC16) and writes it out.
typedef void (*HostSink)(uint8_t htype, const uint8_t* payload, size_t len);
void begin(SettingsManager* settings, BLEKeyStore* keystore,
LiveKeystrokeEngine* engine, DebugLog* dlog) {
_settings = settings;
_keystore = keystore;
_engine = engine;
_dlog = dlog;
// Identity: eFuse base MAC == WiFi STA MAC == the MAC inside the
// AES device tag the host already keys encryption by.
if (esp_efuse_mac_get_default(_myMac) != ESP_OK) {
esp_read_mac(_myMac, ESP_MAC_WIFI_STA);
}
snprintf(_deviceTag, sizeof(_deviceTag),
"%s%02X:%02X:%02X:%02X:%02X:%02X", BLE_DEVICE_TAG_PREFIX,
_myMac[0], _myMac[1], _myMac[2],
_myMac[3], _myMac[4], _myMac[5]);
_rxQueue = xQueueCreate(32, sizeof(RxItem));
_resumeAtBoot = LittleFS.exists(BLE_LIVE_RESUME_PATH) &&
_readFlagFile();
_instance = this;
}
// True if power was lost mid-session: skip the boot grace and listen
// immediately so the host's session rejoins without user action.
bool resumeRequestedAtBoot() const { return _resumeAtBoot; }
void consumeResume() { _resumeAtBoot = false; }
Role role() const { return _role; }
bool isHub() const { return _role == HUB; }
bool isRadioActive() const { return _role != OFF; }
bool nodeInSession() const { return _role == NODE_JOINED; }
bool nodeIdentify() const { return _identify; }
const char* nodeLabel() const { return _label; }
uint32_t nodeLabelVer() const { return _labelVer; }
bool nodeLagging() const { return _lagging; }
int hubNodeCount() const { return _rosterCount; }
uint32_t hubNodesVer() const { return _rosterVer; }
const char* nodeStatusText() const {
if (_role != NODE_JOINED) return "Listening";
if (_lagging) return "Reconnecting...";
if (_paused) return "Muted by host";
return _engineSawKeys ? "Receiving" : "Connected";
}
// ------------------------------------------------------------------
// Role control (called from MacroPad.ino / SerialProtocol, main task)
// ------------------------------------------------------------------
// Idempotent: bring the radio up in passive node-listen. Safe to call
// every idle loop iteration (mirrors bleManager.startLive()).
void startNodeListen() {
if (_role == NODE_LISTEN || _role == NODE_JOINED || _role == HUB) return;
if (!_radioUp()) return;
_role = NODE_LISTEN;
_resetNodeSession();
if (_dlog) _dlog->log("mesh: node listening");
}
// Tear everything down (routine starting, USB upload, etc.).
void shutdown() {
if (_role == OFF) return;
bool wasJoined = (_role == NODE_JOINED);
if (wasJoined && _engine) _engine->stop();
_role = OFF;
_resetNodeSession();
_radioDown();
if (_dlog) _dlog->logf("mesh: shutdown (wasJoined=%d)", (int)wasJoined);
}
// Enter hub mode (espnow_hub serial command). The caller is
// responsible for shutting BLE down first.
bool hubStart(HostSink sink) {
shutdown();
if (!_radioUp()) return false;
_hostSink = sink;
_role = HUB;
_hubReset();
_lastHostMs = millis();
if (_dlog) _dlog->log("mesh: HUB on");
return true;
}
void hubStop() {
if (_role != HUB) return;
_role = OFF;
_hostSink = nullptr;
_radioDown();
if (_dlog) _dlog->log("mesh: HUB off");
}
// Any traffic from the host app (binary frame or hub_ping JSON).
void notifyHostActivity() { _lastHostMs = millis(); }
// Host pushed a complete mesh frame (H2D_SEND). Reliable DATA frames
// get cached in the retransmit ring before broadcast.
void hubSendFromHost(const uint8_t* frame, size_t len) {
if (_role != HUB || len < MESH_HDR_LEN || len > MESH_MAX_FRAME) return;
_lastHostMs = millis();
if (frame[0] != MESH_MAGIC) return;
uint8_t type = frame[1];
uint32_t seq = _rdU32(frame + 4);
if (type == MESH_T_DATA) {
RingSlot& slot = _ring[seq & (MESH_RING_FRAMES - 1)];
slot.seq = seq;
slot.len = (uint16_t)len;
slot.lastRetxMs = 0;
memcpy(slot.data, frame, len);
if ((int32_t)(seq - _hubMaxSeq) > 0) _hubMaxSeq = seq;
}
_txEnqueue(_BCAST, frame, len);
}
// ------------------------------------------------------------------
// Main-loop tick: drain RX, run timers, pump TX
// ------------------------------------------------------------------
void tick() {
if (_role == OFF) return;
uint32_t now = millis();
// Drain the WiFi-task RX queue (bounded per tick).
RxItem item;
int drained = 0;
while (drained < 8 && _rxQueue &&
xQueueReceive(_rxQueue, &item, 0) == pdTRUE) {
drained++;
if (_role == HUB) _hubOnRx(item, now);
else _nodeOnRx(item, now);
}
if (_role == HUB) {
_hubTimers(now);
} else if (_role == NODE_JOINED || _role == NODE_LISTEN) {
_nodeTimers(now);
}
// Deferred resume-flag writes (flash is main-task-only by policy).
if (_flagWant != -1) {
int want = _flagWant;
_flagWant = -1;
_writeFlagFile(want == 1);
}
_txPump();
}
private:
// ---- Wire helpers -------------------------------------------------
static constexpr uint8_t _BCAST[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
static uint32_t _rdU32(const uint8_t* p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static void _wrU32(uint8_t* p, uint32_t v) {
p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8);
p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24);
}
static bool _macEq(const uint8_t* a, const uint8_t* b) {
return memcmp(a, b, 6) == 0;
}
static bool _isBcast(const uint8_t* m) {
return _macEq(m, _BCAST);
}
void _mkHdr(uint8_t* out, uint8_t type, uint8_t flags, uint32_t seq,
const uint8_t* dest) {
out[0] = MESH_MAGIC;
out[1] = type;
out[2] = flags;
out[3] = 0;
_wrU32(out + 4, seq);
memcpy(out + 8, dest, 6);
}
// ---- Radio bring-up / teardown ------------------------------------
bool _radioUp() {
if (_radioActive) return true;
WiFi.mode(WIFI_STA);
WiFi.disconnect(true, false); // never associate with any AP
uint8_t ch = _settings ? _settings->settings.meshChannel
: DEFAULT_MESH_CHANNEL;
esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);
if (esp_now_init() != ESP_OK) {
WiFi.mode(WIFI_OFF);
if (_dlog) _dlog->log("mesh: esp_now_init FAILED");
return false;
}
esp_now_register_recv_cb(&EspNowManager::_recvCbStatic);
esp_now_register_send_cb(&EspNowManager::_sendCbStatic);
_addPeer(_BCAST);
_radioActive = true;
_txInflight = false;
_txHead = _txTail = _txCount = 0;
return true;
}
void _radioDown() {
if (!_radioActive) return;
esp_now_deinit();
WiFi.mode(WIFI_OFF);
_radioActive = false;
_txInflight = false;
_txHead = _txTail = _txCount = 0;
if (_rxQueue) xQueueReset(_rxQueue);
}
void _addPeer(const uint8_t* mac) {
if (esp_now_is_peer_exist(mac)) return;
esp_now_peer_info_t p = {};
memcpy(p.peer_addr, mac, 6);
p.channel = 0; // current channel
p.ifidx = WIFI_IF_STA;
p.encrypt = false; // crypto is app-layer AES-256-GCM
esp_now_add_peer(&p);
}
// ---- ESP-NOW callbacks (WiFi task — copy & return) -----------------
struct RxItem {
uint8_t src[6];
uint16_t len;
uint8_t data[MESH_MAX_FRAME];
};
static void _recvCbStatic(const esp_now_recv_info_t* info,
const uint8_t* data, int len) {
EspNowManager* self = _instance;
if (!self || !self->_rxQueue) return;
if (len < MESH_HDR_LEN || len > MESH_MAX_FRAME) return;
if (data[0] != MESH_MAGIC) return;
RxItem item;
memcpy(item.src, info->src_addr, 6);
item.len = (uint16_t)len;
memcpy(item.data, data, len);
// Drop-oldest would reorder; drop-newest keeps the NACK machinery
// simple (the gap is recovered like any other loss).
xQueueSend(self->_rxQueue, &item, 0);
}
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
static void _sendCbStatic(const esp_now_send_info_t* tx_info,
esp_now_send_status_t status) {
#else
static void _sendCbStatic(const uint8_t* mac_addr,
esp_now_send_status_t status) {
#endif
EspNowManager* self = _instance;
if (self) self->_txInflight = false;
(void)status; // MAC-level only; app-level ACKs carry the truth
}
// ---- TX queue (serialized sends; esp_now_send dislikes bursts) -----
struct TxItem {
uint8_t mac[6];
uint16_t len;
uint8_t data[MESH_MAX_FRAME];
};
static constexpr int TXQ_CAP = 24;
void _txEnqueue(const uint8_t* mac, const uint8_t* data, size_t len) {
if (len > MESH_MAX_FRAME) return;
if (_txCount >= TXQ_CAP) { _txDrops++; return; }
TxItem& t = _txq[_txTail];
memcpy(t.mac, mac, 6);
t.len = (uint16_t)len;
memcpy(t.data, data, len);
_txTail = (_txTail + 1) % TXQ_CAP;
_txCount++;
}
void _txPump() {
if (_txInflight || _txCount == 0 || !_radioActive) return;
TxItem& t = _txq[_txHead];
// Unicast peers are added lazily (nodes learn the hub's MAC from
// its first frame; the hub never unicasts except via peers that
// already wrote to it — but add defensively either way).
if (!_isBcast(t.mac)) _addPeer(t.mac);
_txInflight = true;
if (esp_now_send(t.mac, t.data, t.len) != ESP_OK) {
_txInflight = false; // dropped; reliability layer recovers
}
_txHead = (_txHead + 1) % TXQ_CAP;
_txCount--;
}
// ==================================================================
// NODE side
// ==================================================================
void _resetNodeSession() {
_hasSession = false;
_paused = false;
_identify = false;
_lagging = false;
_engineSawKeys = false;
_label[0] = '\0';
_cumSeq = 0;
_highSeen = 0;
_lastMoveSeq = 0;
_authFails = 0;
_framesSinceAck = 0;
_unackedSinceMs = 0;
_gapSinceMs = 0;
_lastNackMs = 0;
_lastHubFrameMs = 0;
_beaconDueMs = 0;
for (int i = 0; i < MESH_REORDER_SLOTS; i++) _reorder[i].used = false;
}
void _nodeOnRx(const RxItem& item, uint32_t now) {
const uint8_t* h = item.data;
uint8_t type = h[1];
uint8_t flags = h[2];
uint32_t seq = _rdU32(h + 4);
const uint8_t* dest = h + 8;
const uint8_t* payload = item.data + MESH_HDR_LEN;
size_t payLen = item.len - MESH_HDR_LEN;
(void)flags;
// Only hub->node types are meaningful here; ignore other nodes'
// unicast ACK/BEACON chatter that we happen to overhear.
if (type != MESH_T_DATA && type != MESH_T_DATA_U &&
type != MESH_T_JOIN && type != MESH_T_POLL) {
return;
}
_lastHubFrameMs = now;
if (_lagging && _hasSession) _lagging = false;
switch (type) {
case MESH_T_POLL: {
// Hub keepalive doubles as discovery: reply with a BEACON
// (jittered so 30 nodes don't collide).
memcpy(_hubMac, item.src, 6);
uint16_t jitter = _hasSession
? (uint16_t)(_nodeIdx * 2)
: (uint16_t)(esp_random() % 40);
if (_beaconDueMs == 0) _beaconDueMs = now + jitter + 1;
break;
}
case MESH_T_JOIN: {
if (!_isBcast(dest) && !_macEq(dest, _myMac)) return;
_nodeHandleJoin(item.src, payload, payLen, now);
break;
}
case MESH_T_DATA: {
if (!_hasSession) return;
if (!_macEq(item.src, _hubMac)) return;
_nodeHandleData(seq, dest, payload, payLen, item.len, item.data, now);
break;
}
case MESH_T_DATA_U: {
if (!_hasSession) return;
if (!_macEq(item.src, _hubMac)) return;
// Latest-wins lane: strictly newer move seqs only.
if ((int32_t)(seq - _lastMoveSeq) <= 0) return;
_lastMoveSeq = seq;
if (!_isBcast(dest) && !_macEq(dest, _myMac)) return;
static uint8_t plain[MESH_MAX_FRAME];
size_t plainLen = 0;
char tag[BLE_DEVICE_TAG_MAX];
if (!frameCryptoParse(_groupKey, payload, payLen,
tag, sizeof(tag),
plain, sizeof(plain), &plainLen)) return;
if (strcmp(tag, _hubTag) != 0) return;
_dispatchInner(plain, plainLen, now);
break;
}
}
}
void _nodeHandleJoin(const uint8_t* src, const uint8_t* payload,
size_t payLen, uint32_t now) {
if (!_keystore || !_keystore->hasKey()) return;
static uint8_t plain[MESH_MAX_FRAME];
size_t plainLen = 0;
char tag[BLE_DEVICE_TAG_MAX];
if (!frameCryptoParse(_keystore->key(), payload, payLen,
tag, sizeof(tag), plain, sizeof(plain),
&plainLen)) {
return; // not for us / wrong key — silent like BLE
}
if (strcmp(tag, _deviceTag) != 0) return;
plain[plainLen] = '\0';
JsonDocument doc;
if (deserializeJson(doc, (const char*)plain) != DeserializationError::Ok) return;
const char* gkeyHex = doc["gkey"] | "";
if (strlen(gkeyHex) != FRAME_KEY_LEN * 2) return;
for (size_t i = 0; i < FRAME_KEY_LEN; i++) {
char b[3] = { gkeyHex[i * 2], gkeyHex[i * 2 + 1], 0 };
_groupKey[i] = (uint8_t)strtoul(b, nullptr, 16);
}
_sessionId = doc["sid"].as<uint64_t>();
_nodeIdx = doc["idx"] | 0;
uint32_t base = doc["base"] | 0;
memcpy(_hubMac, src, 6);
snprintf(_hubTag, sizeof(_hubTag), "%s%02X:%02X:%02X:%02X:%02X:%02X",
BLE_DEVICE_TAG_PREFIX, src[0], src[1], src[2], src[3],
src[4], src[5]);
bool rejoin = _hasSession;
_hasSession = true;
_role = NODE_JOINED;
_cumSeq = base;
_highSeen = base;
_lastMoveSeq = 0;
_authFails = 0;
_paused = false;
_lagging = false;
for (int i = 0; i < MESH_REORDER_SLOTS; i++) _reorder[i].used = false;
_lastHubFrameMs = now;
if (_engine) _engine->start();
_flagWant = 1; // persist live-resume flag (survives power loss)
// JOIN_ACK back under the per-device key so the host (via the
// hub) gets cryptographic confirmation this exact device joined.
uint8_t ack[MESH_MAX_FRAME];
_mkHdr(ack, MESH_T_JOIN_ACK, 0, base, _hubMac);
uint8_t body[24];
size_t bn = 0;
body[bn++] = 1; // ok
body[bn++] = (uint8_t)_nodeIdx;
size_t encLen = 0;
if (frameCryptoBuild(_keystore->key(), _deviceTag, body, bn,
ack + MESH_HDR_LEN,
sizeof(ack) - MESH_HDR_LEN, &encLen)) {
_txEnqueue(_hubMac, ack, MESH_HDR_LEN + encLen);
}
if (_dlog) _dlog->logf("mesh: %sjoined idx=%d base=%lu",
rejoin ? "re" : "", _nodeIdx,
(unsigned long)base);
}
void _nodeHandleData(uint32_t seq, const uint8_t* dest,
const uint8_t* payload, size_t payLen,
uint16_t rawLen, const uint8_t* rawFrame,
uint32_t now) {
if ((int32_t)(seq - _highSeen) > 0) _highSeen = seq;
if ((int32_t)(seq - _cumSeq) <= 0) {
// Duplicate (retransmit we already have) — re-ACK promptly so
// the hub stops resending.
_framesSinceAck = MESH_ACK_EVERY_N;
return;
}
if (seq == _cumSeq + 1) {
_deliverData(dest, payload, payLen, now);
_cumSeq = seq;
if (_unackedSinceMs == 0) _unackedSinceMs = now;
_framesSinceAck++;
_drainReorder(now);
if (_cumSeq >= _highSeen) _gapSinceMs = 0;
} else {
// Out of order: stash and let the NACK timer fill the gap.
int slot = -1;
for (int i = 0; i < MESH_REORDER_SLOTS; i++) {
if (_reorder[i].used && _reorder[i].seq == seq) return; // dup
if (slot < 0 && !_reorder[i].used) slot = i;
}
if (slot >= 0) {
_reorder[slot].used = true;
_reorder[slot].seq = seq;
_reorder[slot].len = rawLen;
memcpy(_reorder[slot].data, rawFrame, rawLen);
}
if (_gapSinceMs == 0) _gapSinceMs = now;
}
}
void _drainReorder(uint32_t now) {
bool advanced = true;
while (advanced) {
advanced = false;
for (int i = 0; i < MESH_REORDER_SLOTS; i++) {
if (!_reorder[i].used) continue;
if (_reorder[i].seq == _cumSeq + 1) {
const uint8_t* f = _reorder[i].data;
_deliverData(f + 8, f + MESH_HDR_LEN,
_reorder[i].len - MESH_HDR_LEN, now);
_cumSeq++;
_framesSinceAck++;
_reorder[i].used = false;
advanced = true;
} else if ((int32_t)(_reorder[i].seq - _cumSeq) <= 0) {
_reorder[i].used = false; // stale
}
}
}
if (_cumSeq >= _highSeen) _gapSinceMs = 0;
}
void _deliverData(const uint8_t* dest, const uint8_t* payload,
size_t payLen, uint32_t now) {
// Per-node frames still consume a seq for everyone (single shared
// stream); only the addressee decrypts.
if (!_isBcast(dest) && !_macEq(dest, _myMac)) return;
static uint8_t plain[MESH_MAX_FRAME];
size_t plainLen = 0;
char tag[BLE_DEVICE_TAG_MAX];
if (!frameCryptoParse(_groupKey, payload, payLen, tag, sizeof(tag),
plain, sizeof(plain), &plainLen)) {
// Wrong group key — stale session (host restarted without a
// re-JOIN reaching us). After a few strikes drop the session
// and beacon as available; the host re-JOINs automatically.
if (++_authFails >= 3) {
if (_dlog) _dlog->log("mesh: group-key mismatch, leaving session");
_leaveSession(false);
}
return;
}
_authFails = 0;
if (strcmp(tag, _hubTag) != 0) return;
_dispatchInner(plain, plainLen, now);
}
// Inner live-protocol dispatch — the mesh twin of BLEManager::
// onLiveWriteReceived. Replay/ordering/ACK live at the transport
// layer, so this only interprets content. Runs on the main task.
void _dispatchInner(const uint8_t* plain, size_t plainLen, uint32_t now) {
if (plainLen < 17) return;
uint8_t msgType = plain[0];
// sid sanity: ignore frames from another session generation.
uint64_t sid = 0;
for (int i = 0; i < 8; i++) sid |= ((uint64_t)plain[1 + i]) << (i * 8);
if (sid != _sessionId) return;
const uint8_t* body = plain + 17;
size_t bodyLen = plainLen - 17;
switch (msgType) {
case MESH_IN_START:
if (_engine) _engine->start();
_engineSawKeys = false;
break;
case MESH_IN_KEYS: {
if (_paused) break;
if (bodyLen < 1) break;
uint8_t count = body[0];
if (bodyLen < 1u + (size_t)count * 6u) break;
bool anyDrop = false;
for (uint8_t i = 0; i < count; i++) {
const uint8_t* ev = body + 1 + i * 6;
uint32_t tMs = (uint32_t)ev[2] | ((uint32_t)ev[3] << 8) |
((uint32_t)ev[4] << 16) |
((uint32_t)ev[5] << 24);
if (_engine && !_engine->enqueue(ev[0], ev[1], tMs)) {
anyDrop = true;
}
}
_engineSawKeys = true;
if (anyDrop) _sendErr(MESH_ERR_BUFFER_FULL, _cumSeq);
break;
}
case MESH_IN_MOUSE: {
if (_paused || bodyLen < 6) break;
uint16_t x = (uint16_t)body[1] | ((uint16_t)body[2] << 8);
uint16_t y = (uint16_t)body[3] | ((uint16_t)body[4] << 8);
if (_engine) {
_engine->enqueueMouse(body[0], x, y, (int8_t)body[5]);
}
break;
}
case MESH_IN_IDENTIFY:
_identify = (bodyLen >= 1) ? (body[0] != 0) : true;
_identifyMs = now;
break;
case MESH_IN_LABEL: {
size_t n = bodyLen;
if (n >= sizeof(_label)) n = sizeof(_label) - 1;
memcpy(_label, body, n);
_label[n] = '\0';
_labelVer++;
break;
}
case MESH_IN_PAUSE:
_paused = true;
// Defensive: release anything held so a muted node can't
// wedge keys down on its target machine.
if (_engine) { _engine->stop(); _engine->start(); }
break;
case MESH_IN_RESUME:
_paused = false;
break;
case MESH_IN_STOP:
_leaveSession(true);
break;
default:
_sendErr(MESH_ERR_BAD_MSG, _cumSeq);
break;
}
}
void _leaveSession(bool explicitStop) {
if (_engine) _engine->stop();
_hasSession = false;
_paused = false;
_identify = false;
_lagging = false;
_label[0] = '\0';
_labelVer++;
if (_role == NODE_JOINED) _role = NODE_LISTEN;
if (explicitStop) _flagWant = 0; // clean end: no power-loss resume
}
void _nodeTimers(uint32_t now) {
// Scheduled BEACON reply (jittered)
if (_beaconDueMs != 0 && (int32_t)(now - _beaconDueMs) >= 0) {
_beaconDueMs = 0;
_sendBeacon();
}
if (_role != NODE_JOINED) return;
// Cumulative ACK timer
bool ackDue =
(_framesSinceAck >= MESH_ACK_EVERY_N) ||
(_unackedSinceMs != 0 &&
(now - _unackedSinceMs) >= (uint32_t)(MESH_ACK_MAX_DELAY_MS + _nodeIdx * 2));
if (ackDue) _sendAck(now);
// Gap NACK timer
if (_gapSinceMs != 0 && (now - _gapSinceMs) >= MESH_NACK_AFTER_MS &&
(now - _lastNackMs) >= MESH_NACK_REPEAT_MS) {
_sendNack(now);
}
// Hub-silence watchdog. The hub keepalive-POLLs every second, so
// multi-second silence means we're out of range / hub gone. Stop
// emitting (release held keys), flag the LED/screen, and after a
// long quiet period drop back to plain listening (the resume flag
// stays set so a returning host re-JOINs us seamlessly).
if (_lastHubFrameMs != 0) {
uint32_t quiet = now - _lastHubFrameMs;
if (quiet > 3000 && !_lagging) {
_lagging = true;
if (_engine) { _engine->stop(); _engine->start(); }
} else if (quiet > 30000) {
if (_dlog) _dlog->log("mesh: hub silent 30s, leaving session");
_leaveSession(false);
}
}
// Identify safety timeout (host crashed mid-label-dialog)
if (_identify && (now - _identifyMs) > 30000) _identify = false;
}
void _sendBeacon() {
uint8_t f[MESH_HDR_LEN + 4 + MESH_BEACON_LABEL_LEN];
_mkHdr(f, MESH_T_BEACON, 0, 0, _hubMac);
uint8_t* b = f + MESH_HDR_LEN;
b[0] = 1; // proto version
b[1] = _boardIsLite() ? 1 : 0;
b[2] = _hasSession ? 1 : 0;
b[3] = _paused ? 1 : 0;
size_t ln = strlen(_label);
if (ln > MESH_BEACON_LABEL_LEN - 1) ln = MESH_BEACON_LABEL_LEN - 1;
memcpy(b + 4, _label, ln);
b[4 + ln] = '\0';
_txEnqueue(_hubMac, f, MESH_HDR_LEN + 4 + ln + 1);
}
void _sendAck(uint32_t now) {
uint8_t f[MESH_HDR_LEN + 10];
_mkHdr(f, MESH_T_ACK, 0, _cumSeq, _hubMac);
uint8_t* b = f + MESH_HDR_LEN;
_wrU32(b, _cumSeq);
_wrU32(b + 4, _lastMoveSeq);
uint8_t flags = 0;
if (_engine && _engine->takeOverflowFlag()) flags |= 0x01;
if (_paused) flags |= 0x02;
b[8] = flags;
b[9] = (uint8_t)_nodeIdx;
_txEnqueue(_hubMac, f, MESH_HDR_LEN + 10);
_framesSinceAck = 0;
_unackedSinceMs = 0;
(void)now;
}
void _sendNack(uint32_t now) {
// Report up to 8 missing ranges between cum+1 and highSeen,
// skipping seqs already parked in the reorder buffer.
uint8_t ranges = 0;
uint8_t f[MESH_HDR_LEN + 1 + 8 * 8];
uint8_t* b = f + MESH_HDR_LEN + 1;
uint32_t s = _cumSeq + 1;
while (ranges < 8 && (int32_t)(_highSeen - s) >= 0) {
if (_inReorder(s)) { s++; continue; }
uint32_t from = s;
while ((int32_t)(_highSeen - s) >= 0 && !_inReorder(s)) s++;
uint32_t to = s - 1;
_wrU32(b + ranges * 8, from);
_wrU32(b + ranges * 8 + 4, to);
ranges++;
}
if (ranges == 0) { _gapSinceMs = 0; return; }
_mkHdr(f, MESH_T_NACK, 0, _cumSeq, _hubMac);
f[MESH_HDR_LEN] = ranges;
_txEnqueue(_hubMac, f, MESH_HDR_LEN + 1 + ranges * 8);
_lastNackMs = now;
}
bool _inReorder(uint32_t seq) const {
for (int i = 0; i < MESH_REORDER_SLOTS; i++) {
if (_reorder[i].used && _reorder[i].seq == seq) return true;
}
return false;
}
void _sendErr(uint8_t code, uint32_t refSeq) {
uint8_t f[MESH_HDR_LEN + 2];
_mkHdr(f, MESH_T_ERR, 0, refSeq, _hubMac);
f[MESH_HDR_LEN] = code;
f[MESH_HDR_LEN + 1] = (uint8_t)_nodeIdx;
_txEnqueue(_hubMac, f, MESH_HDR_LEN + 2);
}
bool _boardIsLite() const {
return M5.getBoard() == m5::board_t::board_M5AtomS3Lite;
}
// ==================================================================
// HUB side
// ==================================================================
struct RingSlot {
uint32_t seq = 0;
uint16_t len = 0;
uint32_t lastRetxMs = 0;
uint8_t data[MESH_MAX_FRAME];
};
struct NodeInfo {
uint8_t mac[6];
uint32_t cum = 0;
uint32_t moveSeq = 0;
uint32_t lastAckMs = 0;
uint32_t lastProgressMs = 0;
uint8_t flags = 0;
bool used = false;
};
static constexpr int ROSTER_CAP = 32;
void _hubReset() {
_hubMaxSeq = 0;
_rosterCount = 0;
_rosterVer++;
for (int i = 0; i < ROSTER_CAP; i++) _roster[i].used = false;
for (int i = 0; i < MESH_RING_FRAMES; i++) _ring[i].len = 0;
_lastAcktabMs = 0;
_lastPollMs = 0;
_pollActive = false;
}
NodeInfo* _findNode(const uint8_t* mac, bool create) {
for (int i = 0; i < ROSTER_CAP; i++) {
if (_roster[i].used && _macEq(_roster[i].mac, mac)) return &_roster[i];
}
if (!create) return nullptr;
for (int i = 0; i < ROSTER_CAP; i++) {
if (!_roster[i].used) {
_roster[i].used = true;
memcpy(_roster[i].mac, mac, 6);
_roster[i].cum = 0;
_roster[i].moveSeq = 0;
_roster[i].lastAckMs = millis();
_roster[i].lastProgressMs = millis();
_rosterCount++;
_rosterVer++;
return &_roster[i];
}
}
return nullptr;
}
void _hubOnRx(const RxItem& item, uint32_t now) {
const uint8_t* h = item.data;
uint8_t type = h[1];
const uint8_t* payload = item.data + MESH_HDR_LEN;
size_t payLen = item.len - MESH_HDR_LEN;
switch (type) {
case MESH_T_ACK: {
if (payLen < 10) return;
NodeInfo* n = _findNode(item.src, true);
if (!n) return;
uint32_t cum = _rdU32(payload);
if ((int32_t)(cum - n->cum) > 0) {
n->cum = cum;
n->lastProgressMs = now;
}
n->moveSeq = _rdU32(payload + 4);
n->flags = payload[8];
n->lastAckMs = now;
break;
}
case MESH_T_NACK: {
if (payLen < 1) return;
NodeInfo* n = _findNode(item.src, true);
if (n) n->lastAckMs = now;
uint8_t ranges = payload[0];
if (payLen < 1u + (size_t)ranges * 8u) return;
for (uint8_t r = 0; r < ranges && r < 8; r++) {
uint32_t from = _rdU32(payload + 1 + r * 8);
uint32_t to = _rdU32(payload + 1 + r * 8 + 4);
// Bound the burst: a node 100+ behind recovers via
// repeated NACK rounds rather than one storm.
if (to - from > 16) to = from + 16;
for (uint32_t s = from; (int32_t)(to - s) >= 0; s++) {
_retxSeq(s, now);
}
}
break;
}
case MESH_T_BEACON:
case MESH_T_JOIN_ACK:
case MESH_T_ERR: {
if (type == MESH_T_BEACON) {
NodeInfo* n = _findNode(item.src, true);
if (n) n->lastAckMs = now;
}
// Forward to the host: src_mac + raw frame.
if (_hostSink) {
uint8_t buf[6 + MESH_MAX_FRAME];
memcpy(buf, item.src, 6);
memcpy(buf + 6, item.data, item.len);
_hostSink(HUB_D2H_RX, buf, 6 + item.len);
}
break;
}
default:
break;
}
}
void _retxSeq(uint32_t seq, uint32_t now) {
RingSlot& slot = _ring[seq & (MESH_RING_FRAMES - 1)];
if (slot.len == 0 || slot.seq != seq) return; // evicted
if (slot.lastRetxMs != 0 &&
(now - slot.lastRetxMs) < MESH_RETX_MIN_GAP_MS) return;
slot.lastRetxMs = now;
uint8_t f[MESH_MAX_FRAME];
memcpy(f, slot.data, slot.len);
f[2] |= MESH_F_RETX;
_txEnqueue(_BCAST, f, slot.len);
}
void _hubTimers(uint32_t now) {
// Host heartbeat: a dead/closed host app must not leave an
// orphaned hub running forever.
if ((now - _lastHostMs) > MESH_HUB_HOST_TIMEOUT_MS) {
if (_dlog) _dlog->log("mesh: host silent, hub auto-off");
hubStop();
return;
}
// Keepalive / discovery POLL every second. Nodes treat ANY hub
// frame as liveness; idle nodes use it to beacon their presence.
if ((now - _lastPollMs) >= 1000) {
_lastPollMs = now;
uint8_t f[MESH_HDR_LEN + 5];
_mkHdr(f, MESH_T_POLL, 0, 0, _BCAST);
_wrU32(f + MESH_HDR_LEN, ++_pollId);
f[MESH_HDR_LEN + 4] = _pollActive ? 1 : 0;
_txEnqueue(_BCAST, f, MESH_HDR_LEN + 5);
}
// Stall safety: a node whose cum lags maxSeq with no progress for
// MESH_STALL_REBCAST_MS gets its next frame rebroadcast even if
// its NACKs are getting lost.
if (_hubMaxSeq != 0) {
for (int i = 0; i < ROSTER_CAP; i++) {
NodeInfo& n = _roster[i];
if (!n.used) continue;
if ((now - n.lastAckMs) > MESH_NODE_OFFLINE_MS) continue;
if ((int32_t)(_hubMaxSeq - n.cum) > 0 &&
(now - n.lastProgressMs) > MESH_STALL_REBCAST_MS) {
_retxSeq(n.cum + 1, now);
}
}
}
// Periodic per-node ACK table up to the host.
if (_hostSink && (now - _lastAcktabMs) >= HUB_ACKTAB_PERIOD_MS) {
_lastAcktabMs = now;
uint8_t buf[1 + ROSTER_CAP * 17];
uint8_t cnt = 0;
for (int i = 0; i < ROSTER_CAP && cnt < ROSTER_CAP; i++) {
NodeInfo& n = _roster[i];
if (!n.used) continue;
uint8_t* e = buf + 1 + cnt * 17;
memcpy(e, n.mac, 6);
_wrU32(e + 6, n.cum);
_wrU32(e + 10, n.moveSeq);
uint32_t age = now - n.lastAckMs;
uint16_t age16 = (age > 0xFFFF) ? 0xFFFF : (uint16_t)age;
e[14] = (uint8_t)age16;
e[15] = (uint8_t)(age16 >> 8);
e[16] = n.flags;
cnt++;
}
buf[0] = cnt;
_hostSink(HUB_D2H_ACKTAB, buf, 1 + (size_t)cnt * 17);
}
}
public:
// Host-driven discovery toggle (mesh_poll command): when on, the
// keepalive POLL asks idle nodes to beacon.
void hubSetPollActive(bool on) { _pollActive = on; }
private:
// ---- Wiring ----
SettingsManager* _settings = nullptr;
BLEKeyStore* _keystore = nullptr;
LiveKeystrokeEngine* _engine = nullptr;
DebugLog* _dlog = nullptr;
static EspNowManager* _instance;
// ---- Identity ----
uint8_t _myMac[6] = {0};
char _deviceTag[BLE_DEVICE_TAG_MAX] = {0};
// ---- Role / radio ----
volatile Role _role = OFF;
bool _radioActive = false;
QueueHandle_t _rxQueue = nullptr;
// ---- TX ----
TxItem _txq[TXQ_CAP];
int _txHead = 0, _txTail = 0;
volatile int _txCount = 0;
volatile bool _txInflight = false;
uint32_t _txDrops = 0;
// ---- Node session ----
bool _hasSession = false;
uint64_t _sessionId = 0;
uint8_t _groupKey[FRAME_KEY_LEN] = {0};
uint8_t _hubMac[6] = {0};
char _hubTag[BLE_DEVICE_TAG_MAX] = {0};
int _nodeIdx = 0;
bool _paused = false;
bool _identify = false;
uint32_t _identifyMs = 0;
bool _lagging = false;
bool _engineSawKeys = false;
char _label[40] = {0};
uint32_t _labelVer = 0;
int _authFails = 0;
uint32_t _cumSeq = 0;
uint32_t _highSeen = 0;
uint32_t _lastMoveSeq = 0;
int _framesSinceAck = 0;
uint32_t _unackedSinceMs = 0;
uint32_t _gapSinceMs = 0;
uint32_t _lastNackMs = 0;
uint32_t _lastHubFrameMs = 0;
uint32_t _beaconDueMs = 0;
struct ReorderSlot {
bool used = false;
uint32_t seq = 0;
uint16_t len = 0;
uint8_t data[MESH_MAX_FRAME];
};
ReorderSlot _reorder[MESH_REORDER_SLOTS];
// ---- Hub state ----
HostSink _hostSink = nullptr;
RingSlot _ring[MESH_RING_FRAMES];
NodeInfo _roster[ROSTER_CAP];
int _rosterCount = 0;
uint32_t _rosterVer = 0;
uint32_t _hubMaxSeq = 0;
uint32_t _lastHostMs = 0;
uint32_t _lastAcktabMs = 0;
uint32_t _lastPollMs = 0;
uint32_t _pollId = 0;
bool _pollActive = false;
// ---- Live-resume flag (same file the BLE path used) ----
bool _resumeAtBoot = false;
volatile int _flagWant = -1;
bool _readFlagFile() {
File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "r");
if (!f) return false;
int c = f.read();
f.close();
return c == '1';
}
void _writeFlagFile(bool on) {
File f = LittleFS.open(BLE_LIVE_RESUME_PATH, "w");
if (!f) return;
f.write(on ? '1' : '0');
f.close();
}
};
inline EspNowManager* EspNowManager::_instance = nullptr;