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

1909 lines
84 KiB
C++

#pragma once
#include <ArduinoJson.h>
#include <LittleFS.h>
#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<JsonArray>();
_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<JsonArray>();
_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<JsonObject>();
JsonArray choices = node["data"]["choices"].as<JsonArray>();
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><LF><JSON>
// 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>();
JsonArray rt = doc["repeat_targets"].to<JsonArray>();
JsonArray rlid = doc["repeat_loop_ids"].to<JsonArray>();
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<JsonArray>();
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>();
JsonArray rt = doc["repeat_targets"].as<JsonArray>();
JsonArray rlid = doc["repeat_loop_ids"].as<JsonArray>();
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<JsonArray>();
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<JsonArray>();
_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<JsonObject>();
const char* type = node["type"] | "";
JsonObject data = node["data"].as<JsonObject>();
// 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<JsonArray>();
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<const char*>();
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*>()) {
const char* waitStr = data["wait"].as<const char*>();
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<JsonArray>();
_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<JsonArray>();
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<JsonObject>();
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<JsonArray>();
_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<JsonArray>();
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<JsonArray>();
_nodeCount = _nodes.size();
_currentNode++;
return;
}
_nodes = _nodesDoc.as<JsonArray>();
_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<JsonArray>();
int nEvents = events.size();
uint32_t totalMs = 0;
if (nEvents > 0) {
JsonArray last = events[nEvents - 1].as<JsonArray>();
if (last.size() >= 1) {
int ti = last[0].is<const char*>() ? 1 : 0;
if ((int)last.size() > ti)
totalMs = (uint32_t)(last[ti].as<int>());
}
}
_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<JsonArray>();
if (ev.size() < 3) continue;
bool tagged = ev[0].is<const char*>();
uint32_t t = (uint32_t)(ev[tagged ? 1 : 0].as<int>());
// 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<int>();
uint8_t code = (uint8_t)(ev[2].as<int>());
if (action == 0) _hid->keyboard.pressRaw(code);
else _hid->keyboard.releaseRaw(code);
continue;
}
const char* tag = ev[0].as<const char*>();
if (tag && tag[0] == 'k' && ev.size() >= 4) {
int action = ev[2].as<int>();
uint8_t code = (uint8_t)(ev[3].as<int>());
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<int>());
uint16_t x = (uint16_t)(ev[3].as<int>());
uint16_t y = (uint16_t)(ev[4].as<int>());
int8_t wheel = (int8_t)(ev[5].as<int>());
// 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<JsonObject>();
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<JsonArray>();
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;
}
};