Files
2026-07-17 15:29:53 -04:00

593 lines
24 KiB
Arduino

#include <M5Unified.h>
#include "config.h"
#include "settings.h"
#include "usb_hid.h"
#include "led_ui.h"
#include "display_ui.h"
#include "macro_storage.h"
#include "ble_keystore.h"
#include "ble_manager.h"
#include "espnow_manager.h"
#include "macro_engine.h"
#include "serial_protocol.h"
#include "live_keystroke.h"
SettingsManager settingsManager;
HIDController hid;
LedUI ledUI;
DisplayUI display;
MacroStorage storage;
DebugLog debugLog;
BLEKeyStore bleKeyStore;
BLEManager bleManager;
EspNowManager espnowManager;
MacroEngine engine;
SerialProtocol protocol;
LiveKeystrokeEngine liveEngine;
HardwareSerial rs232Serial(1); // UART1 for RS232 via Atomic RS232 Base
// Passed to SerialProtocol so that when the host-side terminal reconfigures
// the RS232 port, any cached baud/config in the engine gets invalidated.
void onRS232Reconfig() {
engine.resetRS232Cache();
}
int currentMacroIdx = 0;
// Track the last status string we drew to showLiveMode so we only
// re-render when the BLE state actually changes (avoids flicker and
// SPI traffic during heavy keystroke streaming).
static const char* _liveLastStatus = nullptr;
// True while the full-screen live-mode display is up (a host is
// connected). Lets us detect the connected->idle transition and
// repaint the macro selector exactly once.
static bool _liveScreenShown = false;
// True while the Bluetooth identify logo is up (host asked us to
// identify ourselves so the user can label this device).
static bool _liveIdentifyShown = false;
// Last device-label version we rendered, so a new label repaints the
// live-mode screen even when the status string hasn't changed.
static uint32_t _liveLabelVerShown = 0;
// Set true if the user cancels the auto-reconnect (power-loss resume) with a
// button hold. Suppresses BLE for the rest of this boot; a power cycle
// re-enables it.
static bool _liveResumeCancelled = false;
// True while the "Reconnecting..." screen is up (resume pending, not yet
// connected). Lets us repaint the selector exactly once when it clears.
static bool _resumeScreenShown = false;
// True while the "MESH HUB" screen is up; repainted when the hub's node
// roster changes (count shown on screen).
static bool _hubScreenShown = false;
static uint32_t _hubRosterVerShown = 0;
// Latches the 5-second transport-toggle so it fires exactly once per press
// (pressedFor() stays true for every update past the threshold). Cleared on
// button release.
static bool _modeSwitchArmed = false;
void setup() {
// Disable DTR/RTS bootloader reboot ASAP — CDC_ON_BOOT means TinyUSB
// is already running before setup(). This prevents a crash-restart cycle
// from entering bootloader when the Python app has the port open.
USBSerial.enableReboot(false);
// Initialize M5 WITHOUT touching USB serial
auto cfg = M5.config();
cfg.serial_baudrate = 0; // Prevent M5.begin() from calling Serial.begin()
M5.begin(cfg);
// Initialize USB composite device (HID + CDC)
hid.begin();
// Short serial timeout so readStringUntil doesn't block the loop
Serial.setTimeout(100);
settingsManager.begin();
M5.BtnA.setHoldThresh(settingsManager.settings.holdMs);
M5.BtnA.setDebounceThresh(DEBOUNCE_MS);
// Universal binary: M5GFX panel autodetect found an LCD on the AtomS3;
// on the AtomS3 Lite there is none and getBoard() reports the Lite.
// All screen output is gated inside DisplayUI; user feedback on the
// Lite comes from the RGB LED (LedUI, via M5.Led).
bool hasDisplay = (M5.getDisplayCount() > 0) &&
(M5.getBoard() != m5::board_t::board_M5AtomS3Lite);
ledUI.begin(!hasDisplay);
display.begin(settingsManager.settings.orientation, hasDisplay, &ledUI);
display.showBoot(settingsManager.settings.liveTransport == LIVE_TX_BLE
? "Mode: BLE" : "Mode: Mesh");
if (!storage.begin()) {
display.showMessage("FS Error!", TFT_RED);
delay(2000);
}
storage.loadSubIndex();
// BLE payload-encryption key — load from LittleFS or generate on first boot.
// Must come after storage.begin() since LittleFS is mounted there.
bleKeyStore.begin();
debugLog.begin();
debugLog.log("Device booted");
engine.begin(&hid, &display, &storage, &settingsManager, &rs232Serial);
// ESP-NOW mesh (live-keyboard transport). Radio stays OFF until the
// idle loop brings up node listening (or the host app switches us
// into hub mode over USB).
espnowManager.begin(&settingsManager, &bleKeyStore, &liveEngine, &debugLog);
protocol.begin(&settingsManager, &storage, &display, &debugLog,
&rs232Serial, onRS232Reconfig, &bleKeyStore, &bleManager,
&espnowManager);
// Give USB time to fully enumerate
delay(1500);
showCurrentMacro();
// BLE manager — only stores debug log pointer here.
// NimBLE is NOT started at boot; it's started on-demand when a
// bluetooth node is hit, then shut down after variables are received.
// This avoids all BLE/USB radio contention during normal operation.
bleManager.begin(&debugLog, &bleKeyStore);
engine.setBLEManager(&bleManager);
// Wire up the live-keystroke engine. The BLE manager pushes events
// into it from its NimBLE write callback; we drain in the main loop.
liveEngine.begin(&hid);
bleManager.setLiveEngine(&liveEngine);
// Check for saved execution state (power-loss recovery)
int resumeSlot = 0, resumeNode = 0;
if (engine.checkSavedState(resumeSlot, resumeNode)) {
uint16_t delaySeconds = settingsManager.settings.resumeDelay;
// resumeSlot is the actual LittleFS slot number (not display index).
// Validate it exists by checking if the slot appears in the order array.
bool slotValid = false;
for (int i = 0; i < storage.macroCount; i++) {
if (storage.order[i] == resumeSlot) { slotValid = true; break; }
}
Serial.printf("[BOOT] resume: slot=%d node=%d slotValid=%d delaySec=%u\n",
resumeSlot, resumeNode, (int)slotValid, (unsigned)delaySeconds);
if (delaySeconds > 0 && slotValid) {
// Drain stale button state. After a USB-power blip the M5.Btn
// driver can latch a "wasClicked" on the first update — if we
// peek at it during the countdown we'd cancel the resume the
// user is depending on. Burn ~200ms of updates so anything
// pending settles before we start watching for real input.
for (int i = 0; i < 20; i++) { M5.update(); delay(10); }
(void)M5.BtnA.wasClicked();
(void)M5.BtnA.wasHold();
// Countdown with cancel option. A HOLD (long press) cancels; a
// single click is ignored — it's too easy to bump the button
// accidentally while watching imaging, and that would silently
// discard the resume.
bool cancelled = false;
uint16_t cancelledAt = delaySeconds;
for (uint16_t remaining = delaySeconds; remaining > 0; remaining--) {
char msg[64];
snprintf(msg, sizeof(msg), "Resuming in %ds\nHold to cancel", remaining);
display.showMessage(msg, TFT_YELLOW);
// Poll button every 100ms during each second
for (int i = 0; i < 10; i++) {
M5.update();
if (M5.BtnA.wasHold()) {
cancelled = true;
cancelledAt = remaining;
break;
}
delay(100);
}
if (cancelled) break;
}
if (!cancelled) {
Serial.println("[BOOT] resume: countdown completed, resuming macro");
if (!engine.resumeMacro(resumeSlot, resumeNode,
storage.macros[resumeSlot].name)) {
Serial.println("[BOOT] resume: resumeMacro() failed, clearing state");
engine.clearExecutionState();
showCurrentMacro();
}
} else {
Serial.printf("[BOOT] resume: cancelled by hold at %us remaining\n",
(unsigned)cancelledAt);
engine.clearExecutionState();
showCurrentMacro();
}
} else {
Serial.printf("[BOOT] resume: skipped (delay=%u, slotValid=%d) — clearing state\n",
(unsigned)delaySeconds, (int)slotValid);
engine.clearExecutionState();
}
} else {
Serial.println("[BOOT] no resume state to load");
}
}
// ---- Live-transport abstraction ---------------------------------------
// The device receives a live-keyboard session over exactly one radio,
// chosen by settings.liveTransport. These helpers hide which one is active
// so the idle loop's screen/reconnect logic is written once for both.
static bool liveIsBle() {
return settingsManager.settings.liveTransport == LIVE_TX_BLE;
}
static bool liveSessionActive() {
return liveIsBle()
? (bleManager.exchangeKind() == BLEManager::EX_LIVE &&
bleManager.isClientConnected())
: espnowManager.nodeInSession();
}
static bool liveIdentifyActive() {
return liveIsBle() ? bleManager.liveIdentify()
: espnowManager.nodeIdentify();
}
static const char* liveStatusStr() {
return liveIsBle() ? bleManager.liveStatusText()
: espnowManager.nodeStatusText();
}
static const char* liveLabelStr() {
return liveIsBle() ? bleManager.liveLabel() : espnowManager.nodeLabel();
}
static uint32_t liveLabelVerNum() {
return liveIsBle() ? bleManager.liveLabelVer()
: espnowManager.nodeLabelVer();
}
static bool liveResumePending() {
return liveIsBle() ? bleManager.liveResumeRequested()
: espnowManager.resumeRequestedAtBoot();
}
static void liveConsumeResume() {
if (liveIsBle()) bleManager.consumeLiveResume();
else espnowManager.consumeResume();
}
// Free both live radios. Safe to call when either/both are already down
// (each teardown is idempotent). Used before running a routine, on a
// transport toggle, and when a USB host claims the device.
static void liveShutdownRadios() {
espnowManager.shutdown();
bleManager.stopLive();
bleManager.shutdown();
}
// Flip the persisted live transport (mesh <-> BLE), tear the current radio
// down so the idle loop brings the new one up, and confirm on-screen. Called
// from the idle selector when the button is held for MODE_SWITCH_HOLD_MS.
static void toggleLiveTransport() {
uint8_t next = liveIsBle() ? LIVE_TX_MESH : LIVE_TX_BLE;
settingsManager.set("live_tx", next); // persists to NVS
liveShutdownRadios();
// Cancel any pending power-loss auto-reconnect for the old transport and
// re-arm listening for the new one.
espnowManager.consumeResume();
bleManager.consumeLiveResume();
_liveResumeCancelled = false;
_liveScreenShown = false;
_liveIdentifyShown = false;
_resumeScreenShown = false;
_liveLastStatus = nullptr;
display.showModeSwitch(next == LIVE_TX_BLE);
// Hold the confirmation ~1.2 s. Pump the LED engine (no-op on an LCD
// board) so a screenless Lite actually animates its mode-switch burst.
// Deliberately do NOT call M5.update() here — the button release must
// be left for the main loop to observe so _modeSwitchArmed clears.
uint32_t until = millis() + 1200;
while ((int32_t)(millis() - until) < 0) {
ledUI.tick();
delay(20);
}
showCurrentMacro();
}
void loop() {
M5.update();
// Safety net: re-assert reboot disable every loop iteration
USBSerial.enableReboot(false);
// LED pattern engine (AtomS3 Lite only; no-op with a display). Cheap:
// recomputes the current pattern color and writes only on change.
ledUI.tick();
// Keyboard-priority gate: when the HID controller is in the middle
// of a synchronous USB-emitting operation (typeText, keyCombo,
// mediaKey, probe), defer non-USB-HID housekeeping. These polls
// don't touch the keyboard interface themselves, but they share the
// main-loop task with engine.tick() — and skipping them keeps the
// FreeRTOS scheduling latency on the typing path as low as possible
// for any future change that pumps the loop while typing.
//
// In current code the main loop is already blocked inside tick()
// for the duration of a keyboard op, so the flag is normally only
// observed BETWEEN ops. We still gate here so the contract holds.
bool hidCritical = hid.isCritical();
if (!hidCritical) {
// Print deferred BLE status on the main task (safe for TinyUSB CDC)
bleManager.pollStatus();
}
// Drain queued live-mode keystrokes outside the HID-critical window.
// The drain itself wraps in beginCritical/endCritical so any other
// gated work observes the new in-flight state — but we only START
// draining when the previous gate has lifted, so emissions don't
// stack on top of each other.
if (!hidCritical && liveEngine.isActive()) {
if (liveEngine.drainQueue() > 0) {
// Screenless boards flicker the LED so the user can see
// keystrokes flowing (no-op when a display is present).
ledUI.liveActivity();
}
}
// Drain the latest absolute-mouse report (BT Keyboard trackpad). Not
// cadence-buffered — emitted as soon as the HID-critical window is clear.
if (!hidCritical) {
liveEngine.drainMouse();
}
bool settingsChanged = protocol.handleSerial();
if (!hidCritical) {
// Drain RS232 RX into the terminal buffer when the host-side terminal is open
protocol.pollRS232();
}
if (settingsChanged) {
M5.BtnA.setHoldThresh(settingsManager.settings.holdMs);
}
if (protocol.needsRefresh()) {
if (currentMacroIdx >= storage.macroCount) {
currentMacroIdx = 0;
}
_liveScreenShown = false;
_liveIdentifyShown = false;
showCurrentMacro();
}
// ---- ESP-NOW mesh (live-keyboard transport) ----
//
// The mesh replaced the old per-device live-BLE channel. While idle
// the device passively listens on the mesh channel so the hub (the
// USB-attached unit the host app drives) can discover and JOIN it
// WITHOUT any on-device gesture. Policy mirrors the old BLE one:
//
// * Profile uploads over USB (isUploadActive) and a USB-connected
// host app keep the radio OFF — unless the host explicitly made
// us the hub (espnow_hub command), which overrides isHostConnected.
// * A running routine owns the device for HID, so the radio is torn
// down the moment a routine starts (below). This also guarantees
// the on-demand BLE variables exchange never coexists with WiFi.
// * 10-second boot grace, skipped when the power-loss resume flag is
// set so a host session reconnects without delay. A button hold
// cancels the resume for this boot (idle UI below).
//
// Security: JOIN must decrypt under this device's AES key and the
// keystroke stream under the session group key — a radio-local
// attacker can never inject input.
espnowManager.tick();
// Bring up whichever live radio settings.liveTransport selects (never
// both — BLE and WiFi contend on the S3). The hub path is unaffected:
// it's entered only over USB and owns the radio while active.
static const uint32_t LIVE_LISTEN_BOOT_GRACE_MS = 10000;
bool bleLiveUp = bleManager.isBLEActive() &&
bleManager.exchangeKind() == BLEManager::EX_LIVE;
bool resumeReq = liveResumePending() && !_liveResumeCancelled;
if (espnowManager.isHub()) {
// Host-driven bridge. It auto-reverts to OFF if the host app goes
// quiet (see espnow_manager.h), so nothing to police here.
} else if (protocol.isHostConnected() || protocol.isUploadActive()) {
// A USB host app is talking to us — this is the configuring
// computer, not a remote-resume scenario. Drop the resume request
// and keep both live radios off for the rest of the boot.
espnowManager.consumeResume();
bleManager.consumeLiveResume();
if (espnowManager.isRadioActive() || bleLiveUp) {
liveShutdownRadios();
_liveScreenShown = false;
_liveIdentifyShown = false;
_resumeScreenShown = false;
}
} else if (!engine.isRunning() && !_liveResumeCancelled &&
(resumeReq || millis() > LIVE_LISTEN_BOOT_GRACE_MS)) {
if (liveIsBle()) {
// Ensure the mesh radio is down, then advertise BLE live.
if (espnowManager.isRadioActive()) espnowManager.shutdown();
bleManager.startLive(); // idempotent
} else {
// Ensure BLE live is down, then idle-listen on the mesh.
if (bleLiveUp) bleManager.stopLive();
espnowManager.startNodeListen(); // idempotent
}
}
// Don't process button for macro selection while receiving data
if (protocol.isBusy()) return;
if (engine.isRunning()) {
engine.tick(settingsManager.settings.typeDelay);
if (M5.BtnA.wasClicked()) {
engine.onButtonClick();
}
if (M5.BtnA.wasHold()) {
engine.onButtonHold();
}
// If engine just finished, return to macro selector
if (!engine.isRunning()) {
_liveScreenShown = false;
_liveIdentifyShown = false;
showCurrentMacro();
}
} else {
// Idle. Hub mode first: the host app drives everything over USB;
// we only show which unit is the hub and how many nodes it sees.
if (espnowManager.isHub()) {
if (!_hubScreenShown ||
espnowManager.hubNodesVer() != _hubRosterVerShown) {
display.showHubMode(espnowManager.hubNodeCount());
_hubRosterVerShown = espnowManager.hubNodesVer();
_hubScreenShown = true;
_liveScreenShown = false;
_liveIdentifyShown = false;
}
(void)M5.BtnA.wasClicked();
(void)M5.BtnA.wasHold();
return;
}
if (_hubScreenShown) {
_hubScreenShown = false;
showCurrentMacro();
}
// Joined a live session (on whichever transport is active): show
// the live-mode screen and let the host drive. The only on-device
// action is a long-press, which overrides into running the
// currently-selected routine.
bool liveConnected = liveSessionActive();
if (liveConnected) {
// Joined — the power-loss resume is satisfied; clear the
// one-shot boot request so later grace logic is normal.
liveConsumeResume();
_resumeScreenShown = false;
if (liveIdentifyActive()) {
// Host is asking us to identify ourselves so the user can
// label this specific device — draw the Bluetooth logo
// (blue/white LED flash on a Lite).
if (!_liveIdentifyShown) {
display.showBluetoothLogo();
_liveIdentifyShown = true;
_liveScreenShown = false; // force a status repaint after
_liveLastStatus = nullptr;
}
} else {
_liveIdentifyShown = false;
const char* status = liveStatusStr();
uint32_t lblVer = liveLabelVerNum();
if (!_liveScreenShown || status != _liveLastStatus ||
lblVer != _liveLabelVerShown) {
display.showLiveMode(status, liveLabelStr());
_liveLastStatus = status;
_liveLabelVerShown = lblVer;
_liveScreenShown = true;
// Screenless mesh nodes: hub silence shows as the
// cyan/red "lost hub" pattern instead of live-idle.
if (!liveIsBle() && espnowManager.nodeLagging()) {
ledUI.lagging();
}
}
}
(void)M5.BtnA.wasClicked(); // consumed, ignored while a host drives
if (M5.BtnA.wasHold() && storage.macroCount > 0) {
// Engine activity overrides a live session: free the radio,
// then run the selected routine.
liveShutdownRadios();
_liveScreenShown = false;
_liveIdentifyShown = false;
int idx = currentMacroIdx % storage.macroCount;
int slot = storage.order[idx];
engine.startMacro(slot, storage.macros[slot].name);
}
return;
}
// No session yet. If we're auto-reconnecting after a power-loss
// (resume flag set, not cancelled), show the "Reconnecting..."
// screen and let a hold cancel it.
if (liveResumePending() && !_liveResumeCancelled) {
if (!_resumeScreenShown) {
display.showReconnecting();
_resumeScreenShown = true;
_liveScreenShown = false;
_liveIdentifyShown = false;
}
(void)M5.BtnA.wasClicked();
if (M5.BtnA.wasHold()) {
// Cancel the auto-reconnect: radios off for the rest of
// this boot. A power cycle re-enables it.
_liveResumeCancelled = true;
liveConsumeResume();
liveShutdownRadios();
_resumeScreenShown = false;
showCurrentMacro();
}
return;
}
// No session. Repaint the selector once if we were just showing
// the live screen, then handle the normal gestures.
if (_liveScreenShown || _liveIdentifyShown || _resumeScreenShown) {
_liveScreenShown = false;
_liveIdentifyShown = false;
_resumeScreenShown = false;
_liveLastStatus = nullptr;
showCurrentMacro();
}
// Screen-button gestures (idle selector / listening):
// click -> next macro
// hold 0.5s .. <5s -> run the selected routine
// hold >= 5s -> toggle the live transport (mesh <-> BLE)
// The run gesture is classified on RELEASE so a long transport-toggle
// hold has room to complete without the routine firing at ~0.5 s.
if (M5.BtnA.pressedFor(MODE_SWITCH_HOLD_MS)) {
if (!_modeSwitchArmed) {
_modeSwitchArmed = true; // fire once for this press
toggleLiveTransport();
}
return; // hold still in progress
}
if (M5.BtnA.wasReleased()) {
_modeSwitchArmed = false; // ready for the next press
}
if (M5.BtnA.wasClicked()) {
if (storage.macroCount > 0) {
currentMacroIdx = (currentMacroIdx + 1) % storage.macroCount;
}
showCurrentMacro();
}
if (M5.BtnA.wasReleasedAfterHold() &&
!M5.BtnA.wasReleaseFor(MODE_SWITCH_HOLD_MS) &&
storage.macroCount > 0) {
// Short hold released (not the 5 s toggle): tear both live radios
// down so WiFi/BLE is fully off for HID emission (and for any BLE
// variables node the routine may hit), then run.
liveShutdownRadios();
_liveScreenShown = false;
int slot = storage.order[currentMacroIdx];
engine.startMacro(slot, storage.macros[slot].name);
}
}
}
void showCurrentMacro() {
if (storage.macroCount == 0) {
display.showMessage("No Macros");
return;
}
if (currentMacroIdx >= storage.macroCount) currentMacroIdx = 0;
int slot = storage.order[currentMacroIdx];
display.showMacroSelector(slot, storage.macros[slot].name, currentMacroIdx, storage.macroCount,
resolveColor(storage.macros[slot].labelColor),
settingsManager.settings.liveTransport == LIVE_TX_BLE);
}