#pragma once #include #include #include "config.h" #include "usb_hid.h" #include "display_ui.h" #include "macro_storage.h" #include "ble_manager.h" #include "settings.h" #include "rs232_util.h" enum EngineState { ENGINE_IDLE, ENGINE_EXECUTING, ENGINE_PAUSED, ENGINE_BRANCHING, ENGINE_DELAY, ENGINE_BLE_WAITING, // waiting for variables to arrive (with timeout) ENGINE_BLE_WAIT, // variables never arrived, waiting for user button press to skip ENGINE_RS232_WAITING, // waiting for expected RS232 response (with timeout) ENGINE_LOOP_SELECTING, // waiting for user to pick a count via the Loop Selector node ENGINE_LOOP_START_SELECTING, // waiting for user to pick the starting iteration (phase 2) ENGINE_GET_LOCAL_FAIL, // get_local failed twice — FAIL screen up, 10s countdown ENGINE_GET_LOCAL_FAIL_PAUSED // user tapped main button — countdown frozen, wait for retry tap }; class MacroEngine { public: EngineState state = ENGINE_IDLE; void begin(HIDController* hid, DisplayUI* display, MacroStorage* storage, SettingsManager* settingsMgr = nullptr, HardwareSerial* rs232 = nullptr) { _hid = hid; _display = display; _storage = storage; _settingsMgr = settingsMgr; _rs232Serial = rs232; } void setBLEManager(BLEManager* ble) { _bleManager = ble; } bool startMacro(int slot, const char* name) { if (state != ENGINE_IDLE) return false; _nodesDoc.clear(); if (!_storage->loadNodes(slot, _nodesDoc)) return false; // Wipe any stale resume-state files left behind by a previous run // whose boot-time resume was cancelled or failed. This guarantees // the first save of THIS run starts from seq=0 in slot A and is // never out-ranked by orphaned higher-seq files from before. clearExecutionState(); _nodes = _nodesDoc.as(); _nodeCount = _nodes.size(); _currentNode = 0; _currentSlot = slot; strlcpy(_macroName, name, sizeof(_macroName)); _repeatCounters[0] = 0; _repeatDepth = 0; for (int i = 0; i < MAX_LOOPS; i++) _loopIterations[i] = 0; // Reset loop-selector commit flags so any selector on this run starts // fresh (prevents a prior run's value from leaking into a new one). _loopSelectorSet = false; _loopSelectorStartSet = false; Serial.printf("[ENGINE] startMacro slot=%d name=%s nodes=%d\n", slot, name, _nodeCount); for (int i = 0; i < _nodeCount; i++) { const char* t = _nodes[i]["type"] | "?"; Serial.printf("[ENGINE] node[%d] type='%s'\n", i, t); } _display->showExecuting(name); state = ENGINE_EXECUTING; return true; } void stop() { state = ENGINE_IDLE; _hid->keyboard.releaseAll(); _hid->mouse.release(MOUSE_ALL); clearExecutionState(); _callDepth = 0; _inSubroutine = false; _currentSubSlot = -1; _repeatDepth = 0; _branchSelected = 0; _branchCount = 0; _branchScroll = 0; _rs232BufPos = 0; _rs232Buf[0] = '\0'; _rs232Expected[0] = '\0'; _numLockCheckAttempt = 0; _loopSelectorSet = false; _loopSelectorStartSet = false; _failNodeIdx = -1; _failContinueTarget = -1; } // Called every main-loop iteration void tick(uint8_t typeDelay) { if (state == ENGINE_IDLE) return; if (state == ENGINE_DELAY) { if (millis() >= _waitUntil) { _currentNode++; state = ENGINE_EXECUTING; } else { // Update progress bar display ~10 times per second to avoid flicker uint32_t now = millis(); if (now - _delayLastDisplayUpdate >= 100) { _delayLastDisplayUpdate = now; uint32_t elapsed = now - _delayStartMs; uint32_t remain = (_delayTotalMs > elapsed) ? (_delayTotalMs - elapsed) : 0; _display->showDelayProgress(_delayTotalMs, remain); } } return; } if (state == ENGINE_PAUSED) { if (_pauseTimed) { if (millis() >= _waitUntil) { _currentNode++; state = ENGINE_EXECUTING; } else { updatePauseDisplay(); } } return; } if (state == ENGINE_BRANCHING) { // Handled externally via onButtonClick() and onButtonHold() return; } if (state == ENGINE_LOOP_SELECTING) { // Handled externally via onButtonClick() (cycle) and onButtonHold() (confirm) return; } if (state == ENGINE_LOOP_START_SELECTING) { // Same pattern — phase 2 of the loop selector (start iteration picker) return; } if (state == ENGINE_GET_LOCAL_FAIL) { uint32_t now = millis(); if (now >= _waitUntil) { // Timeout — take the Fail wire. Serial.println("[ENGINE] get_local: FAIL timeout -> fail path"); _currentNode = _failContinueTarget; state = ENGINE_EXECUTING; } else if (now - _failLastDisplayUpdate >= 200) { _failLastDisplayUpdate = now; _display->showFailScreen(_waitUntil - now, _failTotalMs, false); } return; } if (state == ENGINE_GET_LOCAL_FAIL_PAUSED) { // Frozen — countdown does not advance. Wait for second button // press (handled in onButtonClick) which re-enters EXECUTING and // replays the get_local node from scratch. return; } if (state == ENGINE_BLE_WAITING) { // Polling for the exchange the device kicked off (pull / push / // request) to complete. ``isExchangeDone()`` is set by the BLE // manager when the matching reply has been received and applied. if (_bleManager) _bleManager->pollStatus(); if (_bleManager && _bleManager->isExchangeDone()) { _bleManager->shutdown(); _display->showBLEStatus("Vars ready!"); delay(400); _currentNode++; _bleNoTimeout = false; state = ENGINE_EXECUTING; } else if (!_bleNoTimeout && millis() >= _waitUntil) { if (_bleManager) _bleManager->shutdown(); _display->showBLEStatus("No vars. Click>"); _bleNoTimeout = false; state = ENGINE_BLE_WAIT; } return; } if (state == ENGINE_BLE_WAIT) { // Waiting for user short-press to continue after BLE failure return; } if (state == ENGINE_RS232_WAITING) { // Accumulate incoming RS232 data and check for expected response if (_rs232Serial && _rs232Serial->available()) { while (_rs232Serial->available() && _rs232BufPos < RS232_BUF_SIZE - 1) { _rs232Buf[_rs232BufPos++] = _rs232Serial->read(); _rs232Buf[_rs232BufPos] = '\0'; } // Check if expected response is found in accumulated buffer if (_rs232Expected[0] != '\0' && strstr(_rs232Buf, _rs232Expected) != nullptr) { Serial.printf("[ENGINE] RS232: got expected response\n"); _currentNode++; state = ENGINE_EXECUTING; return; } } uint32_t now = millis(); if (now >= _waitUntil) { Serial.printf("[ENGINE] RS232: timeout waiting for response\n"); _currentNode++; state = ENGINE_EXECUTING; } else { // Update RS232 waiting display ~5 times per second if (now - _delayLastDisplayUpdate >= 200) { _delayLastDisplayUpdate = now; uint32_t remain = _waitUntil - now; _display->showRS232Waiting(_rs232Expected, remain, _rs232BufPos); } } return; } if (state == ENGINE_EXECUTING) { if (_currentNode >= _nodeCount) { // End of body — if we're inside a sub-routine call, return to caller if (_callDepth > 0) { _callDepth--; CallFrame& frame = _callStack[_callDepth]; // Reload the caller's nodes from LittleFS _nodesDoc.clear(); bool loaded = false; if (frame.returnSlot >= 0) { // Returning to a main macro loaded = _storage->loadNodes(frame.returnSlot, _nodesDoc); _currentSlot = frame.returnSlot; _inSubroutine = false; _currentSubSlot = -1; } else { // Returning to another sub-routine (nested call) loaded = _storage->loadSubNodes(frame.returnSubSlot, _nodesDoc); _currentSubSlot = frame.returnSubSlot; _inSubroutine = true; } if (!loaded) { Serial.println("[ENGINE] Failed to reload caller nodes, stopping"); stop(); return; } _nodes = _nodesDoc.as(); _nodeCount = _nodes.size(); _currentNode = frame.returnNode; Serial.printf("[ENGINE] Returned from sub-routine (depth=%d, resuming node=%d/%d)\n", _callDepth, _currentNode, _nodeCount); return; } stop(); return; } executeCurrentNode(typeDelay); } } // Button handlers during execution void onButtonClick() { if (state == ENGINE_BLE_WAITING || state == ENGINE_BLE_WAIT) { if (_bleManager) _bleManager->shutdown(); // user skipped — kill BLE _currentNode++; _bleNoTimeout = false; state = ENGINE_EXECUTING; return; } else if (state == ENGINE_PAUSED) { _currentNode++; state = ENGINE_EXECUTING; } else if (state == ENGINE_BRANCHING) { _branchSelected = (_branchSelected + 1) % _branchCount; // Keep selection inside the visible window. With itemH=24 and // SCREEN_H=128 the selector shows 5 rows at a time. const int visible = 128 / 24; if (_branchSelected < _branchScroll) { _branchScroll = _branchSelected; } else if (_branchSelected >= _branchScroll + visible) { _branchScroll = _branchSelected - visible + 1; } // Wrap-around: when selection cycles back to 0, snap viewport too. if (_branchSelected == 0) _branchScroll = 0; updateBranchDisplay(); } else if (state == ENGINE_LOOP_SELECTING || state == ENGINE_LOOP_START_SELECTING) { // Cycle through values with wrap-around at max _loopSelectorCurrent += _loopSelectorStep; if (_loopSelectorCurrent > _loopSelectorMax) { _loopSelectorCurrent = _loopSelectorMin; } if (state == ENGINE_LOOP_SELECTING) { updateLoopSelectorDisplay(); } else { updateLoopStartSelectorDisplay(); } } else if (state == ENGINE_GET_LOCAL_FAIL) { // First tap during FAIL countdown: freeze the timer and show // PAUSED. Snapshot remaining ms so a future un-pause path could // resume it; for now a second tap just retries from scratch. uint32_t now = millis(); _failRemainSnapshot = (now < _waitUntil) ? (_waitUntil - now) : 0; Serial.printf("[ENGINE] get_local FAIL: paused (%u ms left)\n", (unsigned)_failRemainSnapshot); state = ENGINE_GET_LOCAL_FAIL_PAUSED; _display->showFailScreen(_failRemainSnapshot, _failTotalMs, true); } else if (state == ENGINE_GET_LOCAL_FAIL_PAUSED) { // Second tap: re-run the same get_local node from scratch. Serial.println("[ENGINE] get_local FAIL: user retry -> replay node"); _currentNode = _failNodeIdx; state = ENGINE_EXECUTING; } } void onButtonHold() { if (state == ENGINE_BRANCHING) { JsonObject node = _nodes[_currentNode].as(); JsonArray choices = node["data"]["choices"].as(); int target = choices[_branchSelected]["next"] | (_currentNode + 1); _currentNode = target; state = ENGINE_EXECUTING; } else if (state == ENGINE_LOOP_SELECTING) { _loopSelectorValue = _loopSelectorCurrent; _loopSelectorSet = true; Serial.printf("[ENGINE] loop_selector committed: %d\n", _loopSelectorValue); if (_loopSelectorAskStart) { // Phase 2: prompt for which iteration to start at (1..count) _loopSelectorMin = 1; _loopSelectorMax = _loopSelectorValue; _loopSelectorStep = 1; _loopSelectorCurrent = 1; strlcpy(_loopSelectorPrompt, "Start at?", sizeof(_loopSelectorPrompt)); state = ENGINE_LOOP_START_SELECTING; updateLoopStartSelectorDisplay(); } else { // No second phase — clear any stale start setting and advance _loopSelectorStart = 1; _loopSelectorStartSet = false; _currentNode++; state = ENGINE_EXECUTING; } } else if (state == ENGINE_LOOP_START_SELECTING) { _loopSelectorStart = _loopSelectorCurrent; _loopSelectorStartSet = true; Serial.printf("[ENGINE] loop_selector start committed: %d (of %d)\n", _loopSelectorStart, _loopSelectorValue); _currentNode++; state = ENGINE_EXECUTING; } } bool isRunning() const { return state != ENGINE_IDLE; } // Called by SerialProtocol when the host-side terminal reconfigures the // RS232 port behind our back, so the next rs232 macro node re-initializes. void resetRS232Cache() { _rs232LastBaud = 0; _rs232LastConfig = 0; } // --- Execution state persistence for power-loss recovery --- // ---- Resume-state persistence (double-buffered, CRC-checked) ---- // // Failure model: the M5Stack runs through a KVM that cuts USB power when // told to switch input via RS232. Power can vanish at any byte boundary // of any save, so the on-disk format must NEVER have a window where zero // valid resume files exist. // // Scheme: // /resume.a.json — copy A of the saved state (header + JSON) // /resume.b.json — copy B // /resume.idx — 1 byte: 'A' or 'B' — which copy is freshest // // Each file is written as: // <8 hex CRC32> // CRC covers the JSON bytes only. Read side verifies CRC before parsing. // // Save flow: // 1. Decide which slot is INACTIVE (the one we won't clobber). // 2. Open that slot file for write, emit "CRC\nJSON", flush, close. // Power loss here leaves the other slot file untouched — recovery // reads it via the sentinel. // 3. Flip the sentinel to point at the slot we just wrote. // Sentinel write is also tmp+rename (1 byte), so a half-written // sentinel is rejected and the reader falls back to "highest-seq // file that CRC-matches" across both slots. // // Read flow (checkSavedState): // - Try sentinel; if it names a slot whose file parses + CRC-matches, // use it. // - Otherwise, scan both A and B; pick the highest-seq one that // parses + CRC-matches. This handles every partial-write failure // mode (mid-payload, mid-sentinel, missing sentinel). static uint32_t _crc32(const uint8_t* data, size_t len) { uint32_t crc = 0xFFFFFFFFu; for (size_t i = 0; i < len; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { crc = (crc >> 1) ^ (0xEDB88320u & (-(int32_t)(crc & 1))); } } return ~crc; } // Read the on-disk sentinel byte. Returns 'A', 'B', or 0 if missing/garbage. static char _readSentinel() { if (!LittleFS.exists(RESUME_STATE_IDX_PATH)) return 0; File f = LittleFS.open(RESUME_STATE_IDX_PATH, "r"); if (!f) return 0; int c = f.read(); f.close(); if (c == 'A' || c == 'B') return (char)c; return 0; } // Write the sentinel byte atomically. We write to a .tmp then rename; // if the rename doesn't overwrite the destination, we fall back to // remove + rename — but unlike the payload file the sentinel is 1 byte // and if it's briefly missing the reader recovers via the dual-file scan. static void _writeSentinel(char slot) { const char* tmpPath = "/resume.idx.tmp"; if (LittleFS.exists(tmpPath)) LittleFS.remove(tmpPath); File f = LittleFS.open(tmpPath, "w"); if (!f) return; f.write((uint8_t)slot); f.flush(); f.close(); // Try the overwrite-rename first; LittleFS in newer Arduino cores // supports it. If it fails, remove + rename. The "no sentinel" // window between these two ops is harmless: checkSavedState falls // back to the dual-file scan. if (!LittleFS.rename(tmpPath, RESUME_STATE_IDX_PATH)) { if (LittleFS.exists(RESUME_STATE_IDX_PATH)) { LittleFS.remove(RESUME_STATE_IDX_PATH); } LittleFS.rename(tmpPath, RESUME_STATE_IDX_PATH); } } // Try to read+verify one resume-state slot file. On success, parses the // payload into ``doc`` and returns the file's seq value. Returns 0 on // any failure (missing, short, CRC mismatch, JSON parse error). static uint32_t _readSlotFile(const char* path, JsonDocument& doc) { if (!LittleFS.exists(path)) return 0; File f = LittleFS.open(path, "r"); if (!f) return 0; // Header is exactly 9 bytes: 8 hex digits + LF. char header[10]; size_t n = f.readBytes(header, 9); if (n != 9 || header[8] != '\n') { f.close(); return 0; } header[8] = '\0'; uint32_t expectedCrc = strtoul(header, nullptr, 16); String body = f.readString(); f.close(); if (body.length() == 0) return 0; uint32_t actualCrc = _crc32((const uint8_t*)body.c_str(), body.length()); if (actualCrc != expectedCrc) return 0; if (deserializeJson(doc, body) != DeserializationError::Ok) return 0; uint32_t seq = doc["seq"] | 0u; return seq ? seq : 1; // seq=0 is technically valid; map to 1 to mean "found" } // Write one slot file with header+JSON payload. Sets doc["seq"] to the // monotonic counter before writing so a later reader can compare. bool _writeSlotFile(const char* path, JsonDocument& doc) { ++_resumeSeq; doc["seq"] = _resumeSeq; String body; serializeJson(doc, body); uint32_t crc = _crc32((const uint8_t*)body.c_str(), body.length()); File f = LittleFS.open(path, "w"); if (!f) { Serial.printf("[RESUME] save: failed to open %s for write\n", path); return false; } char header[10]; snprintf(header, sizeof(header), "%08X\n", crc); size_t hw = f.write((const uint8_t*)header, 9); size_t bw = f.write((const uint8_t*)body.c_str(), body.length()); f.flush(); f.close(); if (hw != 9 || bw != body.length()) { Serial.printf("[RESUME] save: short write to %s (hdr %u/9, body %u/%u)\n", path, (unsigned)hw, (unsigned)bw, (unsigned)body.length()); return false; } Serial.printf("[RESUME] save: wrote %s seq=%u crc=%08X bytes=%u\n", path, (unsigned)_resumeSeq, (unsigned)crc, (unsigned)body.length()); return true; } void saveExecutionState() { JsonDocument doc; doc["active"] = true; doc["slot"] = _currentSlot; if (_inSubroutine && _callDepth > 0) { // Inside a sub-routine: save the parent's return node so we // resume from the subroutine call (re-running the sub is safe; // resuming mid-sub without the full call stack is not). CallFrame& bottom = _callStack[0]; // outermost caller doc["node"] = bottom.returnNode - 1; // the subroutine call node itself } else { doc["node"] = _currentNode; } // Persist loop selector state so any downstream Loop still uses // the previously-chosen count (and start iteration) after a power loss. doc["loop_sel"] = _loopSelectorValue; doc["loop_sel_set"] = _loopSelectorSet; doc["loop_sel_start"] = _loopSelectorStart; doc["loop_sel_start_set"] = _loopSelectorStartSet; // Persist repeat stack + per-loop iteration counters so iteration_branch // still resolves to the correct path after a power-loss resume. doc["repeat_depth"] = _repeatDepth; JsonArray rc = doc["repeat_counters"].to(); JsonArray rt = doc["repeat_targets"].to(); JsonArray rlid = doc["repeat_loop_ids"].to(); for (int i = 0; i < 8; i++) { rc.add(_repeatCounters[i]); rt.add(_repeatTargets[i]); rlid.add(_repeatLoopIds[i]); } JsonArray li = doc["loop_iters"].to(); for (int i = 0; i < MAX_LOOPS; i++) { li.add(_loopIterations[i]); } // Pick the INACTIVE slot. First save of a fresh boot reads the // sentinel on disk to avoid clobbering the only good file. if (_resumeActiveSlot != 'A' && _resumeActiveSlot != 'B') { char onDisk = _readSentinel(); _resumeActiveSlot = (onDisk == 'A') ? 'A' : (onDisk == 'B' ? 'B' : 0); } char writeSlot = (_resumeActiveSlot == 'A') ? 'B' : 'A'; const char* path = (writeSlot == 'A') ? RESUME_STATE_A_PATH : RESUME_STATE_B_PATH; if (!_writeSlotFile(path, doc)) { // Payload write failed — do NOT flip the sentinel, leaving the // previous good copy authoritative. return; } // Flip the sentinel to point at the slot we just wrote. From here // on, _resumeActiveSlot tracks what we last committed. _writeSentinel(writeSlot); _resumeActiveSlot = writeSlot; } void clearExecutionState() { if (LittleFS.exists(RESUME_STATE_IDX_PATH)) LittleFS.remove(RESUME_STATE_IDX_PATH); if (LittleFS.exists(RESUME_STATE_A_PATH)) LittleFS.remove(RESUME_STATE_A_PATH); if (LittleFS.exists(RESUME_STATE_B_PATH)) LittleFS.remove(RESUME_STATE_B_PATH); // Sweep legacy single-file path left by older firmware. if (LittleFS.exists(RESUME_STATE_PATH)) LittleFS.remove(RESUME_STATE_PATH); if (LittleFS.exists("/resume.tmp")) LittleFS.remove("/resume.tmp"); _resumeActiveSlot = 0; _resumeSeq = 0; } bool checkSavedState(int& slot, int& nodeIdx) { JsonDocument doc; uint32_t pickedSeq = 0; char pickedSlot = 0; // Always scan BOTH slots and pick the highest-seq valid file. The // sentinel is read only for the diagnostic log; it is NOT load- // bearing. Why: consider the sequence // // save N — writes A, flips sentinel to 'A' (sentinel=A, A=seq=N) // save N+1 — writes B fully, CRASH before flipping (sentinel=A, A=seq=N, B=seq=N+1) // // Both files are on disk and CRC-valid. The sentinel-trust path // picks A (seq=N) — losing the most recent save. The scan-all // path picks B (seq=N+1) — correct. The cost of scanning is two // small file reads (<1 KB total), negligible at boot. char sentinel = _readSentinel(); JsonDocument docA, docB; uint32_t sA = _readSlotFile(RESUME_STATE_A_PATH, docA); uint32_t sB = _readSlotFile(RESUME_STATE_B_PATH, docB); Serial.printf("[RESUME] load: sentinel=%c scan A=%u B=%u\n", sentinel ? sentinel : '?', (unsigned)sA, (unsigned)sB); if (sA > 0 && sA >= sB) { doc = docA; pickedSeq = sA; pickedSlot = 'A'; } else if (sB > 0) { doc = docB; pickedSeq = sB; pickedSlot = 'B'; } else { // Neither slot is readable. One last shot: a legacy single- // file save from older firmware. if (LittleFS.exists(RESUME_STATE_PATH)) { File f = LittleFS.open(RESUME_STATE_PATH, "r"); if (f) { if (deserializeJson(doc, f) == DeserializationError::Ok) { f.close(); pickedSeq = 1; pickedSlot = 'L'; Serial.println("[RESUME] load: legacy resume.json accepted"); } else { f.close(); } } } if (pickedSeq == 0) return false; } bool active = doc["active"] | false; if (!active) { Serial.println("[RESUME] load: doc says inactive"); return false; } slot = doc["slot"] | 0; nodeIdx = doc["node"] | 0; _resumeSeq = pickedSeq; _resumeActiveSlot = (pickedSlot == 'A' || pickedSlot == 'B') ? pickedSlot : 0; // Restore loop selector state so a Loop that reads from it // still gets the correct value after a power-loss resume. _loopSelectorValue = doc["loop_sel"] | 1; _loopSelectorSet = doc["loop_sel_set"] | false; _loopSelectorStart = doc["loop_sel_start"] | 1; _loopSelectorStartSet = doc["loop_sel_start_set"] | false; // Restore repeat stack + per-loop iteration counters. _repeatDepth = doc["repeat_depth"] | 0; if (_repeatDepth < 0 || _repeatDepth > 8) _repeatDepth = 0; JsonArray rc = doc["repeat_counters"].as(); JsonArray rt = doc["repeat_targets"].as(); JsonArray rlid = doc["repeat_loop_ids"].as(); for (int i = 0; i < 8; i++) { _repeatCounters[i] = (i < (int)rc.size()) ? (int)rc[i] : 0; _repeatTargets[i] = (i < (int)rt.size()) ? (int)rt[i] : 0; _repeatLoopIds[i] = (i < (int)rlid.size()) ? (int)rlid[i] : 0; } JsonArray li = doc["loop_iters"].as(); for (int i = 0; i < MAX_LOOPS; i++) { _loopIterations[i] = (i < (int)li.size()) ? (int)li[i] : 0; } Serial.printf("[RESUME] load: slot=%c seq=%u macro_slot=%d node=%d depth=%d\n", pickedSlot, (unsigned)pickedSeq, slot, nodeIdx, _repeatDepth); return true; } bool resumeMacro(int slot, int nodeIdx, const char* name) { if (state != ENGINE_IDLE) { Serial.printf("[ENGINE] resumeMacro: refused — engine not idle (state=%d)\n", (int)state); return false; } _nodesDoc.clear(); if (!_storage->loadNodes(slot, _nodesDoc)) { Serial.printf("[ENGINE] resumeMacro: loadNodes(%d) failed\n", slot); return false; } _nodes = _nodesDoc.as(); _nodeCount = _nodes.size(); if (nodeIdx >= _nodeCount) { Serial.printf("[ENGINE] resumeMacro: node %d out of range (count=%d) — clearing state\n", nodeIdx, _nodeCount); clearExecutionState(); return false; } _currentNode = nodeIdx; _currentSlot = slot; strlcpy(_macroName, name, sizeof(_macroName)); // NOTE: we INTENTIONALLY do NOT reset the repeat stack or // _loopIterations here — those were already restored from NVRAM // by checkSavedState(). Resetting them would break iteration_branch // after a power-loss resume mid-loop. Serial.printf("[ENGINE] resumeMacro slot=%d name=%s node=%d/%d depth=%d\n", slot, name, nodeIdx, _nodeCount, _repeatDepth); _display->showExecuting(name); state = ENGINE_EXECUTING; return true; } private: HIDController* _hid; DisplayUI* _display; MacroStorage* _storage; SettingsManager* _settingsMgr = nullptr; BLEManager* _bleManager = nullptr; HardwareSerial* _rs232Serial = nullptr; // RS232 response waiting state char _rs232Expected[128]; char _rs232Buf[RS232_BUF_SIZE]; int _rs232BufPos = 0; int _rs232LastBaud = 0; uint32_t _rs232LastConfig = 0; // Num Lock check state int _numLockCheckAttempt = 0; // Loop Selector state — persists across nodes so Loop can read it later int _loopSelectorValue = 1; // last committed count (also restored from NVRAM) bool _loopSelectorSet = false; // true once a selector node has committed at least once int _loopSelectorStart = 1; // committed start iteration (defaults to 1) bool _loopSelectorStartSet = false; // true if user picked a start-iteration // Transient state while in ENGINE_LOOP_SELECTING / ENGINE_LOOP_START_SELECTING int _loopSelectorMin = 1; int _loopSelectorMax = 10; int _loopSelectorStep = 1; int _loopSelectorCurrent = 1; char _loopSelectorPrompt[64]; bool _loopSelectorAskStart = false; // whether to prompt for start iter after count commit // Delay display state uint32_t _delayTotalMs = 0; uint32_t _delayStartMs = 0; uint32_t _delayLastDisplayUpdate = 0; JsonDocument _nodesDoc; JsonArray _nodes; int _nodeCount = 0; int _currentNode = 0; int _currentSlot = 0; char _macroName[64]; // Get Variables FAIL screen state (used when both attempts produce no // matching outcome). On entry: showFailScreen, 10s countdown ticking // via _waitUntil. Single click → frozen state, snapshot remaining; // second click → reset _currentNode to _failNodeIdx and re-enter // ENGINE_EXECUTING so executeCurrentNode() re-runs the get_local node // from scratch. Timeout → take the Fail wire (fail_target). int _failNodeIdx = -1; int _failContinueTarget = -1; uint32_t _failStartMs = 0; uint32_t _failTotalMs = 0; uint32_t _failRemainSnapshot = 0; uint32_t _failLastDisplayUpdate = 0; // Pause state bool _pauseTimed = false; uint32_t _waitUntil = 0; // When set, ENGINE_BLE_WAITING ignores _waitUntil and just keeps polling. // Used by request_ble where the host dialog can take arbitrarily long // to fill in. Cancel path is the side-button handler. bool _bleNoTimeout = false; uint32_t _pauseTotalMs = 0; uint32_t _pauseStartMs = 0; char _pauseText[128]; int _pauseFontSize = 12; uint16_t _pauseTextColor = TFT_WHITE; // Branch state int _branchSelected = 0; int _branchCount = 0; int _branchScroll = 0; // index of first visible row in showBranchSelector const char* _branchLabels[MAX_BRANCH_CHOICES]; char _branchLabelBuf[MAX_BRANCH_CHOICES][32]; uint16_t _branchColors[MAX_BRANCH_CHOICES]; // Repeat state int _repeatCounters[8]; int _repeatTargets[8]; int _repeatLoopIds[8]; // which loop_id corresponds to each stack level int _repeatDepth = 0; // Per-loop iteration counter (1-indexed), keyed by loop_id. // Accessible by iteration_branch regardless of stack depth. int _loopIterations[MAX_LOOPS] = {0}; // Resume-state double-buffer cursor: which slot file the most recent save // wrote to ('A' or 'B'). The NEXT save writes the opposite slot. Reset to // 0 at boot; on first save we read the on-disk sentinel (if any) to pick // the inactive slot so we don't clobber the only valid file we have. char _resumeActiveSlot = 0; uint32_t _resumeSeq = 0; // Sub-routine call stack — stores only indices, not JsonDocuments. // On return, the parent macro's nodes are reloaded from LittleFS. struct CallFrame { int returnSlot; // Macro slot to reload on return (-1 = sub-routine slot) int returnSubSlot; // Sub-routine slot to reload on return (-1 = macro slot) int returnNode; // Node index to resume at after return int calledSubSlot; // Which sub was called (for recursion detection) }; CallFrame _callStack[MAX_SUB_CALL_DEPTH]; int _callDepth = 0; bool _inSubroutine = false; // true when executing a sub-routine (vs main macro) int _currentSubSlot = -1; // which sub-routine slot we're in (-1 = main macro) // Static callback for numlock probe step display update static void _onProbeStep(const char* phase, void* userData) { DisplayUI* disp = (DisplayUI*)userData; if (disp) { disp->showNumLockProbing(phase); } } // Static callback for typeText per-character display update static void _onTextChar(const char* fullText, int charIdx, void* userData) { DisplayUI* disp = (DisplayUI*)userData; if (disp) { disp->showTextRibbon(fullText, charIdx); } } void executeCurrentNode(uint8_t typeDelay) { JsonObject node = _nodes[_currentNode].as(); const char* type = node["type"] | ""; JsonObject data = node["data"].as(); // Persist execution state so we can resume after power loss. // RS232 nodes handle their own save (pre-commit style) because the // payload may cause an imminent power cut to the M5Stack (e.g. a // KVM switch that also cuts USB power). See the rs232 handler below. if (strcmp(type, "rs232") != 0) { saveExecutionState(); } if (strcmp(type, "text") == 0) { const char* raw = data["text"] | ""; char expanded[512]; expandVariables(raw, expanded, sizeof(expanded)); uint16_t shiftExtra = _settingsMgr ? _settingsMgr->settings.typeShiftExtraMs : DEFAULT_TYPE_SHIFT_EXTRA_MS; uint16_t settleMs = _settingsMgr ? _settingsMgr->settings.typeSettleMs : DEFAULT_TYPE_SETTLE_MS; uint16_t holdMin = _settingsMgr ? _settingsMgr->settings.typeHoldMinMs : DEFAULT_TYPE_HOLD_MIN_MS; uint16_t interChar = _settingsMgr ? _settingsMgr->settings.typeInterCharMs : DEFAULT_TYPE_INTER_CHAR_MS; _hid->typeText(expanded, typeDelay, _onTextChar, _display, shiftExtra, settleMs, holdMin, interChar); _currentNode++; } else if (strcmp(type, "combo") == 0) { JsonArray mods = data["mods"].as(); const char* keyStr = data["key"] | ""; // Build display string for the combo (also resolves modifier keycodes) char comboStr[64] = ""; uint8_t modKeys[8]; uint8_t modCount = 0; for (JsonVariant m : mods) { if (modCount < 8) { const char* modName = m.as(); modKeys[modCount] = _hid->resolveModifier(modName); if (modKeys[modCount] != 0) { if (comboStr[0] != '\0') strcat(comboStr, "+"); char cap[16]; strlcpy(cap, modName, sizeof(cap)); if (cap[0] >= 'a' && cap[0] <= 'z') cap[0] -= 32; strcat(comboStr, cap); modCount++; } } } if (keyStr[0] != '\0') { if (comboStr[0] != '\0') strcat(comboStr, "+"); char capKey[16]; strlcpy(capKey, keyStr, sizeof(capKey)); if (capKey[0] >= 'a' && capKey[0] <= 'z') capKey[0] -= 32; strcat(comboStr, capKey); } _display->showKeyCombo(comboStr); uint8_t key = _hid->resolveKey(keyStr); // Start with the device-wide defaults from Settings uint16_t preMs = _settingsMgr ? _settingsMgr->settings.comboPreMs : DEFAULT_COMBO_PRE_MS; uint16_t postMs = _settingsMgr ? _settingsMgr->settings.comboPostMs : DEFAULT_COMBO_POST_MS; uint16_t keyPreMs = COMBO_KEY_PRE_DELAY; uint16_t keyPostMs = COMBO_KEY_POST_DELAY; // Per-combo custom_timings overrides all four ms values bool customTimings = data["custom_timings"] | false; if (customTimings) { preMs = data["custom_pre_ms"] | 167; postMs = data["custom_post_ms"] | 167; keyPreMs = data["custom_key_pre_ms"] | 3; keyPostMs = data["custom_key_post_ms"] | 8; } else { // Legacy fallback: if an older macro still has "fast" set, // treat it exactly like the pre-migration behavior (divide // everything by 3). Macros loaded through the Python app // will have been migrated already; this catches any that // were uploaded directly with the legacy flag. bool legacyFast = data["fast"] | false; if (legacyFast) { preMs = preMs / 3; postMs = postMs / 3; keyPreMs = COMBO_KEY_PRE_DELAY / 3; keyPostMs = COMBO_KEY_POST_DELAY / 3; } } // Floor every delay at 1ms so we never starve the USB HID stack. if (preMs < 1) preMs = 1; if (postMs < 1) postMs = 1; if (keyPreMs < 1) keyPreMs = 1; if (keyPostMs < 1) keyPostMs = 1; delay(preMs); _hid->keyCombo(modKeys, modCount, key, keyPreMs, keyPostMs); delay(postMs); _currentNode++; } else if (strcmp(type, "delay") == 0) { int ms = data["ms"] | 100; _delayTotalMs = (uint32_t)ms; _delayStartMs = millis(); _waitUntil = _delayStartMs + _delayTotalMs; _delayLastDisplayUpdate = 0; _display->showDelayProgress(_delayTotalMs, _delayTotalMs); state = ENGINE_DELAY; } else if (strcmp(type, "pause") == 0) { const char* rawPause = data["text"] | "Press to continue"; expandVariables(rawPause, _pauseText, sizeof(_pauseText)); _pauseFontSize = data["font_size"] | 12; _pauseTextColor = resolveColor(data["text_color"] | "white"); // "wait" is either the string "click" or an integer (ms) bool isClick = false; int ms = 0; if (data["wait"].is()) { const char* waitStr = data["wait"].as(); isClick = (strcmp(waitStr, "click") == 0); if (!isClick) ms = atoi(waitStr); } else { ms = data["wait"] | 1000; } if (isClick || ms <= 0) { _pauseTimed = false; _display->showPauseScreen(_pauseText, _pauseFontSize, false, 0, 0, _pauseTextColor, _pauseMarginL(), _pauseMarginR(), _pauseMarginT(), _pauseMarginB()); } else { _pauseTimed = true; _pauseTotalMs = (uint32_t)ms; _pauseStartMs = millis(); _waitUntil = _pauseStartMs + _pauseTotalMs; _display->showPauseScreen(_pauseText, _pauseFontSize, true, _pauseTotalMs, _pauseTotalMs, _pauseTextColor, _pauseMarginL(), _pauseMarginR(), _pauseMarginT(), _pauseMarginB()); } state = ENGINE_PAUSED; } else if (strcmp(type, "branch") == 0) { JsonArray choices = data["choices"].as(); _branchCount = choices.size(); if (_branchCount > MAX_BRANCH_CHOICES) _branchCount = MAX_BRANCH_CHOICES; _branchSelected = 0; _branchScroll = 0; const char* bmode = data["mode"] | "manual"; if (strcmp(bmode, "by_variable") == 0 && _bleManager) { // Resolve immediately by variable lookup; no on-device prompt. const char* vname = data["var_name"] | ""; const char* vscope = data["var_scope"] | "auto"; const char* val = _bleManager->getVariableScoped(vname, vscope); int picked = _branchCount - 1; // default: last choice = else for (int i = 0; i < _branchCount - 1; i++) { const char* m = choices[i]["match_value"] | ""; // Case-insensitive to match getVariable() and Type Text // (VAR{...}) expansion semantics. "true" / "True" / "TRUE" // all match — saves an entire class of silent-fail bugs. if (strcasecmp(m, val) == 0) { picked = i; break; } } int target = choices[picked]["next"] | (_currentNode + 1); Serial.printf("[ENGINE] branch by var '%s'='%s' -> choice %d\n", vname, val, picked); _currentNode = target; } else { for (int i = 0; i < _branchCount; i++) { strlcpy(_branchLabelBuf[i], choices[i]["label"] | "?", sizeof(_branchLabelBuf[i])); _branchLabels[i] = _branchLabelBuf[i]; // Per-choice color sent by host as a name string. Default // to white so legacy macros (no "color" field) render // exactly like before. const char* cname = choices[i]["color"] | "white"; _branchColors[i] = resolveColor(cname); } state = ENGINE_BRANCHING; updateBranchDisplay(); } } else if (strcmp(type, "repeat") == 0) { // Legacy repeat handler (for old-format macros) int count = data["count"] | 1; int startIdx = data["start_idx"] | (_currentNode + 1); if (_repeatDepth < 8) { if (_repeatCounters[_repeatDepth] == 0) { _repeatCounters[_repeatDepth] = count; _repeatTargets[_repeatDepth] = _currentNode; _currentNode = startIdx; } else { _repeatCounters[_repeatDepth]--; if (_repeatCounters[_repeatDepth] > 0) { _currentNode = startIdx; } else { _repeatCounters[_repeatDepth] = 0; _currentNode++; } } } else { _currentNode++; } } else if (strcmp(type, "_loop_start") == 0) { // New loop model: initialize counter and jump to body int count = data["count"] | 1; int bodyStart = data["body"] | (_currentNode + 1); int doneTarget = data["done"] | (_currentNode + 1); bool useSelector = data["use_selector"] | false; int loopId = data["loop_id"] | 0; // If this loop is configured to read from the Loop Selector, // override count with the last committed selector value. // If no selector has run yet, fall back to the configured count. if (useSelector && _loopSelectorSet) { count = _loopSelectorValue; Serial.printf("[ENGINE] loop uses selector value: %d\n", count); } if (count < 1) count = 1; // Optional: start iteration (skip the first K-1 body passes). // Only applied when the loop reads from the selector AND the // selector was configured to ask for a start iteration. int startIter = 1; if (useSelector && _loopSelectorStartSet) { startIter = _loopSelectorStart; if (startIter < 1) startIter = 1; if (startIter > count) startIter = count; Serial.printf("[ENGINE] loop starts at iteration %d\n", startIter); } int remaining = count - startIter + 1; if (remaining < 1) remaining = 1; if (_repeatDepth < 8) { _repeatCounters[_repeatDepth] = remaining; _repeatTargets[_repeatDepth] = doneTarget; _repeatLoopIds[_repeatDepth] = loopId; if (loopId >= 0 && loopId < MAX_LOOPS) { _loopIterations[loopId] = startIter; } _display->showLoopStatus(startIter, count); _repeatDepth++; _currentNode = bodyStart; } else { _currentNode = doneTarget; // Too deep, skip loop } } else if (strcmp(type, "iteration_branch") == 0) { int loopId = data["loop_id"] | -1; JsonArray choices = data["choices"].as(); int numChoices = choices.size(); if (numChoices <= 0) { _currentNode++; return; } int iter = 1; if (loopId >= 0 && loopId < MAX_LOOPS) { int v = _loopIterations[loopId]; if (v >= 1) iter = v; } // Skip-on-final-iteration: if the node is configured to no-op on // the tied loop's last pass, check the repeat stack for that // loop_id and compare its remaining counter. remaining == 1 means // _loop_back hasn't fired yet for the last iteration — this IS // the last iteration. Jump directly to skip_target (the merge // point after all paths) so nothing in the branch executes. bool skipFinal = data["skip_final_iteration"] | false; if (skipFinal && loopId >= 0) { int depth = -1; for (int d = 0; d < _repeatDepth; d++) { if (_repeatLoopIds[d] == loopId) { depth = d; break; } } if (depth >= 0 && _repeatCounters[depth] == 1) { int skipTo = data["skip_target"] | (_currentNode + 1); Serial.printf("[ENGINE] iteration_branch: loop=%d iter=%d is final — skipping to node %d\n", loopId, iter, skipTo); _currentNode = skipTo; return; } } // Cycle through paths: iter 1 → 0, iter 2 → 1, ..., wrap. int pathIdx = (iter - 1) % numChoices; if (pathIdx < 0) pathIdx = 0; const char* label = choices[pathIdx]["label"] | "?"; int nextNode = choices[pathIdx]["next"] | (_currentNode + 1); Serial.printf("[ENGINE] iteration_branch: loop=%d iter=%d -> path %d '%s'\n", loopId, iter, pathIdx, label); _display->showIterationBranch(iter, label, pathIdx, numChoices); delay(400); // brief display so the user can see which path was chosen _currentNode = nextNode; } else if (strcmp(type, "loop_selector") == 0) { int mn = data["min"] | 1; int mx = data["max"] | 10; int step = data["step"] | 1; if (step <= 0) step = 1; if (mx < mn) mx = mn; int dflt = data["default"] | mn; if (dflt < mn) dflt = mn; if (dflt > mx) dflt = mx; const char* prompt = data["prompt"] | "Loop count?"; bool askStart = data["ask_start"] | false; _loopSelectorMin = mn; _loopSelectorMax = mx; _loopSelectorStep = step; _loopSelectorCurrent = dflt; strlcpy(_loopSelectorPrompt, prompt, sizeof(_loopSelectorPrompt)); _loopSelectorAskStart = askStart; // Clear any previous start setting — this selector will supply // a new one (or leave it default=1 if ask_start is false). _loopSelectorStartSet = false; state = ENGINE_LOOP_SELECTING; updateLoopSelectorDisplay(); } else if (strcmp(type, "_loop_back") == 0) { // End of loop body: decrement counter, loop or exit int loopStartIdx = data["target"] | (_currentNode + 1); if (_repeatDepth > 0) { int loopId = _repeatLoopIds[_repeatDepth - 1]; _repeatCounters[_repeatDepth - 1]--; if (_repeatCounters[_repeatDepth - 1] > 0) { // Read original count from the _loop_start node for display JsonObject loopNode = _nodes[loopStartIdx].as(); int totalCount = loopNode["data"]["count"] | 1; bool useSelector = loopNode["data"]["use_selector"] | false; if (useSelector && _loopSelectorSet) totalCount = _loopSelectorValue; int remaining = _repeatCounters[_repeatDepth - 1]; int iteration = totalCount - remaining + 1; _display->showLoopStatus(iteration, totalCount); if (loopId >= 0 && loopId < MAX_LOOPS) { _loopIterations[loopId] = iteration; } // Loop again: jump back to body start (loop_start + 1) _currentNode = loopStartIdx + 1; } else { // Done: jump to done target and pop stack _repeatDepth--; _currentNode = _repeatTargets[_repeatDepth]; // Leave _loopIterations[loopId] intact so nodes later in // the macro can still inspect the final iteration count. } } else { _currentNode++; } } else if (strcmp(type, "mouse") == 0) { const char* btn = data["button"] | "left"; const char* action = data["action"] | "click"; _display->showMouseAction(action, btn); uint8_t button = _hid->resolveMouseButton(btn); if (strcmp(action, "click") == 0) _hid->mouseClick(button); else if (strcmp(action, "double") == 0) _hid->mouseDoubleClick(button); else if (strcmp(action, "press") == 0) _hid->mousePress(button); else if (strcmp(action, "release") == 0) _hid->mouseRelease(button); _currentNode++; } else if (strcmp(type, "media") == 0) { const char* action = data["action"] | ""; _display->showMediaKey(action); uint16_t key = _hid->resolveMediaKey(action); uint16_t mediaMs = _settingsMgr ? _settingsMgr->settings.mediaHoldMs : DEFAULT_MEDIA_HOLD_MS; if (key != 0) _hid->mediaKey(key, mediaMs); _currentNode++; } else if (strcmp(type, "bluetooth") == 0) { const char* mode = data["mode"] | "pull_ble"; Serial.printf("[ENGINE] variables node hit (mode=%s)\n", mode); if (strcmp(mode, "pull_ble") == 0) { if (!_bleManager) { _display->showBLEStatus("No BLE! Click>"); state = ENGINE_BLE_WAIT; } else { const char* scope = data["scope"] | "universal"; _bleManager->startPull(scope); _display->showBLEStatus("Pulling vars..."); _waitUntil = millis() + 30000; _bleNoTimeout = false; state = ENGINE_BLE_WAITING; } } else if (strcmp(mode, "push_ble") == 0) { if (!_bleManager) { _display->showBLEStatus("No BLE! Click>"); state = ENGINE_BLE_WAIT; } else { _bleManager->startPush(); _display->showBLEStatus("Pushing vars..."); _waitUntil = millis() + 30000; _bleNoTimeout = false; state = ENGINE_BLE_WAITING; } } else if (strcmp(mode, "request_ble") == 0) { if (!_bleManager) { _display->showBLEStatus("No BLE! Click>"); state = ENGINE_BLE_WAIT; } else { JsonArray names = data["names"].as(); _bleManager->startRequest(names); _display->showBLEStatus("Requesting..."); // No timeout: the user fills in the host dialog at their // own pace. Cancel via the side button (handler below). _bleNoTimeout = true; state = ENGINE_BLE_WAITING; } } else if (strcmp(mode, "set_local") == 0) { if (_bleManager) { const char* scope = data["scope"] | "universal"; JsonArray assigns = data["assignments"].as(); int written = 0; for (JsonVariant a : assigns) { const char* n = a["name"] | ""; const char* v = a["value"] | ""; if (n[0] && _bleManager->setLocal(scope, n, v)) written++; } Serial.printf("[ENGINE] set_local: wrote %d (scope=%s)\n", written, scope); } _currentNode++; } else if (strcmp(mode, "get_local") == 0) { runGetLocal(data); } else { Serial.printf("[ENGINE] unknown variables mode: %s\n", mode); _currentNode++; } } else if (strcmp(type, "ble_refresh") == 0) { // Legacy node — no longer emitted by the host but kept as a no-op // for projects that pre-date the unified Variables node. _currentNode++; } else if (strcmp(type, "pc_alive_check") == 0) { const char* condition = data["condition"] | "pc_response"; bool loopEnabled = data["loop"] | true; int pollDelayMs = data["poll_delay_ms"] | 500; int trueTarget = data["true_target"] | (_currentNode + 1); int falseTarget = data["false_target"] | (_currentNode + 1); _numLockCheckAttempt = 0; bool conditionMet = false; do { _numLockCheckAttempt++; // Probe: toggle num lock, check if host responds uint16_t probeMs = _settingsMgr ? _settingsMgr->settings.probeTimeoutMs : DEFAULT_PROBE_TIMEOUT_MS; bool pcAlive = _hid->probeNumLock(probeMs, _onProbeStep, _display); bool numState = _hid->numLockOn; Serial.printf("[ENGINE] pc_alive_check: attempt=%d alive=%s numlock=%s cond=%s\n", _numLockCheckAttempt, pcAlive ? "Y" : "N", numState ? "ON" : "OFF", condition); // Evaluate the selected condition if (strcmp(condition, "pc_response") == 0) { conditionMet = pcAlive; } else if (strcmp(condition, "numlock_on") == 0) { conditionMet = pcAlive && numState; } else if (strcmp(condition, "numlock_off") == 0) { conditionMet = pcAlive && !numState; } if (conditionMet) { _display->showPCAliveResult(true, _numLockCheckAttempt, condition); delay(300); break; } _display->showPCAliveResult(false, _numLockCheckAttempt, condition); if (loopEnabled) { delay(pollDelayMs); } } while (loopEnabled); _numLockCheckAttempt = 0; _currentNode = conditionMet ? trueTarget : falseTarget; } else if (strcmp(type, "subroutine") == 0) { // IMPORTANT: ``data["name"]`` returns a pointer into the // parent macro's _nodesDoc memory pool. The pool gets reset // by _nodesDoc.clear() / loadSubNodes() further down, after // which any const char* into it is dangling — reading it // can crash the device (LoadProhibited if the new content // doesn't null-terminate where the old string did) and was // the root cause of the "screen freaks out then reboot" // symptom on sub-routine entry. Copy the name into a small // stack buffer up front so every later use is safe. char subName[40]; { const char* raw = data["name"] | ""; strlcpy(subName, raw, sizeof(subName)); } _display->showSubroutineCall(subName); int subSlot = _storage->findSubByName(subName); if (subSlot < 0) { Serial.printf("[ENGINE] Sub-routine '%s' not found, skipping\n", subName); _currentNode++; return; } if (_callDepth >= MAX_SUB_CALL_DEPTH) { Serial.printf("[ENGINE] Sub-routine call depth exceeded, skipping\n"); _currentNode++; return; } // Recursive-call guard (prevent infinite loops) for (int d = 0; d < _callDepth; d++) { if (_callStack[d].calledSubSlot == subSlot) { Serial.printf("[ENGINE] Recursive sub-routine '%s' detected, skipping\n", subName); _currentNode++; return; } } // Push return info onto call stack (just indices, no JsonDocument) CallFrame& frame = _callStack[_callDepth]; frame.returnSlot = _inSubroutine ? -1 : _currentSlot; frame.returnSubSlot = _inSubroutine ? _currentSubSlot : -1; frame.returnNode = _currentNode + 1; // resume after the subroutine node frame.calledSubSlot = subSlot; _callDepth++; // Load sub-routine nodes (replaces current _nodesDoc completely). // From this point on, ``data`` and any const char* derived from // it (other than the subName stack copy above) is invalid. // // Snapshot whether we're currently in a sub BEFORE we clobber // _nodesDoc, so the failure path below can reload the right // parent (a sub may call another sub). int parentSlot = _currentSlot; int parentSubSlot = _currentSubSlot; bool parentInSub = _inSubroutine; _nodesDoc.clear(); if (!_storage->loadSubNodes(subSlot, _nodesDoc)) { Serial.printf("[ENGINE] Failed to load sub-routine nodes — restoring parent\n"); // Critical: at this point _nodesDoc is empty. If we return // without reloading the parent, the next tick() reads // _nodes[_currentNode] out of an empty document and the // device hard-crashes. Reload the parent macro / sub here // so the engine can continue past the broken sub-routine // node. (Common case: a sub-routine whose GUI graph has // zero connections flattens to an empty file, which is // stored as the invalid JSON "[" by beginSubWrite — also // fixed in macro_storage.h.) _callDepth--; bool reloaded = parentInSub ? _storage->loadSubNodes(parentSubSlot, _nodesDoc) : _storage->loadNodes(parentSlot, _nodesDoc); if (!reloaded) { Serial.println("[ENGINE] Parent reload after sub failure also failed — stopping"); stop(); return; } _nodes = _nodesDoc.as(); _nodeCount = _nodes.size(); _currentNode++; return; } _nodes = _nodesDoc.as(); _nodeCount = _nodes.size(); _currentNode = 0; _inSubroutine = true; _currentSubSlot = subSlot; Serial.printf("[ENGINE] Entering sub-routine '%s' (slot=%d depth=%d, nodes=%d)\n", subName, subSlot, _callDepth, _nodeCount); if (_nodeCount == 0) { // An "empty" sub-routine is technically valid — the engine // will immediately hit the _currentNode >= _nodeCount branch // on the next tick() and return to the caller. Log it so // the user can tell from the serial monitor that the sub // ran but did nothing (usually means the GUI graph is // missing connections or content). Serial.printf("[ENGINE] (sub-routine '%s' is empty — will skip back to caller)\n", subName); } } else if (strcmp(type, "rs232") == 0) { if (!_rs232Serial) { Serial.println("[ENGINE] RS232: no serial port configured"); saveExecutionState(); _currentNode++; return; } int baud = data["baud"] | 9600; int dataBits = data["data_bits"] | 8; const char* stopBitsStr = data["stop_bits"] | "1"; const char* parityStr = data["parity"] | "none"; const char* rawMsg = data["message"] | ""; const char* lineEnding = data["line_ending"] | "none"; bool waitResponse = data["wait_response"] | false; const char* expectedResp = data["expected_response"] | ""; int timeoutMs = data["timeout_ms"] | 5000; int postSendDelayMs = data["post_send_delay_ms"] | 0; bool willWait = (waitResponse && expectedResp[0] != '\0'); // === Pre-commit the advance BEFORE sending === // Rationale: this RS232 payload may cause an imminent power cut // (e.g. a KVM switch that also cuts the M5Stack's USB power). // If we saved "node=current" and then lost power mid-send, the // resume flow would re-enter the RS232 node, re-send, and cut // power again — an infinite reboot loop. // // Instead we save "node=next" now. On power loss, resume picks // up at the node AFTER this send. Side effect: if power is cut // BEFORE the payload reaches the wire, the macro still advances // as if it had — acceptable for the KVM-switching use case. // // When waiting for a response, we save "node=current" so a // power loss during the wait simply re-enters and re-waits. if (willWait) { saveExecutionState(); } else { _currentNode++; saveExecutionState(); _currentNode--; } // Build UART config and re-init the port only when the settings change uint32_t config = computeRS232Config(dataBits, parityStr, stopBitsStr); if (baud != _rs232LastBaud || config != _rs232LastConfig) { _rs232Serial->end(); _rs232Serial->begin(baud, config, RS232_RX_PIN, RS232_TX_PIN); _rs232LastBaud = baud; _rs232LastConfig = config; delay(50); } // Expand BLE variables in the message char expanded[512]; expandVariables(rawMsg, expanded, sizeof(expanded)); _display->showRS232Sending(expanded, baud); // Send message (+ line ending) _rs232Serial->print(expanded); if (strcmp(lineEnding, "cr") == 0) _rs232Serial->print('\r'); else if (strcmp(lineEnding, "lf") == 0) _rs232Serial->print('\n'); else if (strcmp(lineEnding, "crlf") == 0) _rs232Serial->print("\r\n"); _rs232Serial->flush(); Serial.printf("[ENGINE] RS232: sent '%s' at %dbps\n", expanded, baud); // Post-send delay: once the bytes are on the wire, the resume // state has already been pre-committed, so the device is now // safe to lose power. Sit idle for the configured duration so // any power-cutting side effect (e.g. a KVM cutting USB power) // has a guaranteed window, and flash has plenty of time to // fully settle the save that just happened. if (!willWait && postSendDelayMs > 0) { _display->showRS232SafeIdle(expanded, postSendDelayMs); Serial.printf("[ENGINE] RS232: safe-idle %dms (ready to lose power)\n", postSendDelayMs); delay((uint32_t)postSendDelayMs); } if (willWait) { _waitUntil = millis() + (uint32_t)timeoutMs; strlcpy(_rs232Expected, expectedResp, sizeof(_rs232Expected)); _rs232BufPos = 0; _rs232Buf[0] = '\0'; while (_rs232Serial->available()) _rs232Serial->read(); state = ENGINE_RS232_WAITING; } else { _currentNode++; } } else if (strcmp(type, "macro") == 0) { // Replay a recorded key sequence verbatim. // // Event format (from Python flatten): each entry is an array // [t_ms, action, hid_code] // t_ms — ms since the start of the recording // action — 0 = press (key down), 1 = release (key up) // hid_code — raw USB HID usage code // // We use pressRaw / releaseRaw to send each key independently — // never the high-level press(char) path, which would silently // toggle the shift modifier as a side-effect of mapping capital // ASCII letters. With pressRaw, chords replay exactly the way // they were recorded (modifiers stay held across the key-down // and key-up of other keys). // // Timing: we anchor every event to the WALL-CLOCK offset from // the start of playback (target = startMs + t). The previous // implementation kept an idealised ``elapsed`` counter and // delayed for ``t - elapsed`` between events, which silently // drifted slower as USB report acks accumulated (~1-5 ms per // pressRaw/releaseRaw call). On a long recording that drift // adds up and the replay no longer matches the original // cadence. Anchoring to millis() absorbs the ack time into // the gap instead of pushing every later event back by it. // Two event formats are supported, auto-detected per event: // * legacy keyboard-only: [t_ms, action, hid] // * library (keys+mouse): ["k", t_ms, action, hid] // ["m", t_ms, buttons, x, y, wheel] // (x/y are absolute 0..32767.) A leading string element marks the // tagged form. data["mode"] is "recorded" (default) or "library" // but we detect per-event so a missing/old mode field still works. const char* mname = data["name"] | ""; JsonArray events = data["events"].as(); int nEvents = events.size(); uint32_t totalMs = 0; if (nEvents > 0) { JsonArray last = events[nEvents - 1].as(); if (last.size() >= 1) { int ti = last[0].is() ? 1 : 0; if ((int)last.size() > ti) totalMs = (uint32_t)(last[ti].as()); } } _display->showMacroPlayback(mname, nEvents, totalMs); Serial.printf("[ENGINE] macro '%s' playback start: %d events, %ums\n", mname, nEvents, totalMs); _hid->beginCritical(); uint32_t startMs = millis(); bool sawMouse = false; uint16_t lastX = 0, lastY = 0; for (JsonVariant evVar : events) { JsonArray ev = evVar.as(); if (ev.size() < 3) continue; bool tagged = ev[0].is(); uint32_t t = (uint32_t)(ev[tagged ? 1 : 0].as()); // Sleep until the wall-clock target for this event (anchored // to playback start so USB-ack time doesn't accumulate drift). uint32_t target = startMs + t; uint32_t now = millis(); if ((int32_t)(target - now) > 0) { delay(target - now); } if (!tagged) { // Legacy keyboard event. int action = ev[1].as(); uint8_t code = (uint8_t)(ev[2].as()); if (action == 0) _hid->keyboard.pressRaw(code); else _hid->keyboard.releaseRaw(code); continue; } const char* tag = ev[0].as(); if (tag && tag[0] == 'k' && ev.size() >= 4) { int action = ev[2].as(); uint8_t code = (uint8_t)(ev[3].as()); if (action == 0) _hid->keyboard.pressRaw(code); else _hid->keyboard.releaseRaw(code); } else if (tag && tag[0] == 'm' && ev.size() >= 6) { uint8_t buttons = (uint8_t)(ev[2].as()); uint16_t x = (uint16_t)(ev[3].as()); uint16_t y = (uint16_t)(ev[4].as()); int8_t wheel = (int8_t)(ev[5].as()); // Call absMouse.report() directly (NOT the absMouseReport // wrapper) — we're already inside beginCritical/endCritical // and the wrapper would toggle the critical flag off. _hid->absMouse.report(buttons, x, y, wheel); sawMouse = true; lastX = x; lastY = y; } } // Safety release: flush keyboard, and drop any held mouse buttons // at the last position so nothing's left stuck after a hand-edit // or a mid-chord stop. _hid->keyboard.releaseAll(); if (sawMouse) _hid->absMouse.report(0, lastX, lastY, 0); _hid->endCritical(); Serial.println("[ENGINE] macro playback done"); _currentNode++; } else if (strcmp(type, "aggregator") == 0) { // Passthrough — branch paths merge here, just advance to next node. // No display update: the aggregator is a visual-only merge marker, // and showing it would flicker between the preceding node and the // post-aggregator node. _currentNode++; } else if (strcmp(type, "_jump") == 0) { int target = data["target"] | (_currentNode + 1); _currentNode = target; } else { // Unknown or internal node type - show generic display if (type[0] != '_') { // Don't show display for internal nodes like _jump _display->showGenericNode(type); } Serial.printf("[ENGINE] Unknown node type: '%s'\n", type); _currentNode++; } } // Variables -> Get Variables mode. Types the configured script into the // currently-focused window, presses Enter, then watches the host's Num // Lock LED for a sequence of toggles. The number of OFF->ON transitions // observed within the listen window is matched against the configured // outcomes; on a match the named variable is set and the Pass output is // taken. On no match (or timeout) the variable is left alone and the // Fail output is taken. // One end-to-end attempt of the get_local flow: optionally launch an // elevated terminal, type the script, listen for Scroll Lock toggles, // match against the outcomes table. Returns the matched value (pointer // into the JSON doc — valid until the doc is mutated), or nullptr if // no outcome matched. Does NOT mutate _currentNode. const char* attemptGetLocal(JsonObject data) { const char* script = data["script"] | ""; uint32_t preMs = data["pre_listen_ms"] | 500; uint32_t winMs = data["listen_window_ms"] | 5000; uint8_t typeDelay = _settingsMgr ? _settingsMgr->settings.typeDelay : DEFAULT_TYPE_DELAY; uint16_t typeShiftExtra = _settingsMgr ? _settingsMgr->settings.typeShiftExtraMs : DEFAULT_TYPE_SHIFT_EXTRA_MS; uint16_t typeSettle = _settingsMgr ? _settingsMgr->settings.typeSettleMs : DEFAULT_TYPE_SETTLE_MS; uint16_t typeHoldMin = _settingsMgr ? _settingsMgr->settings.typeHoldMinMs : DEFAULT_TYPE_HOLD_MIN_MS; uint16_t typeInterChar = _settingsMgr ? _settingsMgr->settings.typeInterCharMs : DEFAULT_TYPE_INTER_CHAR_MS; // Optional Win+R launcher — brings up an elevated terminal so the // script lands somewhere with admin rights. Re-run on every attempt // so retries get a fresh terminal (the previous one may have died // or be in an unknown state). JsonObject launch = data["elevated_launch"].as(); if (!launch.isNull() && (launch["enabled"] | false)) { const char* launchCmd = launch["command"] | "powershell -Command \"Start-Process wt -Verb RunAs\""; uint32_t winRMs = launch["win_r_wait_ms"] | 5000; uint32_t postMs = launch["post_type_wait_ms"] | 15000; bool uacAccept = launch["uac_accept"] | false; uint32_t uacMs = launch["uac_wait_ms"] | 10000; _display->showGenericNode("Get Var: Win+R"); _hid->keyboard.press(KEY_LEFT_GUI); _hid->keyboard.press('r'); delay(60); _hid->keyboard.releaseAll(); delay(winRMs); _display->showGenericNode("Get Var: launching"); _hid->typeText(launchCmd, typeDelay, nullptr, nullptr, typeShiftExtra, typeSettle, typeHoldMin, typeInterChar); _hid->keyboard.press(KEY_RETURN); delay(20); _hid->keyboard.releaseAll(); // Optional UAC auto-accept. Order matters: this runs BEFORE the // post-launch wait, because the UAC dialog appears almost // immediately after the Run-box Enter while the launched app // (e.g. Windows Terminal) only shows up AFTER UAC is accepted. if (uacAccept) { _display->showGenericNode("Get Var: UAC wait"); delay(uacMs); _display->showGenericNode("Get Var: UAC accept"); _hid->keyboard.press(KEY_LEFT_ARROW); delay(60); _hid->keyboard.releaseAll(); delay(150); _hid->keyboard.press(KEY_RETURN); delay(60); _hid->keyboard.releaseAll(); } delay(postMs); } _display->showGenericNode("Get Var: typing"); if (script[0]) { _hid->typeText(script, typeDelay, nullptr, nullptr, typeShiftExtra, typeSettle, typeHoldMin, typeInterChar); _hid->keyboard.press(KEY_RETURN); delay(20); _hid->keyboard.releaseAll(); } delay(preMs); _display->showGenericNode("Get Var: listening"); Serial.printf("[ENGINE] get_local: listening for %u ms (initial scroll=%d)\n", (unsigned)winMs, (int)_hid->scrollLockOn); int toggles = _hid->probeScrollLockSequence(winMs); Serial.printf("[ENGINE] get_local: observed %d Scroll Lock toggles (final scroll=%d)\n", toggles, (int)_hid->scrollLockOn); JsonArray outs = data["outcomes"].as(); for (JsonVariant o : outs) { int wanted = o["toggle_count"] | -1; if (wanted == toggles) { return o["value"] | ""; } } return nullptr; } void runGetLocal(JsonObject data) { if (!_bleManager) { int t = data["fail_target"] | (_currentNode + 1); _currentNode = t; return; } const char* varName = data["var_name"] | ""; const char* scope = data["scope"] | "universal"; int passTarget = data["pass_target"] | (_currentNode + 1); int failTarget = data["fail_target"] | (_currentNode + 1); // First attempt, then silent retries on no match. retry_attempts // counts ADDITIONAL attempts after the initial one, so retry=0 means // "one attempt total", retry=1 means "two attempts" (the historical // hard-coded behavior), etc. Retries cover the case where the script // never ran (USB not enumerated yet, terminal died, UAC dismissed, // etc.) AND the case where it ran but produced an unconfigured toggle // count. Each retry re-runs the elevated_launch block so the user // gets a fresh terminal. We exit the loop early on first match so a // successful first attempt never triggers a redundant rerun. int retryAttempts = data["retry_attempts"] | 1; if (retryAttempts < 0) retryAttempts = 0; const char* matchedValue = attemptGetLocal(data); for (int i = 0; i < retryAttempts && !matchedValue; i++) { Serial.printf("[ENGINE] get_local: no matching outcome -> retry %d/%d\n", i + 1, retryAttempts); matchedValue = attemptGetLocal(data); } if (matchedValue && varName[0]) { _bleManager->setLocal(scope, varName, matchedValue); Serial.printf("[ENGINE] get_local: %s=%s -> Pass\n", varName, matchedValue); _currentNode = passTarget; return; } // All attempts failed — enter the FAIL screen state. The tick() // handler will count down for 10 s and then take the Fail wire. // A main-button click pauses the countdown; a second click retries // the whole flow from scratch. Serial.println("[ENGINE] get_local: all attempts failed -> FAIL screen"); _failNodeIdx = _currentNode; _failContinueTarget = failTarget; _failStartMs = millis(); _failTotalMs = 10000; _waitUntil = _failStartMs + _failTotalMs; _failLastDisplayUpdate = 0; _display->showFailScreen(_failTotalMs, _failTotalMs, false); state = ENGINE_GET_LOCAL_FAIL; } // Expand (VAR{name}) placeholders in input using the variable store. // Lookup is case-insensitive (matches user-typed names like // (VAR{password}) against stored "Password"). If a name is not found, // the placeholder is left verbatim so text still reads correctly. // // Legacy: (BLE{name}) is still recognized for backwards compatibility // with old text nodes; new content should use (VAR{name}). void expandVariables(const char* input, char* output, size_t maxLen) { static const char* OPEN_VAR = "(VAR{"; static const char* OPEN_BLE = "(BLE{"; static const char* CLOSE = "})"; static const int OPEN_LEN = 5; // both prefixes are 5 chars static const int CLOSE_LEN = 2; size_t out = 0; size_t in = 0; size_t len = strlen(input); while (in < len && out < maxLen - 1) { bool matchesOpen = in + OPEN_LEN <= len && (strncmp(input + in, OPEN_VAR, OPEN_LEN) == 0 || strncmp(input + in, OPEN_BLE, OPEN_LEN) == 0); if (matchesOpen) { // Find closing "})" size_t nameStart = in + OPEN_LEN; size_t nameEnd = nameStart; bool closed = false; while (nameEnd + CLOSE_LEN <= len) { if (strncmp(input + nameEnd, CLOSE, CLOSE_LEN) == 0) { closed = true; break; } nameEnd++; } if (closed && nameEnd > nameStart) { char varName[BLE_VAR_NAME_LEN]; size_t nameLen = nameEnd - nameStart; if (nameLen < BLE_VAR_NAME_LEN) { strncpy(varName, input + nameStart, nameLen); varName[nameLen] = '\0'; const char* val = _bleManager ? _bleManager->getVariable(varName) : nullptr; if (val && val[0] != '\0') { size_t valLen = strlen(val); size_t copy = (out + valLen < maxLen - 1) ? valLen : (maxLen - 1 - out); memcpy(output + out, val, copy); out += copy; in = nameEnd + CLOSE_LEN; continue; } } // Unknown variable — copy placeholder verbatim size_t placeholderEnd = nameEnd + CLOSE_LEN; while (in < placeholderEnd && out < maxLen - 1) { output[out++] = input[in++]; } continue; } } output[out++] = input[in++]; } output[out] = '\0'; } void updateLoopSelectorDisplay() { _display->showLoopSelector(_loopSelectorPrompt, _loopSelectorCurrent, _loopSelectorMin, _loopSelectorMax); } void updateLoopStartSelectorDisplay() { _display->showLoopStartSelector(_loopSelectorPrompt, _loopSelectorCurrent, _loopSelectorMin, _loopSelectorMax, _loopSelectorValue); } void updateBranchDisplay() { _display->showBranchSelector(_branchLabels, _branchColors, _branchCount, _branchSelected, _branchScroll); } void updatePauseDisplay() { if (_pauseTimed) { uint32_t elapsed = millis() - _pauseStartMs; uint32_t remain = (_pauseTotalMs > elapsed) ? (_pauseTotalMs - elapsed) : 0; _display->showPauseScreen(_pauseText, _pauseFontSize, true, remain, _pauseTotalMs, _pauseTextColor, _pauseMarginL(), _pauseMarginR(), _pauseMarginT(), _pauseMarginB()); } } // Margin getters fall back to the compiled defaults when no settings // manager is wired (e.g., during startup before settings.begin()). uint8_t _pauseMarginL() const { return _settingsMgr ? _settingsMgr->settings.pauseMarginLeft : DEFAULT_PAUSE_MARGIN_LEFT; } uint8_t _pauseMarginR() const { return _settingsMgr ? _settingsMgr->settings.pauseMarginRight : DEFAULT_PAUSE_MARGIN_RIGHT; } uint8_t _pauseMarginT() const { return _settingsMgr ? _settingsMgr->settings.pauseMarginTop : DEFAULT_PAUSE_MARGIN_TOP; } uint8_t _pauseMarginB() const { return _settingsMgr ? _settingsMgr->settings.pauseMarginBottom : DEFAULT_PAUSE_MARGIN_BOTTOM; } };