862 lines
30 KiB
C++
862 lines
30 KiB
C++
#pragma once
|
|
|
|
#include <ArduinoJson.h>
|
|
#include <esp_mac.h>
|
|
#include "esp32-hal-tinyusb.h"
|
|
#include "config.h"
|
|
#include "settings.h"
|
|
#include "macro_storage.h"
|
|
#include "display_ui.h"
|
|
#include "debug_log.h"
|
|
#include "rs232_util.h"
|
|
#include "ble_keystore.h"
|
|
#include "ble_manager.h"
|
|
#include "espnow_manager.h"
|
|
|
|
// CRC16-CCITT (poly 0x1021, init 0xFFFF) over the hub binary framing —
|
|
// must match mesh_link.py on the host.
|
|
inline uint16_t hubCrc16(const uint8_t* data, size_t len,
|
|
uint16_t crc = 0xFFFF) {
|
|
for (size_t i = 0; i < len; i++) {
|
|
crc ^= (uint16_t)data[i] << 8;
|
|
for (int b = 0; b < 8; b++) {
|
|
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021)
|
|
: (uint16_t)(crc << 1);
|
|
}
|
|
}
|
|
return crc;
|
|
}
|
|
|
|
class SerialProtocol {
|
|
public:
|
|
typedef void (*VoidCallback)();
|
|
|
|
void begin(SettingsManager* settings, MacroStorage* storage, DisplayUI* display,
|
|
DebugLog* dlog = nullptr, HardwareSerial* rs232 = nullptr,
|
|
VoidCallback onRS232Reconfig = nullptr,
|
|
BLEKeyStore* keystore = nullptr,
|
|
BLEManager* bleManager = nullptr,
|
|
EspNowManager* mesh = nullptr) {
|
|
_settings = settings;
|
|
_storage = storage;
|
|
_display = display;
|
|
_dlog = dlog;
|
|
_rs232Serial = rs232;
|
|
_onRS232Reconfig = onRS232Reconfig;
|
|
_keystore = keystore;
|
|
_bleManager = bleManager;
|
|
_mesh = mesh;
|
|
}
|
|
|
|
// Call periodically from the main loop. When the host-side RS232 terminal
|
|
// is open, drains incoming bytes into a buffer that rs232_poll returns.
|
|
void pollRS232() {
|
|
if (!_rs232TerminalOpen || !_rs232Serial) return;
|
|
while (_rs232Serial->available()) {
|
|
if (_rs232TerminalBufPos >= (int)sizeof(_rs232TerminalBuf)) {
|
|
// Buffer full — drop oldest half so we don't lose forever-recent data
|
|
int keep = sizeof(_rs232TerminalBuf) / 2;
|
|
memmove(_rs232TerminalBuf, _rs232TerminalBuf + (sizeof(_rs232TerminalBuf) - keep), keep);
|
|
_rs232TerminalBufPos = keep;
|
|
}
|
|
_rs232TerminalBuf[_rs232TerminalBufPos++] = _rs232Serial->read();
|
|
}
|
|
}
|
|
|
|
// Returns true if settings changed (display needs refresh)
|
|
bool handleSerial() {
|
|
if (!Serial.available()) return false;
|
|
|
|
if (_receivingImage) {
|
|
// Keep the upload-active window fresh across the whole image
|
|
// transfer so BLE stays suspended until it finishes.
|
|
_lastUploadCmdMs = millis();
|
|
return receiveImageData();
|
|
}
|
|
|
|
// Hub binary bridge: a frame in progress, or a new one starting.
|
|
// JSON lines keep working in parallel — we dispatch on the first
|
|
// byte (0xC8 = binary frame, '{' = JSON line).
|
|
if (_binState != BIN_IDLE) return _pumpBinary();
|
|
if (Serial.peek() == HUB_MAGIC0) {
|
|
Serial.read();
|
|
_binState = BIN_MAGIC1;
|
|
return _pumpBinary();
|
|
}
|
|
|
|
String line = Serial.readStringUntil('\n');
|
|
line.trim();
|
|
if (line.length() == 0) return false;
|
|
|
|
JsonDocument doc;
|
|
if (deserializeJson(doc, line) != DeserializationError::Ok) {
|
|
sendError("invalid json");
|
|
return false;
|
|
}
|
|
|
|
const char* cmd = doc["cmd"] | "";
|
|
return processCommand(cmd, doc);
|
|
}
|
|
|
|
bool isBusy() const { return _receivingImage || _receivingNodes; }
|
|
|
|
// True while a profile upload (or key/bootloader op) is in flight or
|
|
// just finished. The main loop uses this to keep live-BLE advertising
|
|
// OFF during USB transfers — NimBLE advertising concurrent with a
|
|
// sustained USB-CDC upload is the radio/CDC contention we must avoid.
|
|
bool isUploadActive() const {
|
|
if (_receivingImage || _receivingNodes) return true;
|
|
return _lastUploadCmdMs != 0 &&
|
|
(millis() - _lastUploadCmdMs) < UPLOAD_QUIET_MS;
|
|
}
|
|
|
|
// True once the host app has talked to us over USB this boot (it pings
|
|
// on connect). It means we're plugged into the configuring computer,
|
|
// so BLE stays off for the rest of the boot (see MacroPad.ino) to keep
|
|
// NimBLE from contending with the USB-CDC pipe during uploads. Latched
|
|
// for the whole boot; cleared only by a power cycle.
|
|
bool isHostConnected() const { return _hostSeen; }
|
|
|
|
bool needsRefresh() {
|
|
bool r = _refreshNeeded;
|
|
_refreshNeeded = false;
|
|
return r;
|
|
}
|
|
|
|
private:
|
|
SettingsManager* _settings;
|
|
MacroStorage* _storage;
|
|
DisplayUI* _display;
|
|
DebugLog* _dlog = nullptr;
|
|
HardwareSerial* _rs232Serial = nullptr;
|
|
VoidCallback _onRS232Reconfig = nullptr;
|
|
BLEKeyStore* _keystore = nullptr;
|
|
BLEManager* _bleManager = nullptr;
|
|
EspNowManager* _mesh = nullptr;
|
|
|
|
// RS232 pass-through terminal state
|
|
bool _rs232TerminalOpen = false;
|
|
uint8_t _rs232TerminalBuf[1024];
|
|
int _rs232TerminalBufPos = 0;
|
|
|
|
// Image receive state
|
|
bool _receivingImage = false;
|
|
int _imgSlot = 0;
|
|
size_t _imgSize = 0;
|
|
size_t _imgReceived = 0;
|
|
bool _imgFirst = true;
|
|
|
|
// Chunk protocol state
|
|
bool _imgChunkActive = false;
|
|
size_t _imgChunkSize = 0;
|
|
size_t _imgChunkRead = 0;
|
|
uint8_t _imgChunkBuf[512];
|
|
|
|
// Node receive state
|
|
bool _receivingNodes = false;
|
|
int _nodeSlot = 0;
|
|
int _nodeCount = 0;
|
|
int _nodesReceived = 0;
|
|
|
|
bool _refreshNeeded = false;
|
|
|
|
// millis() of the last profile-mutating serial command. Drives
|
|
// isUploadActive() so the main loop suspends live-BLE advertising
|
|
// for a short window around USB uploads.
|
|
uint32_t _lastUploadCmdMs = 0;
|
|
static constexpr uint32_t UPLOAD_QUIET_MS = 2000;
|
|
|
|
// Latched true the first time we process any valid command from the
|
|
// host app over USB. Drives isHostConnected().
|
|
bool _hostSeen = false;
|
|
|
|
// Commands that imply the host is actively uploading a profile (or
|
|
// syncing the BLE key / entering the bootloader). During these we
|
|
// want BLE off the radio. Lightweight status pings (ping, get_*,
|
|
// rs232_poll) are intentionally excluded so the toolbar can keep
|
|
// polling without flapping the live link.
|
|
static bool _isUploadCmd(const char* cmd) {
|
|
return strcmp(cmd, "macro_begin") == 0 ||
|
|
strcmp(cmd, "node") == 0 ||
|
|
strcmp(cmd, "macro_end") == 0 ||
|
|
strcmp(cmd, "macro_delete") == 0 ||
|
|
strcmp(cmd, "macro_reorder") == 0 ||
|
|
strcmp(cmd, "sub_begin") == 0 ||
|
|
strcmp(cmd, "sub_node") == 0 ||
|
|
strcmp(cmd, "sub_end") == 0 ||
|
|
strcmp(cmd, "sub_clear") == 0 ||
|
|
strcmp(cmd, "get_ble_key") == 0 ||
|
|
strcmp(cmd, "bootloader") == 0;
|
|
}
|
|
|
|
bool processCommand(const char* cmd, JsonDocument& doc) {
|
|
// Any valid command means the host app is connected over USB — keep
|
|
// BLE off for the rest of this boot.
|
|
_hostSeen = true;
|
|
// Note any profile-mutating / bulk-transfer command so the main
|
|
// loop suspends live-BLE advertising during USB uploads.
|
|
if (_isUploadCmd(cmd)) _lastUploadCmdMs = millis();
|
|
|
|
if (strcmp(cmd, "ping") == 0) {
|
|
return cmdPing();
|
|
} else if (strcmp(cmd, "set") == 0) {
|
|
return cmdSet(doc);
|
|
} else if (strcmp(cmd, "get_settings") == 0) {
|
|
return cmdGetSettings();
|
|
} else if (strcmp(cmd, "macro_begin") == 0) {
|
|
return cmdMacroBegin(doc);
|
|
} else if (strcmp(cmd, "node") == 0) {
|
|
return cmdNode(doc);
|
|
} else if (strcmp(cmd, "macro_end") == 0) {
|
|
return cmdMacroEnd(doc);
|
|
} else if (strcmp(cmd, "macro_delete") == 0) {
|
|
return cmdMacroDelete(doc);
|
|
} else if (strcmp(cmd, "macro_reorder") == 0) {
|
|
return cmdMacroReorder(doc);
|
|
} else if (strcmp(cmd, "bootloader") == 0) {
|
|
return cmdBootloader();
|
|
} else if (strcmp(cmd, "get_log") == 0) {
|
|
return cmdGetLog();
|
|
} else if (strcmp(cmd, "clear_log") == 0) {
|
|
return cmdClearLog();
|
|
} else if (strcmp(cmd, "get_ble_log") == 0) {
|
|
return cmdGetBleLog();
|
|
} else if (strcmp(cmd, "clear_ble_log") == 0) {
|
|
return cmdClearBleLog();
|
|
} else if (strcmp(cmd, "sub_begin") == 0) {
|
|
return cmdSubBegin(doc);
|
|
} else if (strcmp(cmd, "sub_node") == 0) {
|
|
return cmdSubNode(doc);
|
|
} else if (strcmp(cmd, "sub_end") == 0) {
|
|
return cmdSubEnd(doc);
|
|
} else if (strcmp(cmd, "sub_clear") == 0) {
|
|
return cmdSubClear();
|
|
} else if (strcmp(cmd, "rs232_open") == 0) {
|
|
return cmdRs232Open(doc);
|
|
} else if (strcmp(cmd, "rs232_close") == 0) {
|
|
return cmdRs232Close(doc);
|
|
} else if (strcmp(cmd, "rs232_send") == 0) {
|
|
return cmdRs232Send(doc);
|
|
} else if (strcmp(cmd, "rs232_poll") == 0) {
|
|
return cmdRs232Poll(doc);
|
|
} else if (strcmp(cmd, "get_ble_key") == 0) {
|
|
return cmdGetBleKey();
|
|
} else if (strcmp(cmd, "espnow_hub") == 0) {
|
|
return cmdEspnowHub(doc);
|
|
} else if (strcmp(cmd, "mesh_poll") == 0) {
|
|
return cmdMeshPoll(doc);
|
|
} else if (strcmp(cmd, "hub_ping") == 0) {
|
|
return cmdHubPing();
|
|
} else {
|
|
sendError("unknown command");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// ESP-NOW mesh hub bridge
|
|
// =====================================================================
|
|
|
|
// Binary frame from the host (H2D): 0xC8 0x35 | htype | len u16LE |
|
|
// payload | crc16(htype, len, payload). Stateful so a frame split
|
|
// across loop iterations resumes where it left off.
|
|
enum BinState : uint8_t { BIN_IDLE = 0, BIN_MAGIC1, BIN_HDR, BIN_BODY };
|
|
|
|
BinState _binState = BIN_IDLE;
|
|
uint8_t _binHdr[3] = {0};
|
|
int _binHdrPos = 0;
|
|
uint16_t _binLen = 0;
|
|
uint16_t _binPos = 0;
|
|
uint8_t _binBuf[HUB_MAX_FRAME + 2];
|
|
|
|
bool _pumpBinary() {
|
|
uint32_t start = millis();
|
|
while ((millis() - start) < 50) {
|
|
if (!Serial.available()) return false; // resume next loop
|
|
switch (_binState) {
|
|
case BIN_MAGIC1: {
|
|
int c = Serial.read();
|
|
if (c != HUB_MAGIC1) { _binState = BIN_IDLE; return false; }
|
|
_binState = BIN_HDR;
|
|
_binHdrPos = 0;
|
|
break;
|
|
}
|
|
case BIN_HDR: {
|
|
_binHdr[_binHdrPos++] = (uint8_t)Serial.read();
|
|
if (_binHdrPos == 3) {
|
|
_binLen = (uint16_t)_binHdr[1] | ((uint16_t)_binHdr[2] << 8);
|
|
if (_binLen > HUB_MAX_FRAME) {
|
|
_binState = BIN_IDLE; // garbage; resync on next magic
|
|
return false;
|
|
}
|
|
_binPos = 0;
|
|
_binState = BIN_BODY;
|
|
}
|
|
break;
|
|
}
|
|
case BIN_BODY: {
|
|
_binBuf[_binPos++] = (uint8_t)Serial.read();
|
|
if (_binPos == (uint16_t)(_binLen + 2)) { // payload + crc16
|
|
_binState = BIN_IDLE;
|
|
uint16_t want = (uint16_t)_binBuf[_binLen] |
|
|
((uint16_t)_binBuf[_binLen + 1] << 8);
|
|
uint16_t got = hubCrc16(_binHdr, 3);
|
|
got = hubCrc16(_binBuf, _binLen, got);
|
|
if (want != got) return false; // corrupt; drop
|
|
_dispatchBinary(_binHdr[0], _binBuf, _binLen);
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
_binState = BIN_IDLE;
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _dispatchBinary(uint8_t htype, const uint8_t* payload, size_t len) {
|
|
if (htype == HUB_H2D_SEND && _mesh) {
|
|
_mesh->hubSendFromHost(payload, len);
|
|
}
|
|
// Unknown htypes are ignored (forward compatibility).
|
|
}
|
|
|
|
// Device-to-host sink used by EspNowManager (main task only). Wraps
|
|
// the payload in the same framing the host parser expects.
|
|
static void hostSinkStatic(uint8_t htype, const uint8_t* payload,
|
|
size_t len) {
|
|
uint8_t hdr[5] = { HUB_MAGIC0, HUB_MAGIC1, htype,
|
|
(uint8_t)len, (uint8_t)(len >> 8) };
|
|
uint16_t crc = hubCrc16(hdr + 2, 3);
|
|
crc = hubCrc16(payload, len, crc);
|
|
uint8_t tail[2] = { (uint8_t)crc, (uint8_t)(crc >> 8) };
|
|
Serial.write(hdr, sizeof(hdr));
|
|
Serial.write(payload, len);
|
|
Serial.write(tail, 2);
|
|
Serial.flush();
|
|
}
|
|
|
|
bool cmdEspnowHub(JsonDocument& doc) {
|
|
if (!_mesh) { sendError("no mesh"); return false; }
|
|
bool on = doc["on"] | true;
|
|
if (on) {
|
|
// The hub owns the radio: live BLE (if any) must be torn down
|
|
// first. The BLE variables path is unaffected — it only runs
|
|
// inside routines, which a hub never executes.
|
|
if (_bleManager) {
|
|
_bleManager->stopLive();
|
|
_bleManager->shutdown();
|
|
}
|
|
if (!_mesh->hubStart(&SerialProtocol::hostSinkStatic)) {
|
|
sendError("hub start failed");
|
|
return false;
|
|
}
|
|
JsonDocument rsp;
|
|
rsp["rsp"] = "hub";
|
|
rsp["on"] = true;
|
|
rsp["ch"] = _settings->settings.meshChannel;
|
|
{
|
|
uint8_t mac[6] = {0};
|
|
if (esp_efuse_mac_get_default(mac) != ESP_OK) {
|
|
esp_read_mac(mac, ESP_MAC_WIFI_STA);
|
|
}
|
|
char macStr[18];
|
|
snprintf(macStr, sizeof(macStr),
|
|
"%02X:%02X:%02X:%02X:%02X:%02X",
|
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
|
rsp["sta_mac"] = macStr;
|
|
}
|
|
sendJson(rsp);
|
|
_refreshNeeded = true; // repaint: hub screen
|
|
} else {
|
|
_mesh->hubStop();
|
|
sendOk();
|
|
_refreshNeeded = true; // repaint: back to selector
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool cmdMeshPoll(JsonDocument& doc) {
|
|
if (!_mesh) { sendError("no mesh"); return false; }
|
|
bool on = doc["on"] | true;
|
|
_mesh->hubSetPollActive(on);
|
|
_mesh->notifyHostActivity();
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdHubPing() {
|
|
if (_mesh) _mesh->notifyHostActivity();
|
|
JsonDocument rsp;
|
|
rsp["rsp"] = "hub_pong";
|
|
rsp["hub"] = _mesh ? _mesh->isHub() : false;
|
|
rsp["nodes"] = _mesh ? _mesh->hubNodeCount() : 0;
|
|
sendJson(rsp);
|
|
return false;
|
|
}
|
|
|
|
bool cmdPing() {
|
|
JsonDocument rsp;
|
|
rsp["rsp"] = "pong";
|
|
rsp["ver"] = FW_VERSION;
|
|
rsp["id"] = DEVICE_ID;
|
|
rsp["macros"] = _storage->macroCount;
|
|
rsp["free"] = _storage->getFreeSpace();
|
|
// Universal binary: tell the host which board this is so the GUI
|
|
// can adapt (the Lite has no screen) and so flash tooling can
|
|
// print accurate instructions.
|
|
rsp["board"] = (_display && !_display->present())
|
|
? BOARD_NAME_ATOMS3_LITE : BOARD_NAME_ATOMS3;
|
|
// WiFi STA MAC (eFuse base MAC) — the mesh identity. Same bytes as
|
|
// the AES device tag, surfaced directly so the host never has to
|
|
// parse the tag string.
|
|
{
|
|
uint8_t mac[6] = {0};
|
|
if (esp_efuse_mac_get_default(mac) != ESP_OK) {
|
|
esp_read_mac(mac, ESP_MAC_WIFI_STA);
|
|
}
|
|
char macStr[18];
|
|
snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X",
|
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
|
rsp["sta_mac"] = macStr;
|
|
}
|
|
rsp["mesh_ch"] = _settings->settings.meshChannel;
|
|
// Persisted live transport (0 = ESP-NOW mesh, 1 = BLE) so the host
|
|
// can show each device's mode as it's plugged in over USB.
|
|
rsp["live_tx"] = _settings->settings.liveTransport;
|
|
sendJson(rsp);
|
|
return false;
|
|
}
|
|
|
|
bool cmdSet(JsonDocument& doc) {
|
|
const char* key = doc["key"] | "";
|
|
int val = doc["val"] | 0;
|
|
_settings->set(key, val);
|
|
|
|
if (strcmp(key, "orientation") == 0) {
|
|
_display->setOrientation(val);
|
|
}
|
|
|
|
sendOk();
|
|
return true;
|
|
}
|
|
|
|
bool cmdGetSettings() {
|
|
JsonDocument rsp;
|
|
rsp["rsp"] = "settings";
|
|
rsp["hold_ms"] = _settings->settings.holdMs;
|
|
rsp["orientation"] = _settings->settings.orientation;
|
|
rsp["type_delay"] = _settings->settings.typeDelay;
|
|
rsp["resume_delay"] = _settings->settings.resumeDelay;
|
|
rsp["combo_pre_ms"] = _settings->settings.comboPreMs;
|
|
rsp["combo_post_ms"] = _settings->settings.comboPostMs;
|
|
rsp["probe_timeout_ms"] = _settings->settings.probeTimeoutMs;
|
|
rsp["media_hold_ms"] = _settings->settings.mediaHoldMs;
|
|
rsp["type_shift_extra_ms"] = _settings->settings.typeShiftExtraMs;
|
|
rsp["type_settle_ms"] = _settings->settings.typeSettleMs;
|
|
rsp["type_hold_min_ms"] = _settings->settings.typeHoldMinMs;
|
|
rsp["type_inter_char_ms"] = _settings->settings.typeInterCharMs;
|
|
rsp["pause_margin_left"] = _settings->settings.pauseMarginLeft;
|
|
rsp["pause_margin_right"] = _settings->settings.pauseMarginRight;
|
|
rsp["pause_margin_top"] = _settings->settings.pauseMarginTop;
|
|
rsp["pause_margin_bottom"] = _settings->settings.pauseMarginBottom;
|
|
sendJson(rsp);
|
|
return false;
|
|
}
|
|
|
|
bool cmdMacroBegin(JsonDocument& doc) {
|
|
int slot = doc["slot"] | 0;
|
|
const char* name = doc["name"] | "Unnamed";
|
|
const char* labelColor = doc["label_color"] | "white";
|
|
int nodeCount = doc["node_count"] | 0;
|
|
size_t imgSize = doc["img_size"] | 0;
|
|
|
|
if (!_storage->beginMacroWrite(slot, name, nodeCount, labelColor)) {
|
|
sendError("write failed");
|
|
return false;
|
|
}
|
|
|
|
_nodeSlot = slot;
|
|
_nodeCount = nodeCount;
|
|
_nodesReceived = 0;
|
|
|
|
if (imgSize > 0) {
|
|
_receivingImage = true;
|
|
_imgSlot = slot;
|
|
_imgSize = imgSize;
|
|
_imgReceived = 0;
|
|
_imgFirst = true;
|
|
_imgChunkActive = false;
|
|
_imgChunkSize = 0;
|
|
_imgChunkRead = 0;
|
|
}
|
|
|
|
sendReady();
|
|
return false;
|
|
}
|
|
|
|
bool receiveImageData() {
|
|
// Chunk+ACK protocol: host sends chunk size as text line first,
|
|
// then binary data, we ACK after writing each chunk.
|
|
if (!_imgChunkActive) {
|
|
// Read the chunk header line (e.g. "CHUNK:128\n")
|
|
if (!Serial.available()) return false;
|
|
String line = Serial.readStringUntil('\n');
|
|
line.trim();
|
|
if (line.startsWith("CHUNK:")) {
|
|
_imgChunkSize = line.substring(6).toInt();
|
|
if (_imgChunkSize <= 0 || _imgChunkSize > 512) {
|
|
sendError("bad chunk size");
|
|
_receivingImage = false;
|
|
return false;
|
|
}
|
|
_imgChunkRead = 0;
|
|
_imgChunkActive = true;
|
|
} else if (line == "IMG_DONE") {
|
|
// Transfer complete
|
|
_receivingImage = false;
|
|
_storage->macros[_imgSlot].hasImage = true;
|
|
Serial.println("{\"rsp\":\"img_ok\"}");
|
|
Serial.flush();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Read binary chunk data byte-by-byte in a tight loop
|
|
unsigned long start = millis();
|
|
while (_imgChunkRead < _imgChunkSize && (millis() - start) < 2000) {
|
|
if (Serial.available()) {
|
|
_imgChunkBuf[_imgChunkRead++] = Serial.read();
|
|
}
|
|
}
|
|
|
|
if (_imgChunkRead >= _imgChunkSize) {
|
|
_storage->writeImageChunk(_imgSlot, _imgChunkBuf, _imgChunkSize, _imgFirst);
|
|
_imgFirst = false;
|
|
_imgReceived += _imgChunkSize;
|
|
_imgChunkActive = false;
|
|
|
|
Serial.println("OK");
|
|
Serial.flush();
|
|
}
|
|
// On timeout we stay in chunk-active mode and resume next loop
|
|
return false;
|
|
}
|
|
|
|
bool cmdNode(JsonDocument& doc) {
|
|
int idx = doc["idx"] | _nodesReceived;
|
|
|
|
// Strip down to just the node fields we want to persist
|
|
JsonDocument nodeDoc;
|
|
nodeDoc["type"] = doc["type"];
|
|
nodeDoc["data"] = doc["data"];
|
|
|
|
String nodeStr;
|
|
serializeJson(nodeDoc, nodeStr);
|
|
|
|
bool last = (idx >= _nodeCount - 1);
|
|
_storage->appendNode(_nodeSlot, nodeStr.c_str(), last);
|
|
_nodesReceived++;
|
|
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdMacroEnd(JsonDocument& doc) {
|
|
int slot = doc["slot"] | _nodeSlot;
|
|
_storage->finalizeMacro(slot);
|
|
_refreshNeeded = true;
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdMacroDelete(JsonDocument& doc) {
|
|
int slot = doc["slot"] | 0;
|
|
_storage->deleteMacro(slot);
|
|
_refreshNeeded = true;
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdBootloader() {
|
|
// Acknowledge before disappearing so the host knows the command landed
|
|
sendOk();
|
|
delay(100);
|
|
// Tear down TinyUSB, route USB PHY back to USB-Serial/JTAG,
|
|
// set FORCE_DOWNLOAD_BOOT flag, then restart into ROM download mode.
|
|
// esptool must use --before no-reset to connect after this.
|
|
usb_persist_restart(RESTART_BOOTLOADER);
|
|
return false; // unreachable
|
|
}
|
|
|
|
bool cmdMacroReorder(JsonDocument& doc) {
|
|
JsonArray orderArr = doc["order"].as<JsonArray>();
|
|
int newOrder[MAX_MACROS];
|
|
int count = 0;
|
|
for (JsonVariant v : orderArr) {
|
|
if (count < MAX_MACROS) {
|
|
newOrder[count++] = v.as<int>();
|
|
}
|
|
}
|
|
_storage->reorder(newOrder, count);
|
|
_refreshNeeded = true;
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdGetLog() {
|
|
if (_dlog) _dlog->sendOverSerial();
|
|
else Serial.println("{\"rsp\":\"log\",\"entries\":[]}");
|
|
Serial.flush();
|
|
return false;
|
|
}
|
|
|
|
bool cmdClearLog() {
|
|
if (_dlog) _dlog->clear();
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdGetBleLog() {
|
|
if (_bleManager) _bleManager->dbg.dumpJson();
|
|
else Serial.println("{\"rsp\":\"ble_log\",\"entries\":[]}");
|
|
Serial.flush();
|
|
return false;
|
|
}
|
|
|
|
bool cmdClearBleLog() {
|
|
if (_bleManager) _bleManager->dbg.clear();
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdGetBleKey() {
|
|
if (!_keystore || !_keystore->hasKey()) {
|
|
sendError("no ble key");
|
|
return false;
|
|
}
|
|
char hex[BLEKeyStore::KEY_LEN * 2 + 1];
|
|
const uint8_t* k = _keystore->key();
|
|
for (size_t i = 0; i < BLEKeyStore::KEY_LEN; i++) {
|
|
sprintf(hex + i * 2, "%02x", k[i]);
|
|
}
|
|
hex[BLEKeyStore::KEY_LEN * 2] = '\0';
|
|
JsonDocument rsp;
|
|
rsp["rsp"] = "ble_key";
|
|
rsp["key"] = hex;
|
|
// Also expose the device tag so the host can store keys
|
|
// per-device. Without this, uploading a profile to a second
|
|
// M5Stack overwrites the first device's key on the host and
|
|
// the user has to re-upload to switch between them.
|
|
if (_bleManager) {
|
|
rsp["tag"] = _bleManager->deviceTag();
|
|
}
|
|
sendJson(rsp);
|
|
return false;
|
|
}
|
|
|
|
// --- Sub-routine commands ---
|
|
int _subSlot = 0;
|
|
int _subNodeCount = 0;
|
|
int _subNodesReceived = 0;
|
|
|
|
bool cmdSubBegin(JsonDocument& doc) {
|
|
int slot = doc["slot"] | 0;
|
|
const char* name = doc["name"] | "Unnamed";
|
|
int nodeCount = doc["node_count"] | 0;
|
|
if (!_storage->beginSubWrite(slot, name, nodeCount)) {
|
|
sendError("sub write failed");
|
|
return false;
|
|
}
|
|
_subSlot = slot;
|
|
_subNodeCount = nodeCount;
|
|
_subNodesReceived = 0;
|
|
sendReady();
|
|
return false;
|
|
}
|
|
|
|
bool cmdSubNode(JsonDocument& doc) {
|
|
if (_subNodesReceived >= _subNodeCount) {
|
|
sendError("too many sub nodes");
|
|
return false;
|
|
}
|
|
JsonDocument nodeDoc;
|
|
nodeDoc["type"] = doc["type"];
|
|
nodeDoc["data"] = doc["data"];
|
|
String nodeStr;
|
|
serializeJson(nodeDoc, nodeStr);
|
|
bool last = (_subNodesReceived >= _subNodeCount - 1);
|
|
_storage->appendSubNode(_subSlot, nodeStr.c_str(), last);
|
|
_subNodesReceived++;
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdSubEnd(JsonDocument& doc) {
|
|
// Verify we got all the nodes the host promised. Missing nodes
|
|
// would leave the tmp file with a trailing comma instead of a
|
|
// closing bracket, which finalizeSubWrite's JSON validation
|
|
// catches anyway — but failing early gives a cleaner error.
|
|
//
|
|
// IMPORTANT: do NOT Serial.printf debug text here. The USB CDC
|
|
// pipe is shared with the JSON response stream, and any non-JSON
|
|
// line on this pipe gets fed to the host's readline() instead of
|
|
// the {"rsp":...} response, which trips json.JSONDecodeError on
|
|
// the host and tears down the serial connection. Diagnostics for
|
|
// upload failures must travel back to the host via the sendError
|
|
// payload, not via Serial.
|
|
int slot = doc["slot"] | _subSlot;
|
|
if (_subNodesReceived != _subNodeCount) {
|
|
// Drop the tmp so the next loadSubNodes still finds the last
|
|
// good live file instead of a stale partial.
|
|
char tmpPath[48];
|
|
snprintf(tmpPath, sizeof(tmpPath), "/sub/s%d/nodes.tmp", slot);
|
|
if (LittleFS.exists(tmpPath)) LittleFS.remove(tmpPath);
|
|
sendError("sub_end node-count mismatch");
|
|
return false;
|
|
}
|
|
if (!_storage->finalizeSubWrite(slot)) {
|
|
sendError("sub_end finalize failed");
|
|
return false;
|
|
}
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdSubClear() {
|
|
_storage->clearAllSubs();
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
// =====================================================================
|
|
// RS232 pass-through (for the host-side terminal)
|
|
// =====================================================================
|
|
|
|
bool cmdRs232Open(JsonDocument& doc) {
|
|
if (!_rs232Serial) {
|
|
sendError("no rs232 configured");
|
|
return false;
|
|
}
|
|
int baud = doc["baud"] | 9600;
|
|
int dataBits = doc["data_bits"] | 8;
|
|
const char* stopBits = doc["stop_bits"] | "1";
|
|
const char* parity = doc["parity"] | "none";
|
|
|
|
uint32_t config = computeRS232Config(dataBits, parity, stopBits);
|
|
|
|
_rs232Serial->end();
|
|
_rs232Serial->begin((unsigned long)baud, config, RS232_RX_PIN, RS232_TX_PIN);
|
|
delay(30);
|
|
|
|
_rs232TerminalOpen = true;
|
|
_rs232TerminalBufPos = 0;
|
|
|
|
// Invalidate any RS232 node's cached config so a later macro run
|
|
// re-initializes the port with its own settings.
|
|
if (_onRS232Reconfig) _onRS232Reconfig();
|
|
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdRs232Close(JsonDocument& doc) {
|
|
_rs232TerminalOpen = false;
|
|
_rs232TerminalBufPos = 0;
|
|
// Don't end() the port — the engine may want to use it next.
|
|
if (_onRS232Reconfig) _onRS232Reconfig();
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdRs232Send(JsonDocument& doc) {
|
|
if (!_rs232Serial || !_rs232TerminalOpen) {
|
|
sendError("not open");
|
|
return false;
|
|
}
|
|
|
|
// Support either a hex-encoded payload (safe for any byte value)
|
|
// or a plain ASCII string in "data". Hex wins if both are present.
|
|
const char* hex = doc["hex"] | "";
|
|
if (hex[0] != '\0') {
|
|
// Parse pairs of hex digits, tolerating whitespace
|
|
int n = 0;
|
|
char pair[3] = {0, 0, 0};
|
|
int pairIdx = 0;
|
|
while (*hex && n < 512) {
|
|
char c = *hex++;
|
|
if (c == ' ' || c == '\t' || c == ',' || c == '\n' || c == '\r') continue;
|
|
pair[pairIdx++] = c;
|
|
if (pairIdx == 2) {
|
|
pair[2] = 0;
|
|
uint8_t b = (uint8_t)strtol(pair, nullptr, 16);
|
|
_rs232Serial->write(b);
|
|
pairIdx = 0;
|
|
n++;
|
|
}
|
|
}
|
|
} else {
|
|
const char* data = doc["data"] | "";
|
|
_rs232Serial->print(data);
|
|
}
|
|
_rs232Serial->flush();
|
|
sendOk();
|
|
return false;
|
|
}
|
|
|
|
bool cmdRs232Poll(JsonDocument& doc) {
|
|
JsonDocument rsp;
|
|
rsp["rsp"] = "rx";
|
|
rsp["n"] = _rs232TerminalBufPos;
|
|
if (_rs232TerminalBufPos > 0) {
|
|
// Encode buffer as hex (2 chars per byte + null terminator)
|
|
static char hexBuf[sizeof(_rs232TerminalBuf) * 2 + 1];
|
|
int n = _rs232TerminalBufPos;
|
|
if (n > (int)(sizeof(hexBuf) - 1) / 2) n = (sizeof(hexBuf) - 1) / 2;
|
|
static const char* HEX_DIGITS = "0123456789abcdef";
|
|
for (int i = 0; i < n; i++) {
|
|
uint8_t v = _rs232TerminalBuf[i];
|
|
hexBuf[i * 2] = HEX_DIGITS[v >> 4];
|
|
hexBuf[i * 2 + 1] = HEX_DIGITS[v & 0x0F];
|
|
}
|
|
hexBuf[n * 2] = 0;
|
|
rsp["hex"] = hexBuf;
|
|
} else {
|
|
rsp["hex"] = "";
|
|
}
|
|
// Clear the buffer now that we've reported it
|
|
_rs232TerminalBufPos = 0;
|
|
sendJson(rsp);
|
|
return false;
|
|
}
|
|
|
|
void sendJson(JsonDocument& doc) {
|
|
String out;
|
|
serializeJson(doc, out);
|
|
Serial.println(out);
|
|
Serial.flush();
|
|
}
|
|
|
|
void sendOk() {
|
|
Serial.println("{\"rsp\":\"ok\"}");
|
|
Serial.flush();
|
|
}
|
|
|
|
void sendReady() {
|
|
Serial.println("{\"rsp\":\"ready\"}");
|
|
Serial.flush();
|
|
}
|
|
|
|
void sendError(const char* msg) {
|
|
JsonDocument doc;
|
|
doc["rsp"] = "error";
|
|
doc["msg"] = msg;
|
|
String out;
|
|
serializeJson(doc, out);
|
|
Serial.println(out);
|
|
Serial.flush();
|
|
}
|
|
};
|