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

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();
}
};