Initial public release
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user