Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
+592
View File
@@ -0,0 +1,592 @@
#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);
}
+114
View File
@@ -0,0 +1,114 @@
#pragma once
#include "USBHID.h"
#include <string.h>
// Absolute-position mouse HID device.
//
// The stock USBHIDMouse is RELATIVE (it reports dx/dy deltas), which
// accumulates error and drifts the remote cursor out of sync over a lossy
// BLE link. This device reports ABSOLUTE coordinates (0..32767 spanning the
// target's screen) so every report fully specifies where the cursor is — a
// dropped report just means the next one re-pins the position, never drift.
// That's exactly what the BT Keyboard virtual trackpad needs to stay synced
// across many devices.
//
// Report payload (the 1-byte report ID is prepended by USBHID::SendReport):
// byte 0 : buttons bitmask (bit0 left, bit1 right, bit2 middle)
// bytes 1-2 : X (uint16 little-endian, 0..32767)
// bytes 3-4 : Y (uint16 little-endian, 0..32767)
// byte 5 : wheel (int8, relative scroll tick)
#define ABS_MOUSE_REPORT_ID 0x0A
#define ABS_MOUSE_BTN_LEFT 0x01
#define ABS_MOUSE_BTN_RIGHT 0x02
#define ABS_MOUSE_BTN_MIDDLE 0x04
#define ABS_MOUSE_MAX 32767
class AbsoluteMouse : public USBHIDDevice {
public:
AbsoluteMouse() : _hid(), _buttons(0), _x(0), _y(0) {
static bool initialized = false;
if (!initialized) {
initialized = true;
uint16_t len = 0;
_descriptor(&len);
_hid.addDevice(this, len);
}
}
void begin() { _hid.begin(); }
// Called by the TinyUSB stack to fetch our report descriptor.
uint16_t _onGetDescriptor(uint8_t* buffer) override {
uint16_t len = 0;
const uint8_t* d = _descriptor(&len);
memcpy(buffer, d, len);
return len;
}
bool ready() { return _hid.ready(); }
// Emit one absolute report. ``x``/``y`` are 0..ABS_MOUSE_MAX; ``wheel``
// is a relative scroll tick. Returns the SendReport result.
bool report(uint8_t buttons, uint16_t x, uint16_t y, int8_t wheel) {
if (x > ABS_MOUSE_MAX) x = ABS_MOUSE_MAX;
if (y > ABS_MOUSE_MAX) y = ABS_MOUSE_MAX;
_buttons = buttons;
_x = x;
_y = y;
uint8_t r[6];
r[0] = buttons;
r[1] = (uint8_t)(x & 0xFF);
r[2] = (uint8_t)((x >> 8) & 0xFF);
r[3] = (uint8_t)(y & 0xFF);
r[4] = (uint8_t)((y >> 8) & 0xFF);
r[5] = (uint8_t)wheel;
return _hid.SendReport(ABS_MOUSE_REPORT_ID, r, sizeof(r));
}
private:
USBHID _hid;
uint8_t _buttons;
uint16_t _x, _y;
static const uint8_t* _descriptor(uint16_t* outLen) {
static const uint8_t d[] = {
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
0x85, ABS_MOUSE_REPORT_ID, // Report ID
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x03, // Usage Maximum (3)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x95, 0x03, // Report Count (3)
0x75, 0x01, // Report Size (1)
0x81, 0x02, // Input (Data,Var,Abs)
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x03, // Input (Const) - padding
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x16, 0x00, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
0x75, 0x10, // Report Size (16)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
0x09, 0x38, // Usage (Wheel)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x06, // Input (Data,Var,Rel)
0xC0, // End Collection
0xC0 // End Collection
};
*outLen = sizeof(d);
return d;
}
};
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <LittleFS.h>
#include <esp_random.h>
// Persistent 32-byte AES-256-GCM key for BLE payload encryption.
// Generated on first boot, stored at /ble.keyfile, pulled to the host
// during every profile upload via the get_ble_key serial command.
class BLEKeyStore {
public:
static constexpr size_t KEY_LEN = 32;
static constexpr const char* KEY_PATH = "/ble.keyfile";
// Loads the key from LittleFS, or generates and persists a new one
// if no key exists. Assumes LittleFS is already mounted.
bool begin() {
if (LittleFS.exists(KEY_PATH)) {
File f = LittleFS.open(KEY_PATH, "r");
if (f && f.size() == KEY_LEN && f.read(_key, KEY_LEN) == KEY_LEN) {
f.close();
_loaded = true;
Serial.println("[BLE] Key loaded");
return true;
}
if (f) f.close();
// Corrupt or wrong-size file — regenerate.
}
esp_fill_random(_key, KEY_LEN);
File f = LittleFS.open(KEY_PATH, "w");
if (!f) {
Serial.println("[BLE] Failed to open key file for write");
return false;
}
size_t wrote = f.write(_key, KEY_LEN);
f.close();
if (wrote != KEY_LEN) {
Serial.println("[BLE] Failed to write full key");
return false;
}
_loaded = true;
Serial.println("[BLE] Generated new key");
return true;
}
bool hasKey() const { return _loaded; }
const uint8_t* key() const { return _key; }
private:
uint8_t _key[KEY_LEN] = {0};
bool _loaded = false;
};
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
#pragma once
#define FW_VERSION "1.1.0"
#define DEVICE_ID "ATOMS3-MACROPAD"
// Universal binary: the same build runs on the AtomS3 (LCD) and the
// AtomS3 Lite (no LCD, SK6812 RGB LED on GPIO 35 — driven by M5.Led,
// which M5Unified wires up from its board pin table). The board is
// detected at boot via M5GFX panel autodetect + M5.getBoard(); these
// names are what cmdPing reports to the host app.
#define BOARD_NAME_ATOMS3 "atoms3"
#define BOARD_NAME_ATOMS3_LITE "atoms3_lite"
// Display (AtomS3; all rendering is gated off on the Lite)
#define SCREEN_W 128
#define SCREEN_H 128
#define IMG_SIZE (SCREEN_W * SCREEN_H * 2) // RGB565 = 32768 bytes
// Default settings
#define DEFAULT_HOLD_MS 500
#define DEFAULT_TYPE_DELAY 15
#define DEFAULT_ORIENTATION 0
// Serial protocol
#define SERIAL_BAUD 115200
#define CMD_BUF_SIZE 4096
#define JSON_DOC_SIZE 8192
// LittleFS paths
#define CONFIG_PATH "/config.json"
#define MACRO_DIR_PREFIX "/m"
// Timing
#define DEBOUNCE_MS 10
#define COMBO_KEY_PRE_DELAY 10 // ms between individual modifier presses within a combo
#define COMBO_KEY_POST_DELAY 25 // ms hold time after all keys pressed before release
// Default timing settings (user-configurable via GUI)
#define DEFAULT_COMBO_PRE_MS 500 // ms delay before sending a key combo
#define DEFAULT_COMBO_POST_MS 500 // ms delay after sending a key combo
#define DEFAULT_PROBE_TIMEOUT_MS 300 // ms to wait for host LED response
#define DEFAULT_MEDIA_HOLD_MS 100 // ms to hold media key before release
#define DEFAULT_TYPE_SHIFT_EXTRA_MS 25 // extra ms per shifted char (uppercase, !@#$ etc.)
#define DEFAULT_TYPE_SETTLE_MS 150 // ms to wait after the last char to let HID reports drain
#define DEFAULT_TYPE_HOLD_MIN_MS 8 // floor for keydown-to-keyup hold; raised when a host needs longer poll-cycle observation
#define DEFAULT_TYPE_INTER_CHAR_MS 5 // floor for gap between consecutive characters; raised for slow/remote hosts that drop fast input
// Pause-screen text margins (host-configurable via GUI). Padding the text
// box from each screen edge — used by drawWrapped() in display_ui.h.
#define DEFAULT_PAUSE_MARGIN_LEFT 4
#define DEFAULT_PAUSE_MARGIN_RIGHT 4
#define DEFAULT_PAUSE_MARGIN_TOP 16
#define DEFAULT_PAUSE_MARGIN_BOTTOM 12
// Max limits
#define MAX_MACROS 40
#define MAX_NODES_PER_MACRO 200
#define MAX_BRANCH_CHOICES 20
#define MAX_LOOPS 16 // per-macro max number of Loop nodes (for iteration tracking)
// RS232 via Atomic RS232 Base (MAX232)
#define RS232_RX_PIN 5
#define RS232_TX_PIN 6
#define RS232_BUF_SIZE 256
// Resume settings
#define DEFAULT_RESUME_DELAY 0 // seconds, 0 = disabled
// Legacy single-file path — kept ONLY so clearExecutionState() can sweep any
// stale file left over from older firmware. The live save path uses the
// A/B double-buffer + sentinel below.
#define RESUME_STATE_PATH "/resume.json"
// Double-buffer + sentinel scheme: each save writes the inactive slot
// (A or B), then flips the 1-byte sentinel. There is no window where zero
// valid resume files exist on disk. Both files carry a monotonic `seq` and
// a CRC32; reader prefers the sentinel-chosen file but falls back to "any
// file that deserializes AND CRC-matches, highest seq wins" if the
// sentinel is missing/garbage.
#define RESUME_STATE_A_PATH "/resume.a.json"
#define RESUME_STATE_B_PATH "/resume.b.json"
#define RESUME_STATE_IDX_PATH "/resume.idx" // 1 byte: 'A' or 'B'
// Sub-routines
#define MAX_SUBROUTINES 20
#define MAX_SUB_CALL_DEPTH 4
#define SUB_DIR_PREFIX "/sub/s"
// BLE variable sync
#define BLE_SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define BLE_VARS_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" // host -> device (write)
#define BLE_VARS_NOTIFY_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a9" // device -> host (notify)
// BLE live-keystroke streaming (separate characteristics on the same
// service so a single advertisement covers both protocols; see
// ble_live.py for the binary frame format)
#define BLE_LIVE_KEYS_WRITE_UUID "4fafc202-1fb5-459e-8fcc-c5c9c331914b" // host -> device (WWR)
#define BLE_LIVE_KEYS_NOTIFY_UUID "4fafc203-1fb5-459e-8fcc-c5c9c331914b" // device -> host (notify)
// Advertised service UUID specifically for live mode. The device advertises
// THIS UUID (instead of BLE_SERVICE_UUID) when it's in EX_LIVE so the host's
// var-sync and live-keystroke scanners filter to disjoint device sets —
// they never race for the same connection. The actual GATT characteristics
// still live in the same internal service (Bleak discovers characteristics
// by UUID regardless of advertised service).
#define BLE_LIVE_SERVICE_UUID "4fafc204-1fb5-459e-8fcc-c5c9c331914b"
#define MAX_BLE_VARS 32
#define BLE_VAR_NAME_LEN 32
#define BLE_VAR_VALUE_LEN 256
#define BLE_VAR_BUF_SIZE 512 // max plaintext+overhead per frame
#define BLE_FRAME_BUF_SIZE 768 // tag(<=64) + nonce(12) + ct+tag(BLE_VAR_BUF_SIZE+16)
#define BLE_DEVICE_TAG_PREFIX "M5Stack|"
#define BLE_DEVICE_TAG_MAX 40 // "M5Stack|AA:BB:CC:DD:EE:FF" + slack
#define BLE_DEV_VARS_PATH "/ble_dev_vars.json"
#define BLE_UNI_VARS_PATH "/ble_uni_vars.json"
#define BLE_REPLAY_STATE_PATH "/ble_replay.json"
// 1-byte flag: '1' while a live keystroke session is active. If power is
// lost mid-session it survives to the next boot, which uses it to skip the
// BLE boot grace and immediately re-advertise for the host to reconnect.
#define BLE_LIVE_RESUME_PATH "/live_resume.flag"
// ---------------------------------------------------------------------------
// ESP-NOW mesh (live keyboard transport)
// ---------------------------------------------------------------------------
// All nodes idle-listen on a fixed WiFi channel (STA mode, no AP
// association anywhere). One USB-attached device is switched into hub
// mode by the host app and bridges USB-CDC <-> ESP-NOW broadcast.
#define DEFAULT_MESH_CHANNEL 1 // 1-13, persisted in NVS ("mesh_ch")
// ---------------------------------------------------------------------------
// Live keyboard transport selection
// ---------------------------------------------------------------------------
// Which radio a device brings up when idle to receive a live-keyboard
// session. Only one is ever up at a time (BLE and WiFi/ESP-NOW contend on
// the ESP32-S3). Persisted in NVS ("live_tx") and toggled on-device by
// holding the screen button for MODE_SWITCH_HOLD_MS while idle.
// LIVE_TX_MESH — idle-listen on the ESP-NOW mesh (the hub broadcasts).
// LIVE_TX_BLE — advertise the BLE live service for direct host links.
#define LIVE_TX_MESH 0
#define LIVE_TX_BLE 1
#define DEFAULT_LIVE_TRANSPORT LIVE_TX_MESH
// Screen-button hold time (ms) that toggles the transport while idle. Well
// above the routine-run hold (settings.holdMs, ~500 ms) so the two gestures
// are unambiguous — the run gesture is classified on release, below 5 s.
#define MODE_SWITCH_HOLD_MS 5000
// Transport header (plaintext, precedes the encrypted ble_frame envelope)
#define MESH_MAGIC 0xE5
#define MESH_HDR_LEN 14
// Hub -> nodes (broadcast)
#define MESH_T_DATA 0x01 // reliable lane (group-key ble_frame)
#define MESH_T_DATA_U 0x02 // unreliable lane: pure mouse moves
#define MESH_T_JOIN 0x03 // per-device-key ble_frame (session invite)
#define MESH_T_POLL 0x04 // discovery poll (plaintext)
// Nodes -> hub (unicast)
#define MESH_T_BEACON 0x81 // discovery reply (plaintext identity)
#define MESH_T_ACK 0x82 // cumulative ack (group key)
#define MESH_T_JOIN_ACK 0x83 // join accepted (per-device key)
#define MESH_T_NACK 0x84 // missing-range report (group key)
#define MESH_T_ERR 0x85 // error report (group key)
// Transport flags
#define MESH_F_RETX 0x01 // retransmission
// Reliability tuning (see espnow_manager.h)
#define MESH_RING_FRAMES 128 // hub retransmit ring (power of two)
#define MESH_RING_SLOT 256 // max cached DATA frame size
#define MESH_REORDER_SLOTS 32 // node-side out-of-order buffer
#define MESH_ACK_EVERY_N 16 // ack at least every N frames...
#define MESH_ACK_MAX_DELAY_MS 50 // ...or this long after first unacked
#define MESH_NACK_AFTER_MS 8 // gap age before first NACK
#define MESH_NACK_REPEAT_MS 30 // re-NACK while gap persists
#define MESH_RETX_MIN_GAP_MS 15 // hub per-seq retransmit rate limit
#define MESH_STALL_REBCAST_MS 60 // hub proactive rebroadcast on stall
#define MESH_NODE_OFFLINE_MS 1500 // hub marks node offline after silence
#define MESH_HUB_HOST_TIMEOUT_MS 5000 // hub reverts to node w/o host traffic
#define MESH_BEACON_LABEL_LEN 24
// CDC binary bridge framing (host <-> hub); JSON lines keep working in
// parallel — the dispatcher peeks at the first byte.
#define HUB_MAGIC0 0xC8
#define HUB_MAGIC1 0x35
#define HUB_H2D_SEND 0x01 // payload = complete mesh frame
#define HUB_D2H_RX 0x81 // src_mac[6] + received node frame
#define HUB_D2H_ACKTAB 0x82 // periodic per-node ack table
#define HUB_ACKTAB_PERIOD_MS 250
#define HUB_MAX_FRAME 1500
+140
View File
@@ -0,0 +1,140 @@
#pragma once
#include <LittleFS.h>
#include "config.h"
// Rolling debug log stored in LittleFS at /debug.log
// Format: one JSON line per entry: {"t":<millis>,"m":"message"}
// Max MAX_LOG_ENTRIES entries; oldest are trimmed on save.
#define LOG_FILE "/debug.log"
#define MAX_LOG_ENTRIES 250
#define MAX_LOG_MSG 128
class DebugLog {
public:
void begin() {
_entryCount = 0;
if (LittleFS.exists(LOG_FILE)) {
File f = LittleFS.open(LOG_FILE, "r");
if (f) {
while (f.available()) {
String line = f.readStringUntil('\n');
line.trim();
if (line.length() > 0) _entryCount++;
}
f.close();
}
}
}
void log(const char* msg) {
// Mirror to serial for live debugging
Serial.printf("[LOG] %s\n", msg);
if (_entryCount >= MAX_LOG_ENTRIES) {
trimLog();
}
File f = LittleFS.open(LOG_FILE, "a");
if (!f) return;
char escaped[MAX_LOG_MSG * 2];
escapeJson(msg, escaped, sizeof(escaped));
f.printf("{\"t\":%lu,\"m\":\"%s\"}\n", millis(), escaped);
f.close();
_entryCount++;
}
void logf(const char* fmt, ...) {
char buf[MAX_LOG_MSG];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
log(buf);
}
// Send the entire log as a JSON array over serial (handles "get_log" cmd).
void sendOverSerial() {
Serial.print("{\"rsp\":\"log\",\"entries\":[");
Serial.flush();
if (LittleFS.exists(LOG_FILE)) {
File f = LittleFS.open(LOG_FILE, "r");
if (f) {
bool first = true;
while (f.available()) {
String line = f.readStringUntil('\n');
line.trim();
if (line.length() == 0) continue;
if (!first) Serial.print(",");
Serial.print(line);
first = false;
Serial.flush();
}
f.close();
}
}
Serial.println("]}");
Serial.flush();
}
void clear() {
LittleFS.remove(LOG_FILE);
_entryCount = 0;
}
private:
int _entryCount = 0;
// Drop the oldest half of the log when MAX_LOG_ENTRIES is hit.
void trimLog() {
File f = LittleFS.open(LOG_FILE, "r");
if (!f) return;
String lines[MAX_LOG_ENTRIES];
int count = 0;
while (f.available() && count < MAX_LOG_ENTRIES) {
String line = f.readStringUntil('\n');
line.trim();
if (line.length() > 0) {
lines[count++] = line;
}
}
f.close();
int keepFrom = count / 2;
File out = LittleFS.open(LOG_FILE, "w");
if (!out) return;
for (int i = keepFrom; i < count; i++) {
out.println(lines[i]);
}
out.close();
_entryCount = count - keepFrom;
}
void escapeJson(const char* input, char* output, size_t maxLen) {
size_t o = 0;
for (const char* p = input; *p && o < maxLen - 7; p++) {
unsigned char c = (unsigned char)*p;
if (c == '"' || c == '\\') {
output[o++] = '\\';
output[o++] = c;
} else if (c == '\n') {
output[o++] = '\\'; output[o++] = 'n';
} else if (c == '\r') {
output[o++] = '\\'; output[o++] = 'r';
} else if (c == '\t') {
output[o++] = '\\'; output[o++] = 't';
} else if (c < 0x20) {
o += snprintf(output + o, maxLen - o, "\\u%04x", c);
} else {
output[o++] = c;
}
}
output[o] = '\0';
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
#pragma once
// Shared AES-256-GCM frame envelope — the single wire format used by the
// BLE variables/live channels AND the ESP-NOW mesh payloads. Mirrors
// ble_frame.py on the host exactly:
//
// [0] tag_len
// [1..tag_len] device tag, e.g. "M5Stack|AA:BB:CC:DD:EE:FF" (also AAD)
// [+12] nonce (random, per frame)
// [...] ciphertext + 16-byte GCM tag
//
// Extracted from BLEManager so the mesh layer can encrypt under a
// per-session group key while BLE keeps using the per-device key — the
// only difference between the callers is which 32-byte key they pass in.
#include <Arduino.h>
#include <mbedtls/gcm.h>
#include <esp_random.h>
#include "config.h"
static constexpr size_t FRAME_NONCE_LEN = 12;
static constexpr size_t FRAME_GCM_TAG_LEN = 16;
static constexpr size_t FRAME_KEY_LEN = 32; // AES-256
// Encrypt `plaintext` under `key` with `tag` as both header and AAD.
inline bool frameCryptoBuild(const uint8_t* key, const char* tag,
const uint8_t* plaintext, size_t plainLen,
uint8_t* out, size_t outCap, size_t* outLen) {
if (!key || !tag) return false;
size_t tagStrLen = strlen(tag);
if (tagStrLen == 0 || tagStrLen > 255) return false;
size_t total = 1 + tagStrLen + FRAME_NONCE_LEN + plainLen + FRAME_GCM_TAG_LEN;
if (total > outCap) return false;
out[0] = (uint8_t)tagStrLen;
memcpy(out + 1, tag, tagStrLen);
uint8_t* nonce = out + 1 + tagStrLen;
uint8_t* ct = nonce + FRAME_NONCE_LEN;
uint8_t* gcmTag = ct + plainLen;
// 12-byte nonce sourced from esp_random (CSPRNG).
for (size_t i = 0; i < FRAME_NONCE_LEN; i += 4) {
uint32_t r = esp_random();
for (size_t b = 0; b < 4 && i + b < FRAME_NONCE_LEN; b++) {
nonce[i + b] = (uint8_t)(r >> (b * 8));
}
}
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key,
FRAME_KEY_LEN * 8);
if (rc == 0) {
rc = mbedtls_gcm_crypt_and_tag(
&gcm, MBEDTLS_GCM_ENCRYPT, plainLen,
nonce, FRAME_NONCE_LEN,
(const uint8_t*)tag, tagStrLen,
plaintext, ct,
FRAME_GCM_TAG_LEN, gcmTag);
}
mbedtls_gcm_free(&gcm);
if (rc != 0) return false;
*outLen = total;
return true;
}
// Verify and decrypt an inbound frame under `key`. Writes the recovered
// tag (NUL-terminated) and plaintext into the caller's buffers. Returns
// false silently on any malformed or auth-failed input.
inline bool frameCryptoParse(const uint8_t* key,
const uint8_t* in, size_t inLen,
char* outTag, size_t outTagCap,
uint8_t* outPlain, size_t outPlainCap,
size_t* outPlainLen) {
if (!key) return false;
if (inLen < 1 + FRAME_NONCE_LEN + FRAME_GCM_TAG_LEN) return false;
size_t tagLen = in[0];
if (tagLen == 0 || tagLen >= outTagCap) return false;
if (inLen < 1 + tagLen + FRAME_NONCE_LEN + FRAME_GCM_TAG_LEN) return false;
memcpy(outTag, in + 1, tagLen);
outTag[tagLen] = '\0';
// Reject anything not starting with our prefix early so we don't
// burn cycles on adversarial input.
if (strncmp(outTag, BLE_DEVICE_TAG_PREFIX,
sizeof(BLE_DEVICE_TAG_PREFIX) - 1) != 0) {
return false;
}
const uint8_t* nonce = in + 1 + tagLen;
size_t ctLen = inLen - 1 - tagLen - FRAME_NONCE_LEN - FRAME_GCM_TAG_LEN;
if (ctLen >= outPlainCap) return false;
const uint8_t* ct = nonce + FRAME_NONCE_LEN;
const uint8_t* gcmTag = ct + ctLen;
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key,
FRAME_KEY_LEN * 8);
if (rc == 0) {
rc = mbedtls_gcm_auth_decrypt(&gcm, ctLen,
nonce, FRAME_NONCE_LEN,
(const uint8_t*)outTag, tagLen,
gcmTag, FRAME_GCM_TAG_LEN,
ct, outPlain);
}
mbedtls_gcm_free(&gcm);
if (rc != 0) return false;
*outPlainLen = ctLen;
return true;
}
+380
View File
@@ -0,0 +1,380 @@
#pragma once
// LedUI — RGB-LED status feedback for screenless boards (AtomS3 Lite).
//
// The universal firmware binary runs on both the AtomS3 (128x128 LCD) and
// the AtomS3 Lite (no LCD, one SK6812 RGB LED on GPIO 35, driven by
// M5Unified's M5.Led which M5.begin() wires up automatically from the
// board pin table). DisplayUI gates every screen call on the Lite and
// forwards a semantic state here instead, so macro_engine.h and
// MacroPad.ino never have to know which board they're on.
//
// Design rules:
//
// 1. Non-blocking. tick() is called every main-loop iteration and
// computes the LED color from millis(); no delay() anywhere. The
// strip is only rewritten when the computed color changes (an RMT
// refresh per loop would be pure waste).
//
// 2. Idempotent state setters. Many display calls repaint every engine
// tick (showDelayProgress, showPauseScreen, showFailScreen). Setting
// the same base pattern again must NOT reset the blink phase, or the
// LED would freeze at "on". setBase() compares against the current
// pattern and keeps the phase when nothing changed.
//
// 3. Base + overlay. The base pattern is the persistent state (idle
// color, executing, live mode). An overlay is a short transient
// (click flash, position burst, alive-check result) that plays once
// and reveals the base again. Overlays never change the base.
//
// LED vocabulary (see the project README for the user-facing table). Every
// state is a distinct (color, motion) pair; smooth crossfades mark the calm
// "what am I / who has me" states, sharper blinks and bursts mark action:
// boot white single pulse
// idle — BLE soft white<->blue crossfade (transport at rest)
// idle — mesh soft white<->amber crossfade (transport at rest)
// ...both: a click adds a white flash + N-blink slot count
// mode switched triple burst in the new accent (blue BLE / amber mesh)
// executing steady green; typing ramps brightness with progress;
// delay nodes breathe green
// pause steady yellow (untimed) / yellow blink, 1 Hz
// accelerating to 4 Hz in the last 3 s (timed)
// branch selector magenta burst, count = selected choice + 1, repeating
// loop selector orange burst, count = current value (capped at 10)
// error red triple-blink repeating
// fail wait red blink (steady red if paused)
// live joined slow cyan breathe (heartbeat); keystrokes = white flicker
// reconnecting cyan 1 Hz blink (seeking the host)
// lagging/lost hub cyan/red 2 Hz alternating
// identify fast blue/white strobe (which physical unit is this)
// hub mode steady purple
// BLE variables blue breathe (on-demand exchange inside a routine)
// host probe fast white blink; waiting-on-user = slow white blink
#include <M5Unified.h>
class LedUI {
public:
void begin(bool enabled) {
_enabled = enabled;
if (!_enabled) return;
M5.Led.setBrightness(255); // we scale in software per-pattern
setBase(Mode::OFF, 0, 0, 0);
}
bool enabled() const { return _enabled; }
// Palette (0xRRGGBB) — one place to tune the whole vocabulary. Each hue
// owns a phase: green = running, red = failure, yellow = pause, magenta =
// branch, orange = loop, cyan = live session, purple = hub, and the two
// transport accents (blue = BLE, amber = mesh) morph out of white.
static constexpr uint32_t COL_WHITE = 0xFFFFFF;
static constexpr uint32_t COL_BLE = 0x0060FF; // BLE transport accent
static constexpr uint32_t COL_MESH = 0xFFB000; // ESP-NOW mesh accent (amber)
static constexpr uint32_t COL_GREEN = 0x00FF00; // executing / running
static constexpr uint32_t COL_RED = 0xFF0000; // error / failure
static constexpr uint32_t COL_YELLOW = 0xFFDD00; // pause node
static constexpr uint32_t COL_MAGENTA = 0xFF00FF; // branch selector
static constexpr uint32_t COL_ORANGE = 0xFF6000; // loop selector
static constexpr uint32_t COL_CYAN = 0x00E0FF; // live session (joined)
static constexpr uint32_t COL_PURPLE = 0x9000FF; // hub mode
// Called every main-loop iteration. Cheap when nothing changes.
void tick() {
if (!_enabled) return;
uint32_t now = millis();
uint32_t rgb;
if (_ovActive) {
if (now - _ovStartMs >= _ovDurationMs) {
_ovActive = false;
rgb = _baseColorAt(now);
} else {
rgb = _patternColorAt(_ov, now - _ovStartMs);
}
} else {
rgb = _baseColorAt(now);
}
if (rgb != _lastWritten) {
_lastWritten = rgb;
M5.Led.setAllColor((uint8_t)(rgb >> 16), (uint8_t)(rgb >> 8),
(uint8_t)rgb);
}
}
// ---------------------------------------------------------------------
// Semantic states (all no-ops when disabled)
// ---------------------------------------------------------------------
void off() { setBase(Mode::OFF, 0, 0, 0); }
void boot() {
setBase(Mode::OFF, 0, 0, 0);
overlayPulse(0xFFFFFF, 35, 800);
}
// Idle selector / listening. The persistent base is a soft crossfade in
// the device's live transport: white<->blue = BLE, white<->amber = ESP-NOW
// mesh, so a resting headless node shows which mode it's in at a glance.
// A click/redraw still plays a white flash + position burst so the user
// can count which slot they're on.
void macroSelector(int displayIdx, bool bleMode) {
if (!_enabled) return;
setBase(Mode::FADE, COL_WHITE, 45, 2600, 0,
bleMode ? COL_BLE : COL_MESH);
int blinks = (displayIdx % 5) + 1;
overlayBurst(COL_WHITE, 70, blinks, 90, 120);
}
// Running a routine: steady green. typing() brightens it with progress
// (dim -> bright as the string types); a delay node breathes it. All three
// are "green = running", distinguished by motion.
void executing() { setBase(Mode::STEADY, COL_GREEN, 60); }
void typing(int charIdx, int len) {
if (len < 1) len = 1;
if (charIdx < 0) charIdx = 0;
if (charIdx >= len) charIdx = len - 1;
uint8_t scale = 20 + (uint8_t)((80 * charIdx) / len);
setBase(Mode::STEADY, COL_GREEN, scale);
}
void breathe() { setBase(Mode::BREATHE, COL_GREEN, 60, 2000); }
// Pause node: yellow. Steady = untimed (waiting on a click); blink that
// accelerates as the timer runs out = timed.
void pauseScreen(bool timed, uint32_t remainMs) {
if (!timed) {
setBase(Mode::STEADY, COL_YELLOW, 60);
} else if (remainMs > 3000) {
setBase(Mode::BLINK, COL_YELLOW, 60, 500, 500);
} else {
setBase(Mode::BLINK, COL_YELLOW, 60, 125, 125);
}
}
// Selectors count with blink-bursts: magenta = branch choice, orange =
// loop value. Both are the burst count + 1s gap, repeating.
void branchSelector(int selectedIdx) {
if (selectedIdx < 0) selectedIdx = 0;
setBaseBurst(COL_MAGENTA, 60, (uint8_t)(selectedIdx + 1), 1000);
}
void iterationBranch(int pathIdx) {
if (!_enabled) return;
overlayBurst(COL_MAGENTA, 60, (uint8_t)((pathIdx < 0 ? 0 : pathIdx) + 1),
120, 150);
}
void loopSelector(int value) {
if (value < 1) value = 1;
if (value > 10) value = 10;
setBaseBurst(COL_ORANGE, 60, (uint8_t)value, 1000);
}
// Error = red triple-blink. Resume countdown = fast green blink ("about to
// auto-run a routine; hold to cancel") — green, not yellow, so it can't be
// mistaken for a timed pause.
void errorPattern() { setBaseBurst(COL_RED, 80, 3, 700); }
void resumeCountdown(){ setBase(Mode::BLINK, COL_GREEN, 70, 150, 150); }
// Live-session states, all cyan-based and told apart by motion:
// reconnecting cyan blink (seeking the host after a power loss)
// liveIdle slow cyan breathe (joined & ready — a live "heartbeat")
// lagging cyan<->red alt (in a session but losing the hub)
void reconnecting() { setBase(Mode::BLINK, COL_CYAN, 55, 500, 500); }
void liveIdle() { setBase(Mode::BREATHE, COL_CYAN, 45, 3200); }
void lagging() { setBase(Mode::ALT, COL_CYAN, 60, 250, 250, COL_RED); }
// Identify ("which physical unit is this?") — a fast, deliberate
// white<->blue strobe, unmistakable against the slow BLE idle fade.
void identify() { setBase(Mode::ALT, COL_BLE, 70, 160, 160, COL_WHITE); }
void hubMode() { setBase(Mode::STEADY, COL_PURPLE, 60); }
// Live-transport toggle confirmation on a screenless Lite: a triple burst
// in the NEW mode's accent (blue = BLE, amber = mesh), matching the idle
// crossfade the node will now rest in.
void modeSwitch(bool ble) {
setBaseBurst(ble ? COL_BLE : COL_MESH, 75, 3, 500);
}
// White blinks: slow = waiting on the user (RS232/pause prompts),
// fast = actively probing the host (Num Lock alive check).
void waiting() { setBase(Mode::BLINK, COL_WHITE, 45, 500, 500); }
void probe() { setBase(Mode::BLINK, COL_WHITE, 50, 100, 100); }
// On-demand BLE variables exchange inside a routine: a blue breathe
// ("working on Bluetooth") — distinct from the BLE idle white<->blue fade.
void bleStatus() { setBase(Mode::BREATHE, COL_BLE, 50, 1400); }
void failWait(bool paused) {
if (paused) setBase(Mode::STEADY, COL_RED, 40);
else setBase(Mode::BLINK, COL_RED, 60, 250, 250);
}
// Short white flicker over the live base — played as keystrokes drain so
// the user can see traffic flowing on a screenless node.
void liveActivity() { overlayPulse(COL_WHITE, 60, 30); }
// Generic per-node transient (key combo, mouse, media key, sub-call...)
void activityPulse(uint16_t color565) { overlayPulse(from565(color565), 60, 90); }
void aliveResult(bool ok) {
overlayBurst(ok ? COL_GREEN : COL_RED, 80, 2, 100, 120);
}
// showMessage mapping: red = persistent error pattern, anything else a
// steady dim tint (covers "No Macros", boot status text, etc.).
void message(uint16_t color565) {
if (color565 == TFT_RED) errorPattern();
else setBase(Mode::STEADY, from565(color565), 35);
}
private:
enum class Mode : uint8_t { OFF, STEADY, BLINK, ALT, BURST, BREATHE, FADE };
struct Pattern {
Mode mode = Mode::OFF;
uint32_t rgb = 0; // primary color, 0xRRGGBB
uint32_t rgb2 = 0; // ALT second color
uint8_t scale = 100; // brightness percent
uint16_t onMs = 0; // BLINK/ALT phase length; BREATHE period
uint16_t offMs = 0;
uint8_t count = 0; // BURST blink count
uint16_t gapMs = 0; // BURST gap after the blinks
};
bool _enabled = false;
Pattern _base;
uint32_t _baseStartMs = 0;
Pattern _ov;
bool _ovActive = false;
uint32_t _ovStartMs = 0;
uint32_t _ovDurationMs = 0;
uint32_t _lastWritten = 0xFFFFFFFF; // sentinel forces first write
static uint32_t from565(uint16_t c) {
uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3);
uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2);
uint8_t b = (uint8_t)((c & 0x1F) << 3);
return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
}
static uint32_t scaleRgb(uint32_t rgb, uint8_t pct) {
uint8_t r = (uint8_t)((((rgb >> 16) & 0xFF) * pct) / 100);
uint8_t g = (uint8_t)((((rgb >> 8) & 0xFF) * pct) / 100);
uint8_t b = (uint8_t)(((rgb & 0xFF) * pct) / 100);
return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
}
// Linear per-channel blend: f=0 -> c1, f=100 -> c2. Used by FADE for a
// smooth crossfade between two colors (e.g. white <-> blue).
static uint32_t mix(uint32_t c1, uint32_t c2, uint32_t f) {
if (f > 100) f = 100;
uint32_t g = 100 - f;
uint8_t r = (uint8_t)((((c1 >> 16) & 0xFF) * g + ((c2 >> 16) & 0xFF) * f) / 100);
uint8_t gr = (uint8_t)((((c1 >> 8) & 0xFF) * g + ((c2 >> 8) & 0xFF) * f) / 100);
uint8_t b = (uint8_t)(((c1 & 0xFF) * g + (c2 & 0xFF) * f) / 100);
return ((uint32_t)r << 16) | ((uint32_t)gr << 8) | b;
}
static bool samePattern(const Pattern& a, const Pattern& b) {
return a.mode == b.mode && a.rgb == b.rgb && a.rgb2 == b.rgb2 &&
a.scale == b.scale && a.onMs == b.onMs && a.offMs == b.offMs &&
a.count == b.count && a.gapMs == b.gapMs;
}
void setBase(Mode mode, uint32_t rgb, uint8_t scale,
uint16_t onMs = 0, uint16_t offMs = 0, uint32_t rgb2 = 0) {
if (!_enabled) return;
Pattern p;
p.mode = mode; p.rgb = rgb; p.rgb2 = rgb2; p.scale = scale;
p.onMs = onMs; p.offMs = offMs;
if (samePattern(p, _base)) return; // keep blink phase
_base = p;
_baseStartMs = millis();
}
void setBaseBurst(uint32_t rgb, uint8_t scale, uint8_t count, uint16_t gapMs) {
if (!_enabled) return;
Pattern p;
p.mode = Mode::BURST; p.rgb = rgb; p.scale = scale;
p.onMs = 120; p.offMs = 150; p.count = count; p.gapMs = gapMs;
if (samePattern(p, _base)) return;
_base = p;
_baseStartMs = millis();
}
void overlayPulse(uint32_t rgb, uint8_t scale, uint16_t durMs) {
if (!_enabled) return;
_ov.mode = Mode::STEADY; _ov.rgb = rgb; _ov.scale = scale;
_ovActive = true;
_ovStartMs = millis();
_ovDurationMs = durMs;
}
void overlayBurst(uint32_t rgb, uint8_t scale, uint8_t count,
uint16_t onMs, uint16_t offMs) {
if (!_enabled) return;
_ov.mode = Mode::BURST; _ov.rgb = rgb; _ov.scale = scale;
_ov.onMs = onMs; _ov.offMs = offMs; _ov.count = count; _ov.gapMs = 0;
_ovActive = true;
_ovStartMs = millis();
_ovDurationMs = (uint32_t)count * (onMs + offMs);
}
uint32_t _baseColorAt(uint32_t now) {
return _patternColorAt(_base, now - _baseStartMs);
}
uint32_t _patternColorAt(const Pattern& p, uint32_t t) {
switch (p.mode) {
case Mode::OFF:
return 0;
case Mode::STEADY:
return scaleRgb(p.rgb, p.scale);
case Mode::BLINK: {
uint32_t period = (uint32_t)p.onMs + p.offMs;
if (period == 0) return scaleRgb(p.rgb, p.scale);
return (t % period) < p.onMs ? scaleRgb(p.rgb, p.scale) : 0;
}
case Mode::ALT: {
uint32_t period = (uint32_t)p.onMs + p.offMs;
if (period == 0) return scaleRgb(p.rgb, p.scale);
return (t % period) < p.onMs ? scaleRgb(p.rgb, p.scale)
: scaleRgb(p.rgb2, p.scale);
}
case Mode::BURST: {
uint32_t blinkLen = (uint32_t)p.onMs + p.offMs;
uint32_t period = (uint32_t)p.count * blinkLen + p.gapMs;
if (period == 0) return 0;
uint32_t ph = t % period;
if (ph >= (uint32_t)p.count * blinkLen) return 0; // gap
return (ph % blinkLen) < p.onMs ? scaleRgb(p.rgb, p.scale) : 0;
}
case Mode::BREATHE: {
// Triangle wave between 10% and the pattern's scale.
uint32_t period = p.onMs ? p.onMs : 2000;
uint32_t ph = t % period;
uint32_t half = period / 2;
uint32_t frac100 = (ph < half) ? (ph * 100) / half
: ((period - ph) * 100) / half;
uint8_t lo = 10;
uint8_t span = (p.scale > lo) ? (p.scale - lo) : 0;
uint8_t s = lo + (uint8_t)((span * frac100) / 100);
return scaleRgb(p.rgb, s);
}
case Mode::FADE: {
// Smooth crossfade rgb <-> rgb2 on a triangle wave (period in
// onMs). Constant brightness (scale) — only the hue morphs.
uint32_t period = p.onMs ? p.onMs : 2600;
uint32_t ph = t % period;
uint32_t half = period / 2;
uint32_t f = (ph < half) ? (ph * 100) / half
: ((period - ph) * 100) / half;
return scaleRgb(mix(p.rgb, p.rgb2, f), p.scale);
}
}
return 0;
}
};
+250
View File
@@ -0,0 +1,250 @@
#pragma once
// LiveKeystrokeEngine — device-side queue and dispatcher for the
// "host streams keystrokes via BLE during macro recording" feature.
//
// Design rules:
//
// 1. BLE write callback runs on the NimBLE host task. It must NOT
// touch USB HID directly — radio contention with TinyUSB causes
// panics on ESP32-S3 (the very issue this whole subsystem is
// designed around). The callback only enqueues parsed events.
//
// 2. The main loop calls drainQueue() each iteration. The HID
// critical-section gate in usb_hid.h means BLE polling is
// already deferred while keystrokes emit, so by the time we
// get here it's safe to take over the USB radio.
//
// 3. pressRaw / releaseRaw are wrapped in beginCritical/endCritical
// so the main loop's gating logic observes the in-flight HID
// operation and defers BLE work — the existing protection
// pattern, reused.
//
// 4. Queue is small and fixed-size; if full we set a flag the
// BLE manager exposes as an ERROR notify back to the host.
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include "usb_hid.h"
class LiveKeystrokeEngine {
public:
static constexpr int QUEUE_CAP = 256;
// Fixed device-side replay buffer. The host sends each event with a
// host-monotonic timestamp; we emit at host_t + this offset so BLE
// jitter is absorbed and the cadence the user typed on the host is
// reproduced exactly on the target USB HID side.
static constexpr uint32_t REPLAY_BUFFER_MS = 100;
struct Event {
uint8_t action; // 0 = down, 1 = up
uint8_t hid; // raw USB HID usage code
uint32_t scheduledMs; // device millis() when this event should
// emit. Computed at enqueue time from the
// host's relative timestamp and the
// session anchor.
};
void begin(HIDController* hid) { _hid = hid; }
// Start/stop are called from the BLE manager when the host sends
// START / STOP control frames (or from MacroPad.ino on emergency
// exits). idempotent.
void start() {
portENTER_CRITICAL(&_mux);
_head = _tail = _count = 0;
_overflow = false;
_anchorSet = false;
_active = true;
portEXIT_CRITICAL(&_mux);
}
void stop() {
portENTER_CRITICAL(&_mux);
_active = false;
_head = _tail = _count = 0;
_anchorSet = false;
// Release any held mouse buttons defensively — queue a buttons=0
// report for the main loop to emit (don't touch USB HID here, this
// can run on the NimBLE callback task).
_mouseButtons = 0;
_mouseWheel = 0;
_mousePending = true;
portEXIT_CRITICAL(&_mux);
// releaseAll is best-effort defensive cleanup. Wrap in
// critical so the BLE poll gate observes it even though we
// ourselves are already on the main task here.
if (_hid) {
_hid->beginCritical();
_hid->keyboard.releaseAll();
_hid->endCritical();
}
}
bool isActive() const {
// Volatile read, no mutex — _active is set under mutex but
// read-only here. Worst case: one extra loop iteration.
return _active;
}
// Enqueue one event with the host's relative timestamp. Called from
// the NimBLE host task — keep it fast. Returns false if the queue
// is full (caller should ERROR-notify the host).
//
// We anchor on the first event of the session: _anchorMs becomes
// the device-millis() value that corresponds to host_t = 0. Each
// event's scheduledMs is then _anchorMs + host_t_ms + REPLAY_BUFFER_MS,
// which preserves the host's typing cadence and adds a small jitter
// buffer so events that arrive slightly out of cadence still emit
// smoothly.
bool enqueue(uint8_t action, uint8_t hid_code, uint32_t host_t_ms) {
bool ok = false;
portENTER_CRITICAL(&_mux);
if (_active && _count < QUEUE_CAP) {
if (!_anchorSet) {
// First event of the session: pin the anchor so this
// event's scheduled time is exactly now + buffer.
_anchorMs = millis() - host_t_ms;
_anchorSet = true;
}
_q[_tail].action = action;
_q[_tail].hid = hid_code;
_q[_tail].scheduledMs = _anchorMs + host_t_ms + REPLAY_BUFFER_MS;
_tail = (_tail + 1) % QUEUE_CAP;
_count++;
ok = true;
} else if (!_active) {
// not live; signal NOT_LIVE_MODE upstream
} else {
_overflow = true;
}
portEXIT_CRITICAL(&_mux);
return ok;
}
// Called from the main loop when !hid->isCritical(). Drains events
// whose scheduled time has passed; events with future scheduledMs
// stay in the queue so the host's typing cadence is preserved on
// emission. Up to maxPerTick events per call so a flood doesn't
// starve other main-loop work.
//
// We must not delay() here — the main loop owns timing for the
// 3-second button-hold exit, button polling, BLE callback flush.
// Anything not ready yet stays queued until the next loop iter.
// Returns the number of events emitted (drives the activity flicker
// on the screenless AtomS3 Lite).
int drainQueue(int maxPerTick = 32) {
if (!_hid || !_active) return 0;
if (_count == 0) return 0;
uint32_t now = millis();
// Peek head first — if it's not ready, nothing else is either
// (events are enqueued in monotonic schedule order).
bool headReady = false;
portENTER_CRITICAL(&_mux);
if (_count > 0) {
headReady = (int32_t)(_q[_head].scheduledMs - now) <= 0;
}
portEXIT_CRITICAL(&_mux);
if (!headReady) return 0;
_hid->beginCritical();
int emitted = 0;
while (emitted < maxPerTick) {
Event ev;
bool got = false;
portENTER_CRITICAL(&_mux);
if (_count > 0 && (int32_t)(_q[_head].scheduledMs - now) <= 0) {
ev = _q[_head];
_head = (_head + 1) % QUEUE_CAP;
_count--;
got = true;
}
portEXIT_CRITICAL(&_mux);
if (!got) break;
if (ev.action == 0) {
_hid->keyboard.pressRaw(ev.hid);
} else {
_hid->keyboard.releaseRaw(ev.hid);
}
emitted++;
}
_hid->endCritical();
return emitted;
}
// ---- Absolute mouse (BT Keyboard trackpad) ----
//
// Mouse state is kept separate from the keystroke queue and is NOT
// cadence-buffered — the trackpad wants low latency. We coalesce: only
// the latest position/buttons matter, and wheel ticks accumulate. The
// main loop drains the latest state with drainMouse(). Because absolute
// positions are self-correcting, dropping intermediate moves is fine.
// Callable from the NimBLE task — no USB HID here, just state under the
// spinlock.
void enqueueMouse(uint8_t buttons, uint16_t x, uint16_t y, int8_t wheel) {
portENTER_CRITICAL(&_mux);
_mouseButtons = buttons;
_mouseX = x;
_mouseY = y;
_mouseWheel += wheel;
_mousePending = true;
portEXIT_CRITICAL(&_mux);
}
// Called from the main loop (outside the HID-critical window). Emits the
// latest absolute pointer report if one is pending. No delay/scheduling.
void drainMouse() {
if (!_hid) return;
bool pending;
uint8_t buttons;
uint16_t x, y;
int wheel;
portENTER_CRITICAL(&_mux);
pending = _mousePending;
buttons = _mouseButtons;
x = _mouseX;
y = _mouseY;
wheel = _mouseWheel;
_mousePending = false;
_mouseWheel = 0;
portEXIT_CRITICAL(&_mux);
if (!pending) return;
int8_t w = (wheel > 127) ? 127 : (wheel < -127 ? -127 : (int8_t)wheel);
_hid->absMouseReport(buttons, x, y, w);
}
// Drained by the BLE manager when it builds the next status frame.
bool takeOverflowFlag() {
bool was;
portENTER_CRITICAL(&_mux);
was = _overflow;
_overflow = false;
portEXIT_CRITICAL(&_mux);
return was;
}
private:
HIDController* _hid = nullptr;
Event _q[QUEUE_CAP];
int _head = 0;
int _tail = 0;
int _count = 0;
volatile bool _active = false;
volatile bool _overflow = false;
// Anchor mapping host_t=0 to a specific device millis() value.
// Set on the first event of each session so subsequent events can
// schedule emissions relative to the host's typing cadence.
bool _anchorSet = false;
uint32_t _anchorMs = 0;
// Absolute mouse state (coalesced; see enqueueMouse/drainMouse).
volatile bool _mousePending = false;
uint8_t _mouseButtons = 0;
uint16_t _mouseX = 0;
uint16_t _mouseY = 0;
int _mouseWheel = 0;
portMUX_TYPE _mux = portMUX_INITIALIZER_UNLOCKED;
};
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
#pragma once
#include <LittleFS.h>
#include <ArduinoJson.h>
#include "config.h"
struct MacroInfo {
char name[64];
char labelColor[12];
int nodeCount;
bool hasImage;
};
struct SubInfo {
char name[64];
int nodeCount;
};
class MacroStorage {
public:
int macroCount = 0;
int order[MAX_MACROS];
MacroInfo macros[MAX_MACROS];
int subCount = 0;
SubInfo subs[MAX_SUBROUTINES];
bool begin() {
if (!LittleFS.begin(true)) {
return false;
}
memset(subs, 0, sizeof(subs));
loadIndex();
return true;
}
void loadIndex() {
macroCount = 0;
memset(order, 0, sizeof(order));
if (!LittleFS.exists(CONFIG_PATH)) {
saveIndex();
return;
}
File f = LittleFS.open(CONFIG_PATH, "r");
if (!f) return;
JsonDocument doc;
if (deserializeJson(doc, f) != DeserializationError::Ok) {
f.close();
return;
}
f.close();
macroCount = doc["count"] | 0;
JsonArray orderArr = doc["order"].as<JsonArray>();
for (int i = 0; i < macroCount && i < MAX_MACROS; i++) {
order[i] = orderArr[i] | i;
}
for (int i = 0; i < macroCount; i++) {
loadMacroMeta(order[i]);
}
}
void saveIndex() {
JsonDocument doc;
doc["count"] = macroCount;
JsonArray orderArr = doc["order"].to<JsonArray>();
for (int i = 0; i < macroCount; i++) {
orderArr.add(order[i]);
}
File f = LittleFS.open(CONFIG_PATH, "w");
if (f) {
serializeJson(doc, f);
f.close();
}
}
void loadMacroMeta(int slot) {
if (slot < 0 || slot >= MAX_MACROS) return;
char path[48];
snprintf(path, sizeof(path), "/m%d/meta.json", slot);
MacroInfo& info = macros[slot];
memset(&info, 0, sizeof(MacroInfo));
strcpy(info.name, "Unnamed");
strcpy(info.labelColor, "white");
if (!LittleFS.exists(path)) return;
File f = LittleFS.open(path, "r");
if (!f) return;
JsonDocument doc;
if (deserializeJson(doc, f) == DeserializationError::Ok) {
strlcpy(info.name, doc["name"] | "Unnamed", sizeof(info.name));
strlcpy(info.labelColor, doc["label_color"] | "white", sizeof(info.labelColor));
info.nodeCount = doc["nodes"] | 0;
}
f.close();
snprintf(path, sizeof(path), "/m%d/icon.raw", slot);
info.hasImage = LittleFS.exists(path);
}
bool beginMacroWrite(int slot, const char* name, int nodeCount, const char* labelColor = "white") {
if (slot < 0 || slot >= MAX_MACROS) return false;
char dir[16];
snprintf(dir, sizeof(dir), "/m%d", slot);
LittleFS.mkdir(dir);
char path[48];
snprintf(path, sizeof(path), "/m%d/meta.json", slot);
File f = LittleFS.open(path, "w");
if (!f) return false;
JsonDocument doc;
doc["name"] = name;
doc["label_color"] = labelColor;
doc["nodes"] = nodeCount;
serializeJson(doc, f);
f.close();
// Clear existing nodes file
snprintf(path, sizeof(path), "/m%d/nodes.json", slot);
File nf = LittleFS.open(path, "w");
if (nf) {
nf.print("[");
nf.close();
}
strlcpy(macros[slot].name, name, sizeof(macros[slot].name));
strlcpy(macros[slot].labelColor, labelColor, sizeof(macros[slot].labelColor));
macros[slot].nodeCount = nodeCount;
return true;
}
bool writeImageData(int slot, uint8_t* data, size_t len) {
char path[32];
snprintf(path, sizeof(path), "/m%d/icon.raw", slot);
File f = LittleFS.open(path, "w");
if (!f) return false;
size_t written = f.write(data, len);
f.close();
macros[slot].hasImage = (written == len);
return macros[slot].hasImage;
}
bool writeImageChunk(int slot, uint8_t* data, size_t len, bool first) {
char path[32];
snprintf(path, sizeof(path), "/m%d/icon.raw", slot);
File f = LittleFS.open(path, first ? "w" : "a");
if (!f) return false;
f.write(data, len);
f.close();
return true;
}
bool appendNode(int slot, const char* nodeJson, bool last) {
char path[48];
snprintf(path, sizeof(path), "/m%d/nodes.json", slot);
File f = LittleFS.open(path, "a");
if (!f) return false;
f.print(nodeJson);
if (!last) f.print(",");
else f.print("]");
f.close();
return true;
}
bool finalizeMacro(int slot) {
// Add to index if not already present
bool found = false;
for (int i = 0; i < macroCount; i++) {
if (order[i] == slot) { found = true; break; }
}
if (!found && macroCount < MAX_MACROS) {
order[macroCount] = slot;
macroCount++;
}
loadMacroMeta(slot);
saveIndex();
return true;
}
bool deleteMacro(int slot) {
char path[48];
snprintf(path, sizeof(path), "/m%d/meta.json", slot);
LittleFS.remove(path);
snprintf(path, sizeof(path), "/m%d/nodes.json", slot);
LittleFS.remove(path);
snprintf(path, sizeof(path), "/m%d/icon.raw", slot);
LittleFS.remove(path);
snprintf(path, sizeof(path), "/m%d", slot);
LittleFS.rmdir(path);
// Remove from order
int idx = -1;
for (int i = 0; i < macroCount; i++) {
if (order[i] == slot) { idx = i; break; }
}
if (idx >= 0) {
for (int i = idx; i < macroCount - 1; i++) {
order[i] = order[i + 1];
}
macroCount--;
}
saveIndex();
return true;
}
bool loadNodes(int slot, JsonDocument& doc) {
char path[48];
snprintf(path, sizeof(path), "/m%d/nodes.json", slot);
File f = LittleFS.open(path, "r");
if (!f) return false;
DeserializationError err = deserializeJson(doc, f);
f.close();
return err == DeserializationError::Ok;
}
void reorder(int* newOrder, int count) {
macroCount = count;
for (int i = 0; i < count && i < MAX_MACROS; i++) {
order[i] = newOrder[i];
}
saveIndex();
}
size_t getFreeSpace() {
return LittleFS.totalBytes() - LittleFS.usedBytes();
}
// --- Sub-routine storage ---
//
// Upload protocol on the wire is "sub_begin → 0..N sub_node → sub_end".
// Storage writes go to ``/sub/s{N}/nodes.tmp`` during the upload and only
// get renamed to the live ``/sub/s{N}/nodes.json`` once sub_end fires,
// confirming we have all the expected nodes AND that the assembled text
// parses as valid JSON. Three failure modes are now handled atomically:
//
// 1. Upload aborts mid-stream (USB unplug, host crash): tmp file exists
// but nodes.json is untouched, so loadSubNodes continues to see the
// LAST GOOD version (or returns false if it never existed).
// 2. nodeCount=0 — sub-routine that flattens to nothing. We skip tmp
// entirely and write "[]" straight to nodes.json so the file is
// immediately valid.
// 3. Malformed JSON (corrupt host send): finalizeSubWrite re-parses
// the tmp before promoting it. If parse fails, the bad tmp is
// removed and the live file is left as-is.
//
// Engine-side, loadSubNodes is unchanged (just reads nodes.json), so
// the engine never sees a partial / malformed file on this path.
bool beginSubWrite(int slot, const char* name, int nodeCount) {
if (slot < 0 || slot >= MAX_SUBROUTINES) return false;
char dir[32];
snprintf(dir, sizeof(dir), "/sub/s%d", slot);
LittleFS.mkdir("/sub");
LittleFS.mkdir(dir);
char path[48];
snprintf(path, sizeof(path), "/sub/s%d/meta.json", slot);
File f = LittleFS.open(path, "w");
if (!f) return false;
JsonDocument doc;
doc["name"] = name;
doc["nodes"] = nodeCount;
serializeJson(doc, f);
f.close();
// Sweep any leftover tmp from a previous interrupted upload so the
// append path starts from a known-empty state.
snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", slot);
if (LittleFS.exists(path)) LittleFS.remove(path);
if (nodeCount <= 0) {
// Empty sub-routine — no append phase will follow, so commit
// the valid empty array straight to the live file. No tmp dance
// needed.
snprintf(path, sizeof(path), "/sub/s%d/nodes.json", slot);
File nf = LittleFS.open(path, "w");
if (nf) { nf.print("[]"); nf.close(); }
} else {
// Open tmp with the opening bracket. appendSubNode will fill
// it in; finalizeSubWrite will rename it to nodes.json on
// success.
snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", slot);
File nf = LittleFS.open(path, "w");
if (nf) { nf.print("["); nf.close(); }
}
strlcpy(subs[slot].name, name, sizeof(subs[slot].name));
subs[slot].nodeCount = nodeCount;
if (slot >= subCount) subCount = slot + 1;
return true;
}
bool appendSubNode(int slot, const char* nodeJson, bool last) {
char path[48];
snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", slot);
File f = LittleFS.open(path, "a");
if (!f) return false;
f.print(nodeJson);
if (!last) f.print(",");
else f.print("]");
f.close();
return true;
}
// Promote the just-written tmp to the live nodes.json — only if it
// parses as valid JSON. Returns true on successful swap. If the tmp
// file doesn't exist (because beginSubWrite already committed the
// empty-sub case directly to nodes.json), this is a no-op success.
// If the tmp file is malformed, the live nodes.json is left as-is
// (preserving the previous good version) and the bad tmp is removed.
bool finalizeSubWrite(int slot) {
if (slot < 0 || slot >= MAX_SUBROUTINES) return false;
char tmpPath[48], livePath[48];
snprintf(tmpPath, sizeof(tmpPath), "/sub/s%d/nodes.tmp", slot);
snprintf(livePath, sizeof(livePath), "/sub/s%d/nodes.json", slot);
if (!LittleFS.exists(tmpPath)) {
// beginSubWrite handled the empty-sub case directly. Nothing
// to promote, but make sure nodes.json exists with at least
// an empty array so loadSubNodes never returns false here.
if (!LittleFS.exists(livePath)) {
File nf = LittleFS.open(livePath, "w");
if (nf) { nf.print("[]"); nf.close(); }
}
return true;
}
// Validate the tmp before promoting. If it doesn't parse, the
// previous live file (if any) is left untouched — the device will
// keep using the last known-good version of this sub.
//
// NOTE: no Serial.printf in this function. It's called from
// cmdSubEnd during profile upload, which shares the USB CDC pipe
// with the JSON command/response stream. Any text emitted here
// would corrupt the host's readline() on the next response and
// tear down the serial connection. Failure is communicated up
// through the bool return value; the caller turns that into a
// sendError(...) JSON payload.
{
File vf = LittleFS.open(tmpPath, "r");
if (!vf) { LittleFS.remove(tmpPath); return false; }
JsonDocument vdoc;
DeserializationError err = deserializeJson(vdoc, vf);
vf.close();
if (err != DeserializationError::Ok) {
LittleFS.remove(tmpPath);
return false;
}
}
// Atomically swap tmp -> live. Some LittleFS versions don't
// overwrite on rename, so remove the live file first; the window
// between remove and rename is tiny (microseconds) compared to
// the full upload, so accepting it here is fine.
if (LittleFS.exists(livePath)) LittleFS.remove(livePath);
if (!LittleFS.rename(tmpPath, livePath)) {
LittleFS.remove(tmpPath);
return false;
}
return true;
}
bool loadSubNodes(int slot, JsonDocument& doc) {
// Stays silent (no Serial.printf) on failure — the storage layer
// can be exercised from inside the protocol handler in edge
// cases (e.g. a re-upload while the engine just finished using
// the sub), and any text emitted on the USB CDC pipe corrupts
// the host's JSON response stream. The engine's caller handles
// the failure case with its own diagnostic line.
char path[48];
snprintf(path, sizeof(path), "/sub/s%d/nodes.json", slot);
File f = LittleFS.open(path, "r");
if (!f) return false;
DeserializationError err = deserializeJson(doc, f);
f.close();
return err == DeserializationError::Ok;
}
int findSubByName(const char* name) {
for (int i = 0; i < subCount; i++) {
if (strcmp(subs[i].name, name) == 0) return i;
}
return -1;
}
void clearAllSubs() {
for (int i = 0; i < subCount; i++) {
char path[48];
snprintf(path, sizeof(path), "/sub/s%d/meta.json", i);
LittleFS.remove(path);
snprintf(path, sizeof(path), "/sub/s%d/nodes.json", i);
LittleFS.remove(path);
// Sweep any stray tmp left over from an interrupted upload.
snprintf(path, sizeof(path), "/sub/s%d/nodes.tmp", i);
if (LittleFS.exists(path)) LittleFS.remove(path);
snprintf(path, sizeof(path), "/sub/s%d", i);
LittleFS.rmdir(path);
}
subCount = 0;
}
void loadSubIndex() {
subCount = 0;
for (int i = 0; i < MAX_SUBROUTINES; i++) {
char path[48];
snprintf(path, sizeof(path), "/sub/s%d/meta.json", i);
if (!LittleFS.exists(path)) break;
File f = LittleFS.open(path, "r");
if (!f) break;
JsonDocument doc;
if (deserializeJson(doc, f) == DeserializationError::Ok) {
strlcpy(subs[i].name, doc["name"] | "Unnamed", sizeof(subs[i].name));
subs[i].nodeCount = doc["nodes"] | 0;
subCount = i + 1;
}
f.close();
}
}
};
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <Arduino.h>
// Build an ESP32 UART config constant from individual parameters.
// Shared by MacroEngine (for rs232 node execution) and SerialProtocol
// (for the host-side RS232 terminal pass-through).
inline uint32_t computeRS232Config(int dataBits, const char* parity, const char* stopBits) {
bool twoStop = (strcmp(stopBits, "2") == 0 || strcmp(stopBits, "1.5") == 0);
if (dataBits == 5) {
if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_5E2 : SERIAL_5E1;
if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_5O2 : SERIAL_5O1;
return twoStop ? SERIAL_5N2 : SERIAL_5N1;
} else if (dataBits == 6) {
if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_6E2 : SERIAL_6E1;
if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_6O2 : SERIAL_6O1;
return twoStop ? SERIAL_6N2 : SERIAL_6N1;
} else if (dataBits == 7) {
if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_7E2 : SERIAL_7E1;
if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_7O2 : SERIAL_7O1;
return twoStop ? SERIAL_7N2 : SERIAL_7N1;
} else { // default to 8 data bits
if (strcmp(parity, "even") == 0) return twoStop ? SERIAL_8E2 : SERIAL_8E1;
if (strcmp(parity, "odd") == 0) return twoStop ? SERIAL_8O2 : SERIAL_8O1;
return twoStop ? SERIAL_8N2 : SERIAL_8N1;
}
}
+861
View File
@@ -0,0 +1,861 @@
#pragma once
#include <ArduinoJson.h>
#include <esp_mac.h>
#include "esp32-hal-tinyusb.h"
#include "config.h"
#include "settings.h"
#include "macro_storage.h"
#include "display_ui.h"
#include "debug_log.h"
#include "rs232_util.h"
#include "ble_keystore.h"
#include "ble_manager.h"
#include "espnow_manager.h"
// CRC16-CCITT (poly 0x1021, init 0xFFFF) over the hub binary framing —
// must match mesh_link.py on the host.
inline uint16_t hubCrc16(const uint8_t* data, size_t len,
uint16_t crc = 0xFFFF) {
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for (int b = 0; b < 8; b++) {
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021)
: (uint16_t)(crc << 1);
}
}
return crc;
}
class SerialProtocol {
public:
typedef void (*VoidCallback)();
void begin(SettingsManager* settings, MacroStorage* storage, DisplayUI* display,
DebugLog* dlog = nullptr, HardwareSerial* rs232 = nullptr,
VoidCallback onRS232Reconfig = nullptr,
BLEKeyStore* keystore = nullptr,
BLEManager* bleManager = nullptr,
EspNowManager* mesh = nullptr) {
_settings = settings;
_storage = storage;
_display = display;
_dlog = dlog;
_rs232Serial = rs232;
_onRS232Reconfig = onRS232Reconfig;
_keystore = keystore;
_bleManager = bleManager;
_mesh = mesh;
}
// Call periodically from the main loop. When the host-side RS232 terminal
// is open, drains incoming bytes into a buffer that rs232_poll returns.
void pollRS232() {
if (!_rs232TerminalOpen || !_rs232Serial) return;
while (_rs232Serial->available()) {
if (_rs232TerminalBufPos >= (int)sizeof(_rs232TerminalBuf)) {
// Buffer full — drop oldest half so we don't lose forever-recent data
int keep = sizeof(_rs232TerminalBuf) / 2;
memmove(_rs232TerminalBuf, _rs232TerminalBuf + (sizeof(_rs232TerminalBuf) - keep), keep);
_rs232TerminalBufPos = keep;
}
_rs232TerminalBuf[_rs232TerminalBufPos++] = _rs232Serial->read();
}
}
// Returns true if settings changed (display needs refresh)
bool handleSerial() {
if (!Serial.available()) return false;
if (_receivingImage) {
// Keep the upload-active window fresh across the whole image
// transfer so BLE stays suspended until it finishes.
_lastUploadCmdMs = millis();
return receiveImageData();
}
// Hub binary bridge: a frame in progress, or a new one starting.
// JSON lines keep working in parallel — we dispatch on the first
// byte (0xC8 = binary frame, '{' = JSON line).
if (_binState != BIN_IDLE) return _pumpBinary();
if (Serial.peek() == HUB_MAGIC0) {
Serial.read();
_binState = BIN_MAGIC1;
return _pumpBinary();
}
String line = Serial.readStringUntil('\n');
line.trim();
if (line.length() == 0) return false;
JsonDocument doc;
if (deserializeJson(doc, line) != DeserializationError::Ok) {
sendError("invalid json");
return false;
}
const char* cmd = doc["cmd"] | "";
return processCommand(cmd, doc);
}
bool isBusy() const { return _receivingImage || _receivingNodes; }
// True while a profile upload (or key/bootloader op) is in flight or
// just finished. The main loop uses this to keep live-BLE advertising
// OFF during USB transfers — NimBLE advertising concurrent with a
// sustained USB-CDC upload is the radio/CDC contention we must avoid.
bool isUploadActive() const {
if (_receivingImage || _receivingNodes) return true;
return _lastUploadCmdMs != 0 &&
(millis() - _lastUploadCmdMs) < UPLOAD_QUIET_MS;
}
// True once the host app has talked to us over USB this boot (it pings
// on connect). It means we're plugged into the configuring computer,
// so BLE stays off for the rest of the boot (see MacroPad.ino) to keep
// NimBLE from contending with the USB-CDC pipe during uploads. Latched
// for the whole boot; cleared only by a power cycle.
bool isHostConnected() const { return _hostSeen; }
bool needsRefresh() {
bool r = _refreshNeeded;
_refreshNeeded = false;
return r;
}
private:
SettingsManager* _settings;
MacroStorage* _storage;
DisplayUI* _display;
DebugLog* _dlog = nullptr;
HardwareSerial* _rs232Serial = nullptr;
VoidCallback _onRS232Reconfig = nullptr;
BLEKeyStore* _keystore = nullptr;
BLEManager* _bleManager = nullptr;
EspNowManager* _mesh = nullptr;
// RS232 pass-through terminal state
bool _rs232TerminalOpen = false;
uint8_t _rs232TerminalBuf[1024];
int _rs232TerminalBufPos = 0;
// Image receive state
bool _receivingImage = false;
int _imgSlot = 0;
size_t _imgSize = 0;
size_t _imgReceived = 0;
bool _imgFirst = true;
// Chunk protocol state
bool _imgChunkActive = false;
size_t _imgChunkSize = 0;
size_t _imgChunkRead = 0;
uint8_t _imgChunkBuf[512];
// Node receive state
bool _receivingNodes = false;
int _nodeSlot = 0;
int _nodeCount = 0;
int _nodesReceived = 0;
bool _refreshNeeded = false;
// millis() of the last profile-mutating serial command. Drives
// isUploadActive() so the main loop suspends live-BLE advertising
// for a short window around USB uploads.
uint32_t _lastUploadCmdMs = 0;
static constexpr uint32_t UPLOAD_QUIET_MS = 2000;
// Latched true the first time we process any valid command from the
// host app over USB. Drives isHostConnected().
bool _hostSeen = false;
// Commands that imply the host is actively uploading a profile (or
// syncing the BLE key / entering the bootloader). During these we
// want BLE off the radio. Lightweight status pings (ping, get_*,
// rs232_poll) are intentionally excluded so the toolbar can keep
// polling without flapping the live link.
static bool _isUploadCmd(const char* cmd) {
return strcmp(cmd, "macro_begin") == 0 ||
strcmp(cmd, "node") == 0 ||
strcmp(cmd, "macro_end") == 0 ||
strcmp(cmd, "macro_delete") == 0 ||
strcmp(cmd, "macro_reorder") == 0 ||
strcmp(cmd, "sub_begin") == 0 ||
strcmp(cmd, "sub_node") == 0 ||
strcmp(cmd, "sub_end") == 0 ||
strcmp(cmd, "sub_clear") == 0 ||
strcmp(cmd, "get_ble_key") == 0 ||
strcmp(cmd, "bootloader") == 0;
}
bool processCommand(const char* cmd, JsonDocument& doc) {
// Any valid command means the host app is connected over USB — keep
// BLE off for the rest of this boot.
_hostSeen = true;
// Note any profile-mutating / bulk-transfer command so the main
// loop suspends live-BLE advertising during USB uploads.
if (_isUploadCmd(cmd)) _lastUploadCmdMs = millis();
if (strcmp(cmd, "ping") == 0) {
return cmdPing();
} else if (strcmp(cmd, "set") == 0) {
return cmdSet(doc);
} else if (strcmp(cmd, "get_settings") == 0) {
return cmdGetSettings();
} else if (strcmp(cmd, "macro_begin") == 0) {
return cmdMacroBegin(doc);
} else if (strcmp(cmd, "node") == 0) {
return cmdNode(doc);
} else if (strcmp(cmd, "macro_end") == 0) {
return cmdMacroEnd(doc);
} else if (strcmp(cmd, "macro_delete") == 0) {
return cmdMacroDelete(doc);
} else if (strcmp(cmd, "macro_reorder") == 0) {
return cmdMacroReorder(doc);
} else if (strcmp(cmd, "bootloader") == 0) {
return cmdBootloader();
} else if (strcmp(cmd, "get_log") == 0) {
return cmdGetLog();
} else if (strcmp(cmd, "clear_log") == 0) {
return cmdClearLog();
} else if (strcmp(cmd, "get_ble_log") == 0) {
return cmdGetBleLog();
} else if (strcmp(cmd, "clear_ble_log") == 0) {
return cmdClearBleLog();
} else if (strcmp(cmd, "sub_begin") == 0) {
return cmdSubBegin(doc);
} else if (strcmp(cmd, "sub_node") == 0) {
return cmdSubNode(doc);
} else if (strcmp(cmd, "sub_end") == 0) {
return cmdSubEnd(doc);
} else if (strcmp(cmd, "sub_clear") == 0) {
return cmdSubClear();
} else if (strcmp(cmd, "rs232_open") == 0) {
return cmdRs232Open(doc);
} else if (strcmp(cmd, "rs232_close") == 0) {
return cmdRs232Close(doc);
} else if (strcmp(cmd, "rs232_send") == 0) {
return cmdRs232Send(doc);
} else if (strcmp(cmd, "rs232_poll") == 0) {
return cmdRs232Poll(doc);
} else if (strcmp(cmd, "get_ble_key") == 0) {
return cmdGetBleKey();
} else if (strcmp(cmd, "espnow_hub") == 0) {
return cmdEspnowHub(doc);
} else if (strcmp(cmd, "mesh_poll") == 0) {
return cmdMeshPoll(doc);
} else if (strcmp(cmd, "hub_ping") == 0) {
return cmdHubPing();
} else {
sendError("unknown command");
return false;
}
}
// =====================================================================
// ESP-NOW mesh hub bridge
// =====================================================================
// Binary frame from the host (H2D): 0xC8 0x35 | htype | len u16LE |
// payload | crc16(htype, len, payload). Stateful so a frame split
// across loop iterations resumes where it left off.
enum BinState : uint8_t { BIN_IDLE = 0, BIN_MAGIC1, BIN_HDR, BIN_BODY };
BinState _binState = BIN_IDLE;
uint8_t _binHdr[3] = {0};
int _binHdrPos = 0;
uint16_t _binLen = 0;
uint16_t _binPos = 0;
uint8_t _binBuf[HUB_MAX_FRAME + 2];
bool _pumpBinary() {
uint32_t start = millis();
while ((millis() - start) < 50) {
if (!Serial.available()) return false; // resume next loop
switch (_binState) {
case BIN_MAGIC1: {
int c = Serial.read();
if (c != HUB_MAGIC1) { _binState = BIN_IDLE; return false; }
_binState = BIN_HDR;
_binHdrPos = 0;
break;
}
case BIN_HDR: {
_binHdr[_binHdrPos++] = (uint8_t)Serial.read();
if (_binHdrPos == 3) {
_binLen = (uint16_t)_binHdr[1] | ((uint16_t)_binHdr[2] << 8);
if (_binLen > HUB_MAX_FRAME) {
_binState = BIN_IDLE; // garbage; resync on next magic
return false;
}
_binPos = 0;
_binState = BIN_BODY;
}
break;
}
case BIN_BODY: {
_binBuf[_binPos++] = (uint8_t)Serial.read();
if (_binPos == (uint16_t)(_binLen + 2)) { // payload + crc16
_binState = BIN_IDLE;
uint16_t want = (uint16_t)_binBuf[_binLen] |
((uint16_t)_binBuf[_binLen + 1] << 8);
uint16_t got = hubCrc16(_binHdr, 3);
got = hubCrc16(_binBuf, _binLen, got);
if (want != got) return false; // corrupt; drop
_dispatchBinary(_binHdr[0], _binBuf, _binLen);
return false;
}
break;
}
default:
_binState = BIN_IDLE;
return false;
}
}
return false;
}
void _dispatchBinary(uint8_t htype, const uint8_t* payload, size_t len) {
if (htype == HUB_H2D_SEND && _mesh) {
_mesh->hubSendFromHost(payload, len);
}
// Unknown htypes are ignored (forward compatibility).
}
// Device-to-host sink used by EspNowManager (main task only). Wraps
// the payload in the same framing the host parser expects.
static void hostSinkStatic(uint8_t htype, const uint8_t* payload,
size_t len) {
uint8_t hdr[5] = { HUB_MAGIC0, HUB_MAGIC1, htype,
(uint8_t)len, (uint8_t)(len >> 8) };
uint16_t crc = hubCrc16(hdr + 2, 3);
crc = hubCrc16(payload, len, crc);
uint8_t tail[2] = { (uint8_t)crc, (uint8_t)(crc >> 8) };
Serial.write(hdr, sizeof(hdr));
Serial.write(payload, len);
Serial.write(tail, 2);
Serial.flush();
}
bool cmdEspnowHub(JsonDocument& doc) {
if (!_mesh) { sendError("no mesh"); return false; }
bool on = doc["on"] | true;
if (on) {
// The hub owns the radio: live BLE (if any) must be torn down
// first. The BLE variables path is unaffected — it only runs
// inside routines, which a hub never executes.
if (_bleManager) {
_bleManager->stopLive();
_bleManager->shutdown();
}
if (!_mesh->hubStart(&SerialProtocol::hostSinkStatic)) {
sendError("hub start failed");
return false;
}
JsonDocument rsp;
rsp["rsp"] = "hub";
rsp["on"] = true;
rsp["ch"] = _settings->settings.meshChannel;
{
uint8_t mac[6] = {0};
if (esp_efuse_mac_get_default(mac) != ESP_OK) {
esp_read_mac(mac, ESP_MAC_WIFI_STA);
}
char macStr[18];
snprintf(macStr, sizeof(macStr),
"%02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
rsp["sta_mac"] = macStr;
}
sendJson(rsp);
_refreshNeeded = true; // repaint: hub screen
} else {
_mesh->hubStop();
sendOk();
_refreshNeeded = true; // repaint: back to selector
}
return false;
}
bool cmdMeshPoll(JsonDocument& doc) {
if (!_mesh) { sendError("no mesh"); return false; }
bool on = doc["on"] | true;
_mesh->hubSetPollActive(on);
_mesh->notifyHostActivity();
sendOk();
return false;
}
bool cmdHubPing() {
if (_mesh) _mesh->notifyHostActivity();
JsonDocument rsp;
rsp["rsp"] = "hub_pong";
rsp["hub"] = _mesh ? _mesh->isHub() : false;
rsp["nodes"] = _mesh ? _mesh->hubNodeCount() : 0;
sendJson(rsp);
return false;
}
bool cmdPing() {
JsonDocument rsp;
rsp["rsp"] = "pong";
rsp["ver"] = FW_VERSION;
rsp["id"] = DEVICE_ID;
rsp["macros"] = _storage->macroCount;
rsp["free"] = _storage->getFreeSpace();
// Universal binary: tell the host which board this is so the GUI
// can adapt (the Lite has no screen) and so flash tooling can
// print accurate instructions.
rsp["board"] = (_display && !_display->present())
? BOARD_NAME_ATOMS3_LITE : BOARD_NAME_ATOMS3;
// WiFi STA MAC (eFuse base MAC) — the mesh identity. Same bytes as
// the AES device tag, surfaced directly so the host never has to
// parse the tag string.
{
uint8_t mac[6] = {0};
if (esp_efuse_mac_get_default(mac) != ESP_OK) {
esp_read_mac(mac, ESP_MAC_WIFI_STA);
}
char macStr[18];
snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
rsp["sta_mac"] = macStr;
}
rsp["mesh_ch"] = _settings->settings.meshChannel;
// Persisted live transport (0 = ESP-NOW mesh, 1 = BLE) so the host
// can show each device's mode as it's plugged in over USB.
rsp["live_tx"] = _settings->settings.liveTransport;
sendJson(rsp);
return false;
}
bool cmdSet(JsonDocument& doc) {
const char* key = doc["key"] | "";
int val = doc["val"] | 0;
_settings->set(key, val);
if (strcmp(key, "orientation") == 0) {
_display->setOrientation(val);
}
sendOk();
return true;
}
bool cmdGetSettings() {
JsonDocument rsp;
rsp["rsp"] = "settings";
rsp["hold_ms"] = _settings->settings.holdMs;
rsp["orientation"] = _settings->settings.orientation;
rsp["type_delay"] = _settings->settings.typeDelay;
rsp["resume_delay"] = _settings->settings.resumeDelay;
rsp["combo_pre_ms"] = _settings->settings.comboPreMs;
rsp["combo_post_ms"] = _settings->settings.comboPostMs;
rsp["probe_timeout_ms"] = _settings->settings.probeTimeoutMs;
rsp["media_hold_ms"] = _settings->settings.mediaHoldMs;
rsp["type_shift_extra_ms"] = _settings->settings.typeShiftExtraMs;
rsp["type_settle_ms"] = _settings->settings.typeSettleMs;
rsp["type_hold_min_ms"] = _settings->settings.typeHoldMinMs;
rsp["type_inter_char_ms"] = _settings->settings.typeInterCharMs;
rsp["pause_margin_left"] = _settings->settings.pauseMarginLeft;
rsp["pause_margin_right"] = _settings->settings.pauseMarginRight;
rsp["pause_margin_top"] = _settings->settings.pauseMarginTop;
rsp["pause_margin_bottom"] = _settings->settings.pauseMarginBottom;
sendJson(rsp);
return false;
}
bool cmdMacroBegin(JsonDocument& doc) {
int slot = doc["slot"] | 0;
const char* name = doc["name"] | "Unnamed";
const char* labelColor = doc["label_color"] | "white";
int nodeCount = doc["node_count"] | 0;
size_t imgSize = doc["img_size"] | 0;
if (!_storage->beginMacroWrite(slot, name, nodeCount, labelColor)) {
sendError("write failed");
return false;
}
_nodeSlot = slot;
_nodeCount = nodeCount;
_nodesReceived = 0;
if (imgSize > 0) {
_receivingImage = true;
_imgSlot = slot;
_imgSize = imgSize;
_imgReceived = 0;
_imgFirst = true;
_imgChunkActive = false;
_imgChunkSize = 0;
_imgChunkRead = 0;
}
sendReady();
return false;
}
bool receiveImageData() {
// Chunk+ACK protocol: host sends chunk size as text line first,
// then binary data, we ACK after writing each chunk.
if (!_imgChunkActive) {
// Read the chunk header line (e.g. "CHUNK:128\n")
if (!Serial.available()) return false;
String line = Serial.readStringUntil('\n');
line.trim();
if (line.startsWith("CHUNK:")) {
_imgChunkSize = line.substring(6).toInt();
if (_imgChunkSize <= 0 || _imgChunkSize > 512) {
sendError("bad chunk size");
_receivingImage = false;
return false;
}
_imgChunkRead = 0;
_imgChunkActive = true;
} else if (line == "IMG_DONE") {
// Transfer complete
_receivingImage = false;
_storage->macros[_imgSlot].hasImage = true;
Serial.println("{\"rsp\":\"img_ok\"}");
Serial.flush();
}
return false;
}
// Read binary chunk data byte-by-byte in a tight loop
unsigned long start = millis();
while (_imgChunkRead < _imgChunkSize && (millis() - start) < 2000) {
if (Serial.available()) {
_imgChunkBuf[_imgChunkRead++] = Serial.read();
}
}
if (_imgChunkRead >= _imgChunkSize) {
_storage->writeImageChunk(_imgSlot, _imgChunkBuf, _imgChunkSize, _imgFirst);
_imgFirst = false;
_imgReceived += _imgChunkSize;
_imgChunkActive = false;
Serial.println("OK");
Serial.flush();
}
// On timeout we stay in chunk-active mode and resume next loop
return false;
}
bool cmdNode(JsonDocument& doc) {
int idx = doc["idx"] | _nodesReceived;
// Strip down to just the node fields we want to persist
JsonDocument nodeDoc;
nodeDoc["type"] = doc["type"];
nodeDoc["data"] = doc["data"];
String nodeStr;
serializeJson(nodeDoc, nodeStr);
bool last = (idx >= _nodeCount - 1);
_storage->appendNode(_nodeSlot, nodeStr.c_str(), last);
_nodesReceived++;
sendOk();
return false;
}
bool cmdMacroEnd(JsonDocument& doc) {
int slot = doc["slot"] | _nodeSlot;
_storage->finalizeMacro(slot);
_refreshNeeded = true;
sendOk();
return false;
}
bool cmdMacroDelete(JsonDocument& doc) {
int slot = doc["slot"] | 0;
_storage->deleteMacro(slot);
_refreshNeeded = true;
sendOk();
return false;
}
bool cmdBootloader() {
// Acknowledge before disappearing so the host knows the command landed
sendOk();
delay(100);
// Tear down TinyUSB, route USB PHY back to USB-Serial/JTAG,
// set FORCE_DOWNLOAD_BOOT flag, then restart into ROM download mode.
// esptool must use --before no-reset to connect after this.
usb_persist_restart(RESTART_BOOTLOADER);
return false; // unreachable
}
bool cmdMacroReorder(JsonDocument& doc) {
JsonArray orderArr = doc["order"].as<JsonArray>();
int newOrder[MAX_MACROS];
int count = 0;
for (JsonVariant v : orderArr) {
if (count < MAX_MACROS) {
newOrder[count++] = v.as<int>();
}
}
_storage->reorder(newOrder, count);
_refreshNeeded = true;
sendOk();
return false;
}
bool cmdGetLog() {
if (_dlog) _dlog->sendOverSerial();
else Serial.println("{\"rsp\":\"log\",\"entries\":[]}");
Serial.flush();
return false;
}
bool cmdClearLog() {
if (_dlog) _dlog->clear();
sendOk();
return false;
}
bool cmdGetBleLog() {
if (_bleManager) _bleManager->dbg.dumpJson();
else Serial.println("{\"rsp\":\"ble_log\",\"entries\":[]}");
Serial.flush();
return false;
}
bool cmdClearBleLog() {
if (_bleManager) _bleManager->dbg.clear();
sendOk();
return false;
}
bool cmdGetBleKey() {
if (!_keystore || !_keystore->hasKey()) {
sendError("no ble key");
return false;
}
char hex[BLEKeyStore::KEY_LEN * 2 + 1];
const uint8_t* k = _keystore->key();
for (size_t i = 0; i < BLEKeyStore::KEY_LEN; i++) {
sprintf(hex + i * 2, "%02x", k[i]);
}
hex[BLEKeyStore::KEY_LEN * 2] = '\0';
JsonDocument rsp;
rsp["rsp"] = "ble_key";
rsp["key"] = hex;
// Also expose the device tag so the host can store keys
// per-device. Without this, uploading a profile to a second
// M5Stack overwrites the first device's key on the host and
// the user has to re-upload to switch between them.
if (_bleManager) {
rsp["tag"] = _bleManager->deviceTag();
}
sendJson(rsp);
return false;
}
// --- Sub-routine commands ---
int _subSlot = 0;
int _subNodeCount = 0;
int _subNodesReceived = 0;
bool cmdSubBegin(JsonDocument& doc) {
int slot = doc["slot"] | 0;
const char* name = doc["name"] | "Unnamed";
int nodeCount = doc["node_count"] | 0;
if (!_storage->beginSubWrite(slot, name, nodeCount)) {
sendError("sub write failed");
return false;
}
_subSlot = slot;
_subNodeCount = nodeCount;
_subNodesReceived = 0;
sendReady();
return false;
}
bool cmdSubNode(JsonDocument& doc) {
if (_subNodesReceived >= _subNodeCount) {
sendError("too many sub nodes");
return false;
}
JsonDocument nodeDoc;
nodeDoc["type"] = doc["type"];
nodeDoc["data"] = doc["data"];
String nodeStr;
serializeJson(nodeDoc, nodeStr);
bool last = (_subNodesReceived >= _subNodeCount - 1);
_storage->appendSubNode(_subSlot, nodeStr.c_str(), last);
_subNodesReceived++;
sendOk();
return false;
}
bool cmdSubEnd(JsonDocument& doc) {
// Verify we got all the nodes the host promised. Missing nodes
// would leave the tmp file with a trailing comma instead of a
// closing bracket, which finalizeSubWrite's JSON validation
// catches anyway — but failing early gives a cleaner error.
//
// IMPORTANT: do NOT Serial.printf debug text here. The USB CDC
// pipe is shared with the JSON response stream, and any non-JSON
// line on this pipe gets fed to the host's readline() instead of
// the {"rsp":...} response, which trips json.JSONDecodeError on
// the host and tears down the serial connection. Diagnostics for
// upload failures must travel back to the host via the sendError
// payload, not via Serial.
int slot = doc["slot"] | _subSlot;
if (_subNodesReceived != _subNodeCount) {
// Drop the tmp so the next loadSubNodes still finds the last
// good live file instead of a stale partial.
char tmpPath[48];
snprintf(tmpPath, sizeof(tmpPath), "/sub/s%d/nodes.tmp", slot);
if (LittleFS.exists(tmpPath)) LittleFS.remove(tmpPath);
sendError("sub_end node-count mismatch");
return false;
}
if (!_storage->finalizeSubWrite(slot)) {
sendError("sub_end finalize failed");
return false;
}
sendOk();
return false;
}
bool cmdSubClear() {
_storage->clearAllSubs();
sendOk();
return false;
}
// =====================================================================
// RS232 pass-through (for the host-side terminal)
// =====================================================================
bool cmdRs232Open(JsonDocument& doc) {
if (!_rs232Serial) {
sendError("no rs232 configured");
return false;
}
int baud = doc["baud"] | 9600;
int dataBits = doc["data_bits"] | 8;
const char* stopBits = doc["stop_bits"] | "1";
const char* parity = doc["parity"] | "none";
uint32_t config = computeRS232Config(dataBits, parity, stopBits);
_rs232Serial->end();
_rs232Serial->begin((unsigned long)baud, config, RS232_RX_PIN, RS232_TX_PIN);
delay(30);
_rs232TerminalOpen = true;
_rs232TerminalBufPos = 0;
// Invalidate any RS232 node's cached config so a later macro run
// re-initializes the port with its own settings.
if (_onRS232Reconfig) _onRS232Reconfig();
sendOk();
return false;
}
bool cmdRs232Close(JsonDocument& doc) {
_rs232TerminalOpen = false;
_rs232TerminalBufPos = 0;
// Don't end() the port — the engine may want to use it next.
if (_onRS232Reconfig) _onRS232Reconfig();
sendOk();
return false;
}
bool cmdRs232Send(JsonDocument& doc) {
if (!_rs232Serial || !_rs232TerminalOpen) {
sendError("not open");
return false;
}
// Support either a hex-encoded payload (safe for any byte value)
// or a plain ASCII string in "data". Hex wins if both are present.
const char* hex = doc["hex"] | "";
if (hex[0] != '\0') {
// Parse pairs of hex digits, tolerating whitespace
int n = 0;
char pair[3] = {0, 0, 0};
int pairIdx = 0;
while (*hex && n < 512) {
char c = *hex++;
if (c == ' ' || c == '\t' || c == ',' || c == '\n' || c == '\r') continue;
pair[pairIdx++] = c;
if (pairIdx == 2) {
pair[2] = 0;
uint8_t b = (uint8_t)strtol(pair, nullptr, 16);
_rs232Serial->write(b);
pairIdx = 0;
n++;
}
}
} else {
const char* data = doc["data"] | "";
_rs232Serial->print(data);
}
_rs232Serial->flush();
sendOk();
return false;
}
bool cmdRs232Poll(JsonDocument& doc) {
JsonDocument rsp;
rsp["rsp"] = "rx";
rsp["n"] = _rs232TerminalBufPos;
if (_rs232TerminalBufPos > 0) {
// Encode buffer as hex (2 chars per byte + null terminator)
static char hexBuf[sizeof(_rs232TerminalBuf) * 2 + 1];
int n = _rs232TerminalBufPos;
if (n > (int)(sizeof(hexBuf) - 1) / 2) n = (sizeof(hexBuf) - 1) / 2;
static const char* HEX_DIGITS = "0123456789abcdef";
for (int i = 0; i < n; i++) {
uint8_t v = _rs232TerminalBuf[i];
hexBuf[i * 2] = HEX_DIGITS[v >> 4];
hexBuf[i * 2 + 1] = HEX_DIGITS[v & 0x0F];
}
hexBuf[n * 2] = 0;
rsp["hex"] = hexBuf;
} else {
rsp["hex"] = "";
}
// Clear the buffer now that we've reported it
_rs232TerminalBufPos = 0;
sendJson(rsp);
return false;
}
void sendJson(JsonDocument& doc) {
String out;
serializeJson(doc, out);
Serial.println(out);
Serial.flush();
}
void sendOk() {
Serial.println("{\"rsp\":\"ok\"}");
Serial.flush();
}
void sendReady() {
Serial.println("{\"rsp\":\"ready\"}");
Serial.flush();
}
void sendError(const char* msg) {
JsonDocument doc;
doc["rsp"] = "error";
doc["msg"] = msg;
String out;
serializeJson(doc, out);
Serial.println(out);
Serial.flush();
}
};
+151
View File
@@ -0,0 +1,151 @@
#pragma once
#include <Preferences.h>
#include "config.h"
struct Settings {
uint16_t holdMs = DEFAULT_HOLD_MS;
uint8_t orientation = DEFAULT_ORIENTATION;
uint8_t typeDelay = DEFAULT_TYPE_DELAY;
uint16_t resumeDelay = DEFAULT_RESUME_DELAY; // seconds, 0 = disabled
uint16_t comboPreMs = DEFAULT_COMBO_PRE_MS;
uint16_t comboPostMs = DEFAULT_COMBO_POST_MS;
uint16_t probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
uint16_t mediaHoldMs = DEFAULT_MEDIA_HOLD_MS;
uint16_t typeShiftExtraMs = DEFAULT_TYPE_SHIFT_EXTRA_MS;
uint16_t typeSettleMs = DEFAULT_TYPE_SETTLE_MS;
uint16_t typeHoldMinMs = DEFAULT_TYPE_HOLD_MIN_MS;
uint16_t typeInterCharMs = DEFAULT_TYPE_INTER_CHAR_MS;
// Pause-screen text-box padding. drawWrapped() uses these to compute
// the text width and vertical center on every Pause node.
uint8_t pauseMarginLeft = DEFAULT_PAUSE_MARGIN_LEFT;
uint8_t pauseMarginRight = DEFAULT_PAUSE_MARGIN_RIGHT;
uint8_t pauseMarginTop = DEFAULT_PAUSE_MARGIN_TOP;
uint8_t pauseMarginBottom = DEFAULT_PAUSE_MARGIN_BOTTOM;
// ESP-NOW mesh channel (1-13). Every device in a mesh must share it;
// it's applied the next time the radio starts (node listen / hub on).
uint8_t meshChannel = DEFAULT_MESH_CHANNEL;
// Live-keyboard transport used when idle: LIVE_TX_MESH or LIVE_TX_BLE.
// Toggled on-device by a long screen-button hold; persisted in NVS.
uint8_t liveTransport = DEFAULT_LIVE_TRANSPORT;
};
class SettingsManager {
public:
Settings settings;
void begin() {
_prefs.begin("macropad", false);
settings.holdMs = _prefs.getUShort("hold_ms", DEFAULT_HOLD_MS);
settings.orientation = _prefs.getUChar("orient", DEFAULT_ORIENTATION);
settings.typeDelay = _prefs.getUChar("type_delay", DEFAULT_TYPE_DELAY);
settings.resumeDelay = _prefs.getUShort("resume_dly", DEFAULT_RESUME_DELAY);
settings.comboPreMs = _prefs.getUShort("combo_pre", DEFAULT_COMBO_PRE_MS);
settings.comboPostMs = _prefs.getUShort("combo_post", DEFAULT_COMBO_POST_MS);
settings.probeTimeoutMs = _prefs.getUShort("probe_to", DEFAULT_PROBE_TIMEOUT_MS);
settings.mediaHoldMs = _prefs.getUShort("media_hold", DEFAULT_MEDIA_HOLD_MS);
settings.typeShiftExtraMs = _prefs.getUShort("type_sh_ex", DEFAULT_TYPE_SHIFT_EXTRA_MS);
settings.typeSettleMs = _prefs.getUShort("type_settle", DEFAULT_TYPE_SETTLE_MS);
settings.typeHoldMinMs = _prefs.getUShort("type_hold_min", DEFAULT_TYPE_HOLD_MIN_MS);
settings.typeInterCharMs = _prefs.getUShort("type_inter_ch", DEFAULT_TYPE_INTER_CHAR_MS);
settings.pauseMarginLeft = _prefs.getUChar("p_mar_l", DEFAULT_PAUSE_MARGIN_LEFT);
settings.pauseMarginRight = _prefs.getUChar("p_mar_r", DEFAULT_PAUSE_MARGIN_RIGHT);
settings.pauseMarginTop = _prefs.getUChar("p_mar_t", DEFAULT_PAUSE_MARGIN_TOP);
settings.pauseMarginBottom = _prefs.getUChar("p_mar_b", DEFAULT_PAUSE_MARGIN_BOTTOM);
settings.meshChannel = _prefs.getUChar("mesh_ch", DEFAULT_MESH_CHANNEL);
if (settings.meshChannel < 1 || settings.meshChannel > 13) {
settings.meshChannel = DEFAULT_MESH_CHANNEL;
}
settings.liveTransport = _prefs.getUChar("live_tx", DEFAULT_LIVE_TRANSPORT);
if (settings.liveTransport > LIVE_TX_BLE) {
settings.liveTransport = DEFAULT_LIVE_TRANSPORT;
}
}
void set(const char* key, int value) {
if (strcmp(key, "hold_ms") == 0) {
settings.holdMs = value;
_prefs.putUShort("hold_ms", value);
} else if (strcmp(key, "orientation") == 0) {
settings.orientation = value;
_prefs.putUChar("orient", value);
} else if (strcmp(key, "type_delay") == 0) {
settings.typeDelay = value;
_prefs.putUChar("type_delay", value);
} else if (strcmp(key, "resume_delay") == 0) {
settings.resumeDelay = value;
_prefs.putUShort("resume_dly", value);
} else if (strcmp(key, "combo_pre_ms") == 0) {
settings.comboPreMs = value;
_prefs.putUShort("combo_pre", value);
} else if (strcmp(key, "combo_post_ms") == 0) {
settings.comboPostMs = value;
_prefs.putUShort("combo_post", value);
} else if (strcmp(key, "probe_timeout_ms") == 0) {
settings.probeTimeoutMs = value;
_prefs.putUShort("probe_to", value);
} else if (strcmp(key, "media_hold_ms") == 0) {
settings.mediaHoldMs = value;
_prefs.putUShort("media_hold", value);
} else if (strcmp(key, "type_shift_extra_ms") == 0) {
settings.typeShiftExtraMs = value;
_prefs.putUShort("type_sh_ex", value);
} else if (strcmp(key, "type_settle_ms") == 0) {
settings.typeSettleMs = value;
_prefs.putUShort("type_settle", value);
} else if (strcmp(key, "type_hold_min_ms") == 0) {
settings.typeHoldMinMs = value;
_prefs.putUShort("type_hold_min", value);
} else if (strcmp(key, "type_inter_char_ms") == 0) {
settings.typeInterCharMs = value;
_prefs.putUShort("type_inter_ch", value);
} else if (strcmp(key, "pause_margin_left") == 0) {
settings.pauseMarginLeft = (uint8_t)value;
_prefs.putUChar("p_mar_l", (uint8_t)value);
} else if (strcmp(key, "pause_margin_right") == 0) {
settings.pauseMarginRight = (uint8_t)value;
_prefs.putUChar("p_mar_r", (uint8_t)value);
} else if (strcmp(key, "pause_margin_top") == 0) {
settings.pauseMarginTop = (uint8_t)value;
_prefs.putUChar("p_mar_t", (uint8_t)value);
} else if (strcmp(key, "pause_margin_bottom") == 0) {
settings.pauseMarginBottom = (uint8_t)value;
_prefs.putUChar("p_mar_b", (uint8_t)value);
} else if (strcmp(key, "mesh_ch") == 0) {
if (value >= 1 && value <= 13) {
settings.meshChannel = (uint8_t)value;
_prefs.putUChar("mesh_ch", (uint8_t)value);
}
} else if (strcmp(key, "live_tx") == 0) {
if (value == LIVE_TX_MESH || value == LIVE_TX_BLE) {
settings.liveTransport = (uint8_t)value;
_prefs.putUChar("live_tx", (uint8_t)value);
}
}
}
int get(const char* key) {
if (strcmp(key, "hold_ms") == 0) return settings.holdMs;
if (strcmp(key, "orientation") == 0) return settings.orientation;
if (strcmp(key, "type_delay") == 0) return settings.typeDelay;
if (strcmp(key, "resume_delay") == 0) return settings.resumeDelay;
if (strcmp(key, "combo_pre_ms") == 0) return settings.comboPreMs;
if (strcmp(key, "combo_post_ms") == 0) return settings.comboPostMs;
if (strcmp(key, "probe_timeout_ms") == 0) return settings.probeTimeoutMs;
if (strcmp(key, "media_hold_ms") == 0) return settings.mediaHoldMs;
if (strcmp(key, "type_shift_extra_ms") == 0) return settings.typeShiftExtraMs;
if (strcmp(key, "type_settle_ms") == 0) return settings.typeSettleMs;
if (strcmp(key, "type_hold_min_ms") == 0) return settings.typeHoldMinMs;
if (strcmp(key, "type_inter_char_ms") == 0) return settings.typeInterCharMs;
if (strcmp(key, "pause_margin_left") == 0) return settings.pauseMarginLeft;
if (strcmp(key, "pause_margin_right") == 0) return settings.pauseMarginRight;
if (strcmp(key, "pause_margin_top") == 0) return settings.pauseMarginTop;
if (strcmp(key, "pause_margin_bottom") == 0) return settings.pauseMarginBottom;
if (strcmp(key, "mesh_ch") == 0) return settings.meshChannel;
if (strcmp(key, "live_tx") == 0) return settings.liveTransport;
return -1;
}
private:
Preferences _prefs;
};
+542
View File
@@ -0,0 +1,542 @@
#pragma once
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDMouse.h"
#include "USBHIDConsumerControl.h"
#include "abs_mouse.h"
#include "config.h"
// USB HID timing floors. The underlying SendReport call is blocking — it
// returns only after the host has acknowledged the report — so most "wait
// for the bytes to arrive" timing concerns are already covered by the
// library. These values are the EXTRA delay we hold after a press or
// between events so the *host application* has a chance to observe and
// process each keystroke (BIOS prompts, installer wizards, and PE shells
// can miss reports that flip in too quickly even after the USB stack has
// delivered them).
//
// USB_HID_HOLD_MIN_MS — minimum keydown hold time. Floor for the
// between-press-and-release delay even if the user's typeDelay
// setting is smaller. 8 ms = one boot-keyboard poll interval.
// USB_HID_INTER_CHAR_MS — gap between releasing one char's keys and
// pressing the next char's. Keeps key-repeat detection happy and
// stops fast-typing apps from coalescing two characters into one.
// USB_HID_PROLOGUE_SETTLE_MS — delay after the defensive entry
// releaseAll() before the first character's press(). Must be long
// enough that the releaseAll's xfer-complete callback has fired,
// otherwise the TinyUSB SendReport semaphore can desync on the
// first character (see press/release retry below).
// USB_HID_RETRY_SETTLE_MS — pause before retrying a failed press or
// release report. Gives the endpoint FIFO time to drain.
// USB_HID_COMBO_MOD_SETTLE_MS — extra dwell, on top of the per-mod
// preDelay, between pressing the last modifier of a combo and
// pressing the main key. Some hosts (notably Windows shell hotkey
// handlers and BIOS UIs) need a clear "modifier is steady-state
// held" window before the keycode arrives or they treat the combo
// as a plain keypress without the modifier. 25 ms is empirically
// enough on every host we've tested without being noticeable to a
// human watching the combo fire.
#define USB_HID_HOLD_MIN_MS 8
#define USB_HID_INTER_CHAR_MS 5
#define USB_HID_PROLOGUE_SETTLE_MS 10
#define USB_HID_RETRY_SETTLE_MS 2
#define USB_HID_COMBO_MOD_SETTLE_MS 25
class HIDController {
public:
USBHIDKeyboard keyboard;
USBHIDMouse mouse;
USBHIDConsumerControl consumer;
AbsoluteMouse absMouse; // absolute-position pointer for the BT Keyboard trackpad
// Keyboard LED state (updated via host reports)
volatile bool numLockOn = false;
volatile bool capsLockOn = false;
volatile bool scrollLockOn = false;
volatile bool ledStateReceived = false; // true once we've received at least one LED report
void begin() {
USB.productName("ATOMS3 MacroPad");
USB.manufacturerName("M5Stack");
// Register LED event callback BEFORE begin() so we don't miss events
_instance = this;
keyboard.onEvent(_keyboardEventCB);
// Register all HID interfaces BEFORE USB.begin()
keyboard.begin();
mouse.begin();
consumer.begin();
absMouse.begin();
// Leave shiftKeyReports at the library default (false). With it
// ON, a shifted character emits FOUR reports — shift-down alone,
// then shift+key, then shift-only on key-up, then shift-up.
// Theoretically this matches a physical keyboard more closely
// and is "what BIOSes expect," but in practice on a normal
// Windows host the intermediate "shift-alone" report has been
// observed to leave shift latched on across subsequent keys,
// causing "Coconuts4frodo" to come out as "COCONUTS$FRODO".
// The single-report form (shift+key bundled) is what every
// tested host actually wants. If a future BIOS/PE target needs
// the split form, enable per-target rather than globally.
// keyboard.setShiftKeyReports(true); // DO NOT enable globally.
// Start the TinyUSB stack LAST - this finalizes all descriptors
USB.begin();
// Disable DTR/RTS triggered reboot AFTER USB stack is running
USBSerial.enableReboot(false);
}
// True while the engine is in the middle of a sequence of HID writes
// (typeText / keyCombo / mediaKey / macro playback / probeNumLock).
// The main loop checks this and skips non-USB-HID polling work
// (BLE log flushes, RS232 RX scraping) so the typing path runs as
// uninterrupted as possible. Read-only from outside.
bool isCritical() const { return _critical; }
// External entry points for callers that emit HID reports directly
// (e.g. ``macro`` node-type playback in the engine, which uses
// pressRaw / releaseRaw against the keyboard object). Wrap the
// sequence in beginCritical() / endCritical() so the main-loop
// priority gate observes it the same as typeText / keyCombo.
void beginCritical() { _critical = true; }
void endCritical() { _critical = false; }
// Probe whether a host PC is alive by toggling Num Lock and checking
// if the LED state changes. Works regardless of the initial Num Lock
// state. Restores the original state if the host is alive.
//
// Returns true if the host responded (PC is alive), false otherwise.
//
// Algorithm:
// 1. Read current Num Lock LED state (before)
// 2. Send Num Lock keypress (toggle)
// 3. Wait for host to report new LED (after)
// 4. Compare before vs after
// - Changed → host is alive → toggle back to restore → return true
// - Same → host is dead / not connected → return false
//
// stepCallback is called at each phase so the display can show progress.
typedef void (*ProbeStepCB)(const char* phase, void* userData);
bool probeNumLock(uint16_t waitMs = 250, ProbeStepCB stepCB = nullptr, void* cbData = nullptr) {
_critical = true;
struct CriticalGuard {
HIDController* h;
~CriticalGuard() { h->_critical = false; }
} guard{this};
// Step 1: Record the "before" state
if (stepCB) stepCB("Read state...", cbData);
bool before = numLockOn;
delay(25);
// Step 2: Toggle Num Lock
if (stepCB) stepCB("Toggling...", cbData);
ledStateReceived = false;
keyboard.press(KEY_NUM_LOCK);
delay(25);
keyboard.releaseAll();
delay(25);
// Step 3: Wait for the host to send an LED report
if (stepCB) stepCB("Waiting for host...", cbData);
uint32_t deadline = millis() + waitMs;
while (!ledStateReceived && millis() < deadline) {
delay(5);
}
delay(25);
bool after = numLockOn;
// Step 4: Compare — if LED changed, host is alive; restore original state
if (ledStateReceived && after != before) {
if (stepCB) stepCB("Host alive! Restoring...", cbData);
ledStateReceived = false;
keyboard.press(KEY_NUM_LOCK);
delay(25);
keyboard.releaseAll();
delay(25);
// Wait for restore to register
deadline = millis() + waitMs;
while (!ledStateReceived && millis() < deadline) {
delay(5);
}
delay(25);
return true;
}
if (stepCB) stepCB("No response", cbData);
delay(25);
return false;
}
// Count the number of Scroll Lock LED transitions observed within a
// listening window. The Get Variables node uses this to read a simple
// signal channel from a host-side script (e.g. PowerShell calling
// user32!keybd_event with VK_SCROLL). Scroll Lock is preferred over
// Num Lock because most users have Num Lock toggling externally
// (numeric keypads, BIOS settings) which would corrupt the count,
// whereas Scroll Lock is virtually never touched by other software.
//
// We count BOTH directions (off->on AND on->off) so each press the
// host script issues maps 1:1 to one count, regardless of starting
// state. Earlier versions only counted off->on, which made the second
// press of a "press twice" sequence invisible.
int probeScrollLockSequence(uint32_t windowMs) {
int count = 0;
bool prev = scrollLockOn;
uint32_t deadline = millis() + windowMs;
while (millis() < deadline) {
bool now = scrollLockOn;
if (now != prev) {
count++;
prev = now;
}
delay(5);
}
return count;
}
// Callback type: called before each character is typed.
// Arguments: (fullText, charIndex, userData)
typedef void (*CharCallback)(const char* fullText, int charIdx, void* userData);
// Type a string one character at a time over the HID keyboard interface.
//
// Per-char timeline (with setShiftKeyReports(true) — see begin()):
// - keyboard.press(c)
// Unshifted: 1 report (key down). Blocks until host ack (~1-5 ms).
// Shifted: 2 reports (shift down, then key down). Blocks ~5-10 ms.
// - delay(holdMs)
// Host-side keydown processing time. Floored at USB_HID_HOLD_MIN_MS
// so very small typeDelay values don't starve apps that need to
// observe the keydown for at least one poll cycle.
// - keyboard.release(c)
// Unshifted: 1 report. Shifted: 2 reports (key up, then shift up).
// - delay(interCharMs)
// Host-side keyup processing + breathing room before the next
// keydown. Without this gap, fast key-repeat detection in some
// editors can elide every other keystroke.
// - For shifted chars, ``shiftExtraMs`` is added to the inter-char gap
// (NOT the hold) because the cost is on the release/next-press
// boundary: 4 reports must drain before the next char's shift
// state diverges.
//
// The original implementation surrounded each ``write()`` with two
// ``releaseAll()`` calls — that's 4 extra HID reports per character
// (~20 ms wasted) plus 15 ms of explicit delay. Functionally identical
// to plain press/release because press() / release() are paired
// already, so the releases were redundant. Removed here.
//
// The trailing settle is a final ``releaseAll()`` followed by
// ``settleMs`` of quiet so any in-flight report fully drains before
// the next macro node fires.
void typeText(const char* text, uint8_t delayMs,
CharCallback onChar = nullptr, void* userData = nullptr,
uint16_t shiftExtraMs = 25, uint16_t settleMs = 150,
uint16_t holdMinMs = USB_HID_HOLD_MIN_MS,
uint16_t interCharMs = USB_HID_INTER_CHAR_MS) {
_critical = true;
int count = 0;
const char* start = text; // Keep pointer to full string for callback
// Defensive prologue: clear any modifier state left held by a
// preceding combo / macro-playback / failed earlier typeText.
// Without this, a stuck shift bit from the previous node would
// turn the whole text into shifted equivalents ("Coconuts4frodo"
// → "COCONUTS$FRODO"). The cost is one report (~1-5 ms host
// ack), cheap insurance.
//
// The post-prologue settle is bumped above interCharMs so the
// releaseAll's xfer-complete callback has definitively fired
// before the first character's press(). On TinyUSB / arduino-esp32
// builds with the known semaphore-desync bug, a too-tight gap
// here can cause the FIRST character of every typeText call to
// drop silently.
keyboard.releaseAll();
uint16_t prologueSettle = interCharMs;
if (prologueSettle < USB_HID_PROLOGUE_SETTLE_MS) prologueSettle = USB_HID_PROLOGUE_SETTLE_MS;
delay(prologueSettle);
// Hold time floor: ensure at least one USB poll cycle elapses with
// the key down so the host always observes the press. Below this
// floor, fast-typing apps can drop characters.
uint16_t holdMs = delayMs;
if (holdMs < holdMinMs) holdMs = holdMinMs;
while (*text) {
if (onChar) {
onChar(start, count, userData);
}
char c = *text;
bool shifted = _isShiftedChar(c);
// press()/release() bundle shift+key into a single HID report
// (since shiftKeyReports is left at the default `false`).
// For an unshifted char: 1 report on press, 1 on release.
// For a shifted char ('A', '!', etc.): same 2 reports, with
// the shift modifier bit set in the press and cleared in the
// release.
//
// SendReport blocks on a semaphore that is given by
// tud_hid_report_complete_cb, so the happy path returns only
// after the host has drained the endpoint. The known
// arduino-esp32 / TinyUSB semaphore-desync bug occasionally
// makes that semaphore-take time out, returning 0 from
// press() / release() without the report ever reaching the
// host. Without the retry, that one character drops
// silently — the symptom is rare random misses like
// "Start-Process" → "Start-Proess".
_hidWriteWithRetry(true, (uint8_t)c, start, count);
delay(holdMs);
_hidWriteWithRetry(false, (uint8_t)c, start, count);
// Inter-char gap. Shifted chars get an extra slice so the
// shift bit has clearly cleared at the host before the next
// unshifted character's keycode arrives — a few hosts have
// been observed to apply a still-cached shift state to the
// very next report.
uint16_t gap = interCharMs;
if (shifted && shiftExtraMs > 0) gap += shiftExtraMs;
delay(gap);
text++;
count++;
}
// Final safety release in case something above failed mid-sequence
// and left a key latched, then the configured settle.
keyboard.releaseAll();
delay(settleMs);
_critical = false;
}
static bool _isShiftedChar(char c) {
if (c >= 'A' && c <= 'Z') return true;
return c != 0 && strchr("!@#$%^&*()_+{}|:\"<>?~", c) != nullptr;
}
// Single press or release with one-shot retry. Returns true if the
// report was acknowledged by the host on either the first try or
// the retry. A logged failure means the character was lost — emit
// a Serial line so the user can confirm in the field whether the
// semaphore-desync failure mode actually fires for their hardware.
//
// ``isPress`` selects press vs release. ``c`` is the key. ``textCtx``
// and ``idx`` are only used for the log line.
bool _hidWriteWithRetry(bool isPress, uint8_t c, const char* textCtx, int idx) {
size_t ok = isPress ? keyboard.press(c) : keyboard.release(c);
if (ok) return true;
// First attempt failed (semaphore-take timeout / FIFO not ready).
// Brief settle then one retry.
delay(USB_HID_RETRY_SETTLE_MS);
ok = isPress ? keyboard.press(c) : keyboard.release(c);
if (ok) {
Serial.printf("[HID retry] %s '%c' idx=%d ok on retry\n",
isPress ? "press" : "release", (char)c, idx);
return true;
}
Serial.printf("[HID retry] %s '%c' idx=%d FAILED twice — char dropped\n",
isPress ? "press" : "release", (char)c, idx);
return false;
}
// Press a key combination (zero or more modifiers + zero or one main key).
//
// Timeline:
// 1. Press each modifier, with ``preDelay`` after each press. The
// ``preDelay`` after the LAST modifier doubles as the
// modifier-to-key spacer — the host has one ack-plus-preDelay
// worth of time to observe each new modifier bit settled before
// anything else happens.
// 2. When ``modCount > 0`` (combo has at least one modifier),
// apply an additional ``USB_HID_COMBO_MOD_SETTLE_MS`` (25 ms)
// dwell so the modifier is steady-state held for clearly more
// than one host poll cycle before the keycode arrives. Without
// this, fast-firing combos like Ctrl+R can race the host's
// modifier-state pipeline and register as a bare 'R' instead.
// Skipped for plain (modifier-less) key presses since there's
// nothing to settle.
// 3. Press the main key.
// 4. Hold the combo for ``postDelay`` so the host registers the
// shortcut as a tap, not a coalesced flicker.
// 5. releaseAll() — single report clears everything. Floor of
// USB_HID_INTER_CHAR_MS afterwards so the next macro node doesn't
// race against the just-issued release.
void keyCombo(const uint8_t* modifiers, uint8_t modCount, uint8_t key,
uint16_t preDelay = COMBO_KEY_PRE_DELAY,
uint16_t postDelay = COMBO_KEY_POST_DELAY) {
_critical = true;
// Caller controls the timings. Engine clamps to a 1ms floor before
// calling us; we trust the inputs here.
for (uint8_t i = 0; i < modCount; i++) {
keyboard.press(modifiers[i]);
delay(preDelay);
}
if (key != 0) {
if (modCount > 0) {
// Extra modifier-settle window so the host clearly sees
// the modifier bits as held BEFORE the keycode flips on.
delay(USB_HID_COMBO_MOD_SETTLE_MS);
}
keyboard.press(key);
}
delay(postDelay);
keyboard.releaseAll();
// Small trailing gap so the next node (often another combo or a
// text node) doesn't race the just-emitted release report.
delay(USB_HID_INTER_CHAR_MS);
_critical = false;
}
// Emit one absolute-position pointer report (BT Keyboard trackpad).
// Wrapped in the critical guard like all other live HID emission so the
// main-loop priority gate defers other work while it's in flight.
void absMouseReport(uint8_t buttons, uint16_t x, uint16_t y, int8_t wheel) {
_critical = true;
absMouse.report(buttons, x, y, wheel);
_critical = false;
}
void mouseClick(uint8_t button) {
mouse.click(button);
}
void mouseDoubleClick(uint8_t button) {
mouse.click(button);
delay(80);
mouse.click(button);
}
void mousePress(uint8_t button) {
mouse.press(button);
}
void mouseRelease(uint8_t button) {
mouse.release(button);
}
void mediaKey(uint16_t key, uint16_t holdMs = 100) {
_critical = true;
consumer.press(key);
delay(holdMs);
consumer.release();
_critical = false;
}
uint8_t resolveModifier(const char* mod) {
if (strcmp(mod, "ctrl") == 0 || strcmp(mod, "control") == 0) return KEY_LEFT_CTRL;
if (strcmp(mod, "shift") == 0) return KEY_LEFT_SHIFT;
if (strcmp(mod, "alt") == 0) return KEY_LEFT_ALT;
if (strcmp(mod, "gui") == 0 || strcmp(mod, "win") == 0 || strcmp(mod, "meta") == 0) return KEY_LEFT_GUI;
if (strcmp(mod, "rctrl") == 0) return KEY_RIGHT_CTRL;
if (strcmp(mod, "rshift") == 0) return KEY_RIGHT_SHIFT;
if (strcmp(mod, "ralt") == 0 || strcmp(mod, "altgr") == 0) return KEY_RIGHT_ALT;
if (strcmp(mod, "rgui") == 0) return KEY_RIGHT_GUI;
return 0;
}
uint8_t resolveKey(const char* key) {
if (strlen(key) == 1) return (uint8_t)key[0];
if (strcmp(key, "enter") == 0 || strcmp(key, "return") == 0) return KEY_RETURN;
if (strcmp(key, "esc") == 0 || strcmp(key, "escape") == 0) return KEY_ESC;
if (strcmp(key, "backspace") == 0) return KEY_BACKSPACE;
if (strcmp(key, "tab") == 0) return KEY_TAB;
if (strcmp(key, "space") == 0) return KEY_SPACE;
if (strcmp(key, "delete") == 0) return KEY_DELETE;
if (strcmp(key, "insert") == 0) return KEY_INSERT;
if (strcmp(key, "home") == 0) return KEY_HOME;
if (strcmp(key, "end") == 0) return KEY_END;
if (strcmp(key, "pageup") == 0) return KEY_PAGE_UP;
if (strcmp(key, "pagedown") == 0) return KEY_PAGE_DOWN;
if (strcmp(key, "up") == 0) return KEY_UP_ARROW;
if (strcmp(key, "down") == 0) return KEY_DOWN_ARROW;
if (strcmp(key, "left") == 0) return KEY_LEFT_ARROW;
if (strcmp(key, "right") == 0) return KEY_RIGHT_ARROW;
if (strcmp(key, "capslock") == 0) return KEY_CAPS_LOCK;
if (strcmp(key, "numlock") == 0) return KEY_NUM_LOCK;
if (strcmp(key, "scrolllock") == 0) return KEY_SCROLL_LOCK;
if (strcmp(key, "printscreen") == 0) return KEY_PRINT_SCREEN;
if (strcmp(key, "pause") == 0) return KEY_PAUSE;
if (strcmp(key, "menu") == 0) return KEY_MENU;
if (key[0] == 'f' || key[0] == 'F') {
int num = atoi(key + 1);
if (num >= 1 && num <= 12) return KEY_F1 + (num - 1);
if (num >= 13 && num <= 24) return KEY_F13 + (num - 13);
}
return 0;
}
uint8_t resolveMouseButton(const char* btn) {
if (strcmp(btn, "left") == 0) return MOUSE_LEFT;
if (strcmp(btn, "right") == 0) return MOUSE_RIGHT;
if (strcmp(btn, "middle") == 0) return MOUSE_MIDDLE;
return MOUSE_LEFT;
}
uint16_t resolveMediaKey(const char* action) {
if (strcmp(action, "vol_up") == 0) return CONSUMER_CONTROL_VOLUME_INCREMENT;
if (strcmp(action, "vol_down") == 0) return CONSUMER_CONTROL_VOLUME_DECREMENT;
if (strcmp(action, "mute") == 0) return CONSUMER_CONTROL_MUTE;
if (strcmp(action, "play_pause") == 0) return CONSUMER_CONTROL_PLAY_PAUSE;
if (strcmp(action, "next") == 0) return CONSUMER_CONTROL_SCAN_NEXT;
if (strcmp(action, "prev") == 0) return CONSUMER_CONTROL_SCAN_PREVIOUS;
if (strcmp(action, "stop") == 0) return CONSUMER_CONTROL_STOP;
if (strcmp(action, "brightness_up") == 0) return CONSUMER_CONTROL_BRIGHTNESS_INCREMENT;
if (strcmp(action, "brightness_down") == 0) return CONSUMER_CONTROL_BRIGHTNESS_DECREMENT;
return 0;
}
private:
static HIDController* _instance;
// Set true while a HID-emitting method (typeText, keyCombo, mediaKey,
// probeNumLock) is in progress. The main loop reads via isCritical()
// and defers non-USB-HID polling work (BLE log flush, RS232 RX
// buffering, M5 button events) for the duration. In practice the
// main loop is already blocked inside engine.tick() while these
// methods run, so the flag mainly serves to document the intent and
// protect any future caller that might pump the main loop from a
// nested context.
volatile bool _critical = false;
static void _keyboardEventCB(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data) {
if (!_instance) return;
if (event_base == ARDUINO_USB_HID_KEYBOARD_EVENTS &&
event_id == ARDUINO_USB_HID_KEYBOARD_LED_EVENT) {
arduino_usb_hid_keyboard_event_data_t* data =
(arduino_usb_hid_keyboard_event_data_t*)event_data;
// Log every LED report so we can verify whether a host-side
// toggle actually propagated to this USB HID device. If the
// host's keybd_event(VK_SCROLL,...) updates the OS state but
// doesn't trigger a Set Report to the keyboard, we'd see no
// log lines here and that's the signaling channel's bug.
bool prevN = _instance->numLockOn;
bool prevC = _instance->capsLockOn;
bool prevS = _instance->scrollLockOn;
_instance->numLockOn = data->numlock;
_instance->capsLockOn = data->capslock;
_instance->scrollLockOn = data->scrolllock;
_instance->ledStateReceived = true;
if (prevN != data->numlock || prevC != data->capslock ||
prevS != data->scrolllock) {
Serial.printf("[LED] num=%d caps=%d scroll=%d (was %d/%d/%d)\n",
(int)data->numlock, (int)data->capslock,
(int)data->scrolllock,
(int)prevN, (int)prevC, (int)prevS);
}
}
}
};
// Static member definition
HIDController* HIDController::_instance = nullptr;