#pragma once #include #include #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(); int newOrder[MAX_MACROS]; int count = 0; for (JsonVariant v : orderArr) { if (count < MAX_MACROS) { newOrder[count++] = v.as(); } } _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(); } };