Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
#pragma once
// LiveKeystrokeEngine — device-side queue and dispatcher for the
// "host streams keystrokes via BLE during macro recording" feature.
//
// Design rules:
//
// 1. BLE write callback runs on the NimBLE host task. It must NOT
// touch USB HID directly — radio contention with TinyUSB causes
// panics on ESP32-S3 (the very issue this whole subsystem is
// designed around). The callback only enqueues parsed events.
//
// 2. The main loop calls drainQueue() each iteration. The HID
// critical-section gate in usb_hid.h means BLE polling is
// already deferred while keystrokes emit, so by the time we
// get here it's safe to take over the USB radio.
//
// 3. pressRaw / releaseRaw are wrapped in beginCritical/endCritical
// so the main loop's gating logic observes the in-flight HID
// operation and defers BLE work — the existing protection
// pattern, reused.
//
// 4. Queue is small and fixed-size; if full we set a flag the
// BLE manager exposes as an ERROR notify back to the host.
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include "usb_hid.h"
class LiveKeystrokeEngine {
public:
static constexpr int QUEUE_CAP = 256;
// Fixed device-side replay buffer. The host sends each event with a
// host-monotonic timestamp; we emit at host_t + this offset so BLE
// jitter is absorbed and the cadence the user typed on the host is
// reproduced exactly on the target USB HID side.
static constexpr uint32_t REPLAY_BUFFER_MS = 100;
struct Event {
uint8_t action; // 0 = down, 1 = up
uint8_t hid; // raw USB HID usage code
uint32_t scheduledMs; // device millis() when this event should
// emit. Computed at enqueue time from the
// host's relative timestamp and the
// session anchor.
};
void begin(HIDController* hid) { _hid = hid; }
// Start/stop are called from the BLE manager when the host sends
// START / STOP control frames (or from MacroPad.ino on emergency
// exits). idempotent.
void start() {
portENTER_CRITICAL(&_mux);
_head = _tail = _count = 0;
_overflow = false;
_anchorSet = false;
_active = true;
portEXIT_CRITICAL(&_mux);
}
void stop() {
portENTER_CRITICAL(&_mux);
_active = false;
_head = _tail = _count = 0;
_anchorSet = false;
// Release any held mouse buttons defensively — queue a buttons=0
// report for the main loop to emit (don't touch USB HID here, this
// can run on the NimBLE callback task).
_mouseButtons = 0;
_mouseWheel = 0;
_mousePending = true;
portEXIT_CRITICAL(&_mux);
// releaseAll is best-effort defensive cleanup. Wrap in
// critical so the BLE poll gate observes it even though we
// ourselves are already on the main task here.
if (_hid) {
_hid->beginCritical();
_hid->keyboard.releaseAll();
_hid->endCritical();
}
}
bool isActive() const {
// Volatile read, no mutex — _active is set under mutex but
// read-only here. Worst case: one extra loop iteration.
return _active;
}
// Enqueue one event with the host's relative timestamp. Called from
// the NimBLE host task — keep it fast. Returns false if the queue
// is full (caller should ERROR-notify the host).
//
// We anchor on the first event of the session: _anchorMs becomes
// the device-millis() value that corresponds to host_t = 0. Each
// event's scheduledMs is then _anchorMs + host_t_ms + REPLAY_BUFFER_MS,
// which preserves the host's typing cadence and adds a small jitter
// buffer so events that arrive slightly out of cadence still emit
// smoothly.
bool enqueue(uint8_t action, uint8_t hid_code, uint32_t host_t_ms) {
bool ok = false;
portENTER_CRITICAL(&_mux);
if (_active && _count < QUEUE_CAP) {
if (!_anchorSet) {
// First event of the session: pin the anchor so this
// event's scheduled time is exactly now + buffer.
_anchorMs = millis() - host_t_ms;
_anchorSet = true;
}
_q[_tail].action = action;
_q[_tail].hid = hid_code;
_q[_tail].scheduledMs = _anchorMs + host_t_ms + REPLAY_BUFFER_MS;
_tail = (_tail + 1) % QUEUE_CAP;
_count++;
ok = true;
} else if (!_active) {
// not live; signal NOT_LIVE_MODE upstream
} else {
_overflow = true;
}
portEXIT_CRITICAL(&_mux);
return ok;
}
// Called from the main loop when !hid->isCritical(). Drains events
// whose scheduled time has passed; events with future scheduledMs
// stay in the queue so the host's typing cadence is preserved on
// emission. Up to maxPerTick events per call so a flood doesn't
// starve other main-loop work.
//
// We must not delay() here — the main loop owns timing for the
// 3-second button-hold exit, button polling, BLE callback flush.
// Anything not ready yet stays queued until the next loop iter.
// Returns the number of events emitted (drives the activity flicker
// on the screenless AtomS3 Lite).
int drainQueue(int maxPerTick = 32) {
if (!_hid || !_active) return 0;
if (_count == 0) return 0;
uint32_t now = millis();
// Peek head first — if it's not ready, nothing else is either
// (events are enqueued in monotonic schedule order).
bool headReady = false;
portENTER_CRITICAL(&_mux);
if (_count > 0) {
headReady = (int32_t)(_q[_head].scheduledMs - now) <= 0;
}
portEXIT_CRITICAL(&_mux);
if (!headReady) return 0;
_hid->beginCritical();
int emitted = 0;
while (emitted < maxPerTick) {
Event ev;
bool got = false;
portENTER_CRITICAL(&_mux);
if (_count > 0 && (int32_t)(_q[_head].scheduledMs - now) <= 0) {
ev = _q[_head];
_head = (_head + 1) % QUEUE_CAP;
_count--;
got = true;
}
portEXIT_CRITICAL(&_mux);
if (!got) break;
if (ev.action == 0) {
_hid->keyboard.pressRaw(ev.hid);
} else {
_hid->keyboard.releaseRaw(ev.hid);
}
emitted++;
}
_hid->endCritical();
return emitted;
}
// ---- Absolute mouse (BT Keyboard trackpad) ----
//
// Mouse state is kept separate from the keystroke queue and is NOT
// cadence-buffered — the trackpad wants low latency. We coalesce: only
// the latest position/buttons matter, and wheel ticks accumulate. The
// main loop drains the latest state with drainMouse(). Because absolute
// positions are self-correcting, dropping intermediate moves is fine.
// Callable from the NimBLE task — no USB HID here, just state under the
// spinlock.
void enqueueMouse(uint8_t buttons, uint16_t x, uint16_t y, int8_t wheel) {
portENTER_CRITICAL(&_mux);
_mouseButtons = buttons;
_mouseX = x;
_mouseY = y;
_mouseWheel += wheel;
_mousePending = true;
portEXIT_CRITICAL(&_mux);
}
// Called from the main loop (outside the HID-critical window). Emits the
// latest absolute pointer report if one is pending. No delay/scheduling.
void drainMouse() {
if (!_hid) return;
bool pending;
uint8_t buttons;
uint16_t x, y;
int wheel;
portENTER_CRITICAL(&_mux);
pending = _mousePending;
buttons = _mouseButtons;
x = _mouseX;
y = _mouseY;
wheel = _mouseWheel;
_mousePending = false;
_mouseWheel = 0;
portEXIT_CRITICAL(&_mux);
if (!pending) return;
int8_t w = (wheel > 127) ? 127 : (wheel < -127 ? -127 : (int8_t)wheel);
_hid->absMouseReport(buttons, x, y, w);
}
// Drained by the BLE manager when it builds the next status frame.
bool takeOverflowFlag() {
bool was;
portENTER_CRITICAL(&_mux);
was = _overflow;
_overflow = false;
portEXIT_CRITICAL(&_mux);
return was;
}
private:
HIDController* _hid = nullptr;
Event _q[QUEUE_CAP];
int _head = 0;
int _tail = 0;
int _count = 0;
volatile bool _active = false;
volatile bool _overflow = false;
// Anchor mapping host_t=0 to a specific device millis() value.
// Set on the first event of each session so subsequent events can
// schedule emissions relative to the host's typing cadence.
bool _anchorSet = false;
uint32_t _anchorMs = 0;
// Absolute mouse state (coalesced; see enqueueMouse/drainMouse).
volatile bool _mousePending = false;
uint8_t _mouseButtons = 0;
uint16_t _mouseX = 0;
uint16_t _mouseY = 0;
int _mouseWheel = 0;
portMUX_TYPE _mux = portMUX_INITIALIZER_UNLOCKED;
};