#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 #include #include #include #include #include #include #include #include #include #include #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(); _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;