1120 lines
41 KiB
C++
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;
|