Skip to content

File SimGateway.cpp

File List > Firmware > Libraries > SimGateway > SimGateway.cpp

Go to the documentation of this file

#ifdef ARDUINO_ARCH_RP2040

#include "SimGateway.h"

// ── TinyUSB HID backend (production builds only) ──────────────────────────────
//
// SIMGATEWAY_TEST builds substitute no-op stubs below. The #ifndef guard prevents
// Adafruit_TinyUSB.h from being included in test builds, keeping tests free of USB
// enumeration side effects.

#ifndef SIMGATEWAY_TEST
#include <Adafruit_TinyUSB.h>
#include <EEPROM.h>
#include "hardware/structs/uart.h" // uart0_hw — PL011 registers; also used by the status LED
#include "hardware/regs/uart.h"    // UART_UARTRSR_*_BITS error-flag masks

namespace {

// HID report: 128 buttons (16 bytes) + 4 hat switches (4-bit each = 2 bytes) + 8 axes (16 bytes)
struct __attribute__((packed)) HIDReport {
    uint8_t buttons[16]; // 128 × 1-bit buttons (button 0 = bit 0 of byte 0)
    uint8_t hats[2];     // 4 × 4-bit hat values; nibble value ≥ 8 = null / centered
    int16_t axes[8];     // X, Y, Z, Rx, Ry, Rz, Slider, Dial
};

static const uint8_t desc_hid_report[] = {
    // Joystick application collection
    0x05, 0x01,        // Usage Page (Generic Desktop)
    0x09, 0x04,        // Usage (Joystick)
    0xA1, 0x01,        // Collection (Application)

    // 128 Buttons (1-bit each, 16 bytes total)
    0x05, 0x09,        //   Usage Page (Button)
    0x19, 0x01,        //   Usage Minimum (1)
    0x29, 0x80,        //   Usage Maximum (128)
    0x15, 0x00,        //   Logical Minimum (0)
    0x25, 0x01,        //   Logical Maximum (1)
    0x75, 0x01,        //   Report Size (1)
    0x95, 0x80,        //   Report Count (128)
    0x81, 0x02,        //   Input (Data, Variable, Absolute)

    // 4 Hat switches (4-bit each, 2 bytes total)
    // Logical 0-7 = N/NE/E/SE/S/SW/W/NW; value ≥ 8 = null (centered).
    0x05, 0x01,        //   Usage Page (Generic Desktop)
    0x09, 0x39,        //   Usage (Hat Switch) — hat 0
    0x09, 0x39,        //   Usage (Hat Switch) — hat 1
    0x09, 0x39,        //   Usage (Hat Switch) — hat 2
    0x09, 0x39,        //   Usage (Hat Switch) — hat 3
    0x15, 0x00,        //   Logical Minimum (0)
    0x25, 0x07,        //   Logical Maximum (7)
    0x35, 0x00,        //   Physical Minimum (0 degrees)
    0x46, 0x3B, 0x01,  //   Physical Maximum (315 degrees)
    0x65, 0x14,        //   Unit (Degrees)
    0x75, 0x04,        //   Report Size (4)
    0x95, 0x04,        //   Report Count (4)
    0x81, 0x42,        //   Input (Data, Variable, Absolute, Null State)

    // 8 Axes: X, Y, Z, Rx, Ry, Rz, Slider, Dial (16-bit signed each, 16 bytes total)
    0x05, 0x01,        //   Usage Page (Generic Desktop)
    0x09, 0x30,        //   Usage (X)
    0x09, 0x31,        //   Usage (Y)
    0x09, 0x32,        //   Usage (Z)
    0x09, 0x33,        //   Usage (Rx)
    0x09, 0x34,        //   Usage (Ry)
    0x09, 0x35,        //   Usage (Rz)
    0x09, 0x36,        //   Usage (Slider)
    0x09, 0x37,        //   Usage (Dial)
    0x16, 0x00, 0x80,  //   Logical Minimum (-32768)
    0x26, 0xFF, 0x7F,  //   Logical Maximum (32767)
    0x75, 0x10,        //   Report Size (16)
    0x95, 0x08,        //   Report Count (8)
    0x81, 0x02,        //   Input (Data, Variable, Absolute)

    0xC0               // End Collection
};

static HIDReport _hidReport = {};
static Adafruit_USBD_HID _usbHid(desc_hid_report, sizeof(desc_hid_report),
                                  HID_ITF_PROTOCOL_NONE, 2, false);

static void _hidBegin() {
    // All hat nibbles start centered (null state = 0xF per nibble).
    _hidReport.hats[0] = 0xFF;
    _hidReport.hats[1] = 0xFF;
    _usbHid.begin();
    // Block until the host enumerates (2 s timeout: handles benchtop use without USB host).
    uint32_t t = millis();
    while (!TinyUSBDevice.mounted() && (millis() - t) < 2000) delay(1);
}

static void _hidSetAxis(uint8_t axisIndex, int16_t value) {
    if (axisIndex < 8) _hidReport.axes[axisIndex] = value;
}

static void _hidSetButton(uint8_t buttonIndex, bool pressed) {
    if (buttonIndex >= 128) return;
    uint8_t byte_idx = buttonIndex / 8;
    uint8_t bit_mask = 1u << (buttonIndex % 8);
    if (pressed) _hidReport.buttons[byte_idx] |=  bit_mask;
    else         _hidReport.buttons[byte_idx] &= ~bit_mask;
}

static void _hidSetHat(uint8_t hatIndex, uint8_t direction) {
    if (hatIndex >= 4) return;
    // direction: 0=center→0xF, 1=N→0, 2=NE→1, …, 8=NW→7; >8→0xF (center)
    uint8_t hid_val    = (direction == 0 || direction > 8) ? 0xF : (direction - 1);
    uint8_t byte_idx   = hatIndex / 2;
    uint8_t nibble_idx = hatIndex % 2;
    if (nibble_idx == 0) _hidReport.hats[byte_idx] = (_hidReport.hats[byte_idx] & 0xF0) | (hid_val & 0x0F);
    else                 _hidReport.hats[byte_idx] = (_hidReport.hats[byte_idx] & 0x0F) | ((hid_val & 0x0F) << 4);
}

static void _hidSend() {
    if (_usbHid.ready()) _usbHid.sendReport(0, &_hidReport, sizeof(_hidReport));
}

} // anonymous namespace

#else // SIMGATEWAY_TEST — no-op HID stubs

namespace {
// Post-calibration capture. The production _hidSetAxis writes into a private HID report with
// no read path, so without this the transform HIDAxis::dispatch() applies is unobservable —
// the frame-parser capture globals below record the *pre*-transform value read off the wire.
int16_t _sgtest_axisValue = 0;
uint8_t _sgtest_axisIndex = 0xFF;
// One report per drain is the batching contract. Nothing else counts report sends, so without
// this a fork that called _hidSend() itself would be invisible to every test.
uint8_t _sgtest_sendCount = 0;

static void _hidBegin()                              {}
static void _hidSetAxis(uint8_t axisIndex, int16_t value) {
    _sgtest_axisIndex = axisIndex;
    _sgtest_axisValue = value;
}
static void _hidSetButton(uint8_t, bool)             {}
static void _hidSetHat(uint8_t, uint8_t)             {}
static void _hidSend()                               { _sgtest_sendCount++; }
} // anonymous namespace

#endif // SIMGATEWAY_TEST

// ── Test capture globals ───────────────────────────────────────────────────────

#ifdef SIMGATEWAY_TEST
uint16_t _sgtest_lastControlId = 0;
uint16_t _sgtest_lastValue     = 0;
uint8_t  _sgtest_dispatchCount = 0;
#endif

// ── Axis calibration state ────────────────────────────────────────────────────

namespace {

// The live calibration, indexed by HID axis index. Deliberately a file-static POD rather
// than per-object members: the C runtime zeroes .bss before any dynamic initialiser runs,
// so every HIDAxis constructed at static init already sees an all-zero blob, which fails
// axisCalValid() and yields identity. That removes the static-init ordering hazard instead
// of managing it — there is no window in which an axis could divide by zero.
//
// Loaded from flash by SimGateway::setup(). Never a constructor argument; see SimGateway.h.
OpenSkyhawk::CalBlob _calBlob;

#ifndef SIMGATEWAY_TEST
// Read the stored blob into RAM, falling back to "uncalibrated" on anything unexpected.
//
// A rejected blob is cleared in RAM only — flash is left exactly as found, so a version the
// firmware does not recognise survives a downgrade instead of being overwritten by it. The
// user's next commit is what replaces it.
//
// EEPROM here is the RP2040 core's flash-backed emulation: begin() allocates a RAM mirror and
// memcpys the sector into it, costing microseconds and no erase, so its position in setup() is
// a matter of tidiness rather than timing.
//
// Only get()/put() are used. getDataPtr() sets the dirty flag unconditionally — even for a
// pure read — which would force a real sector erase on every subsequent commit().
void _calLoad() {
    EEPROM.begin(256); // rounded up to a 256-byte multiple by the library; blob is 72
    EEPROM.get(0, _calBlob);
    if (!OpenSkyhawk::calBlobValid(_calBlob)) {
        OpenSkyhawk::calBlobClear(_calBlob);
    }
}
#else
// Test builds must never write flash, and must not depend on whatever a previous run left
// behind. Start every test from a known uncalibrated state.
void _calLoad() {
    OpenSkyhawk::calBlobClear(_calBlob);
}
#endif

} // namespace

// ── HIDAxis ───────────────────────────────────────────────────────────────────

namespace OpenSkyhawk {

HIDAxis* HIDAxis::_head = nullptr;

HIDAxis::HIDAxis(uint16_t controlId, uint8_t axisIndex)
    : _next(nullptr), _controlId(controlId), _axisIndex(axisIndex)
{
    _next = _head;
    _head = this;
}

HIDAxis* HIDAxis::head()                   { return _head; }
uint16_t HIDAxis::controlId() const        { return _controlId; }
uint8_t  HIDAxis::axisIndex() const        { return _axisIndex; }
HIDAxis* HIDAxis::next() const             { return _next; }
void     HIDAxis::dispatch(uint16_t value) {
    // Calibration first, offset last — the pipeline stays unsigned until the final step.
    const uint16_t v = (_axisIndex < AXIS_CAL_SLOTS)
                     ? axisCalApply(_calBlob.axes[_axisIndex], value)
                     : value;
    _hidSetAxis(_axisIndex, (int16_t)(v - 32768));
}

// ── HIDButton ─────────────────────────────────────────────────────────────────

HIDButton* HIDButton::_head = nullptr;

HIDButton::HIDButton(uint16_t controlId, uint8_t buttonIndex)
    : _next(nullptr), _controlId(controlId), _buttonIndex(buttonIndex)
{
    _next = _head;
    _head = this;
}

HIDButton* HIDButton::head()                   { return _head; }
uint16_t   HIDButton::controlId() const        { return _controlId; }
HIDButton* HIDButton::next() const             { return _next; }
void       HIDButton::dispatch(uint16_t value) {
    _hidSetButton(_buttonIndex, value != 0);
}

// ── HIDHatSwitch ──────────────────────────────────────────────────────────────

HIDHatSwitch* HIDHatSwitch::_head = nullptr;

HIDHatSwitch::HIDHatSwitch(uint16_t controlId, uint8_t hatIndex)
    : _next(nullptr), _controlId(controlId), _hatIndex(hatIndex)
{
    _next = _head;
    _head = this;
}

HIDHatSwitch* HIDHatSwitch::head()                   { return _head; }
uint16_t      HIDHatSwitch::controlId() const        { return _controlId; }
HIDHatSwitch* HIDHatSwitch::next() const             { return _next; }
void          HIDHatSwitch::dispatch(uint16_t value) {
    _hidSetHat(_hatIndex, (uint8_t)(value > 8 ? 0 : value));
}

} // namespace OpenSkyhawk

// ── Internal parser ───────────────────────────────────────────────────────────

namespace {

enum class ParserState : uint8_t { IDLE, GOT_AA, IN_FRAME };

SerialUART*     _uart     = nullptr;
ParserState     _state    = ParserState::IDLE;
uint8_t         _frameBuf[4];
uint8_t         _framePos = 0;

#ifdef SIMGATEWAY_TEST
// 256 rather than 64: a CAL_DATA frame is 92 bytes, so the old capacity silently overflowed
// and surfaced as a confusing FAIL instead of an obvious error.
constexpr size_t SGTEST_CDC_CAPTURE_CAPACITY = 256;
uint8_t _sgtest_cdcBytes[SGTEST_CDC_CAPTURE_CAPACITY];
size_t  _sgtest_cdcCount    = 0;
bool    _sgtest_cdcOverflow = false;

// The UART side had no test seam. The calibration parser's whole safety property is that a
// rejected candidate is handed back to the relay byte-for-byte, and that is only assertable
// by capturing what reaches the UART.
constexpr size_t SGTEST_UART_CAPTURE_CAPACITY = 256;
uint8_t _sgtest_uartBytes[SGTEST_UART_CAPTURE_CAPACITY];
size_t  _sgtest_uartCount    = 0;
bool    _sgtest_uartOverflow = false;
#endif

// Every inbound byte that is not consumed by the calibration parser goes to PanelBridge
// through here. Mirrors _writeCdc's additive capture pattern: the tap records, the real
// write still happens.
void _writeUart(uint8_t b) {
#ifdef SIMGATEWAY_TEST
    if (_sgtest_uartCount < SGTEST_UART_CAPTURE_CAPACITY) {
        _sgtest_uartBytes[_sgtest_uartCount++] = b;
    } else {
        _sgtest_uartOverflow = true;
    }
#endif
    if (_uart) _uart->write(b);
}

void _writeCdc(uint8_t b) {
#ifdef SIMGATEWAY_TEST
    if (_sgtest_cdcCount < SGTEST_CDC_CAPTURE_CAPACITY) {
        _sgtest_cdcBytes[_sgtest_cdcCount++] = b;
    } else {
        _sgtest_cdcOverflow = true;
    }
#endif
    Serial.write(b);
}

// ── Calibration transport (issue #251) ────────────────────────────────────────
//
// Contract: FirmwarePlan/03-uart-usb-hid-protocol.md § Calibration Protocol (USB CDC).
//
// The parser runs at ALL times, not only during a session — it has to, because HELLO and
// GET_CAL are answered outside one. The property that keeps an always-on parser from eating
// the DCS stream is that a candidate is only consumed once it passes all three of magic,
// LEN-matches-TYPE, and CRC. Anything failing any of them is handed back to the relay
// byte-for-byte.

using OpenSkyhawk::CAL_FRAME_MAGIC;
using OpenSkyhawk::CAL_ENVELOPE_BYTES;
using OpenSkyhawk::CAL_MAX_FRAME;
using OpenSkyhawk::CAL_AXIS_NONE;

uint8_t  _calRx[CAL_MAX_FRAME];
uint16_t _calRxPos      = 0;

bool     _calSession    = false;
uint32_t _calLastRxMs   = 0;
uint8_t  _calStreamAxis = CAL_AXIS_NONE;  // axis whose RAW is streamed; emission lands in PR 4
uint8_t  _calRawSeq     = 0;              // free-running per-session counter for RAW

constexpr uint32_t CAL_SESSION_TIMEOUT_MS = 30000;

inline void _put16(uint8_t* p, uint16_t v) { p[0] = (uint8_t)(v & 0xFF); p[1] = (uint8_t)(v >> 8); }
inline uint16_t _get16(const uint8_t* p)   { return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); }

// Emit one frame to the host. Outbound is shared with DCS-BIOS text and _NODE_STATUS, so the
// client de-multiplexes on the raw chunk before line assembly.
void _calSend(uint8_t type, uint8_t seq, const uint8_t* payload, uint16_t len) {
    uint8_t frame[CAL_MAX_FRAME];
    const uint16_t n = OpenSkyhawk::calBuildFrame(frame, sizeof(frame), type, seq, payload, len);
    for (uint16_t i = 0; i < n; ++i) _writeCdc(frame[i]);
}

void _calNack(uint8_t type, uint8_t seq, uint8_t reason, uint8_t detail) {
    const uint8_t p[3] = { type, reason, detail };
    _calSend(OpenSkyhawk::CAL_T_NACK, seq, p, sizeof(p));
}

void _calAck(uint8_t type, uint8_t seq) {
    _calSend(OpenSkyhawk::CAL_T_ACK, seq, &type, 1);
}

void _calSendHelloAck(uint8_t seq) {
    const uint8_t p[6] = {
        OpenSkyhawk::CAL_PROTO_VERSION,
        (uint8_t)OpenSkyhawk::CAL_VERSION,
        OpenSkyhawk::AXIS_CAL_SLOTS,
        SIMGATEWAY_FW_MAJOR, SIMGATEWAY_FW_MINOR, SIMGATEWAY_FW_PATCH,
    };
    _calSend(OpenSkyhawk::CAL_T_HELLO_ACK, seq, p, sizeof(p));
}

// Fixed 82 bytes: two bitmasks then eight 10-byte records, so the client reads it with one
// typed-array view. Absent slots carry controlId 0x0000 and zeroed endpoints.
//
// This reflects committed state by construction: COMMIT and RESET both persist immediately,
// so there is no pending copy that could disagree with flash.
void _calSendCalData(uint8_t seq) {
    uint8_t  p[82];
    for (size_t i = 0; i < sizeof(p); ++i) p[i] = 0;

    uint8_t  presentMask = 0, calibratedMask = 0;
    uint16_t ctrl[OpenSkyhawk::AXIS_CAL_SLOTS] = { 0 };

    for (auto* a = OpenSkyhawk::HIDAxis::head(); a; a = a->next()) {
        const uint8_t i = a->axisIndex();
        if (i < OpenSkyhawk::AXIS_CAL_SLOTS) {
            presentMask = (uint8_t)(presentMask | (1u << i));
            ctrl[i]     = a->controlId();      // last writer wins; the list allows duplicates
        }
    }
    for (uint8_t i = 0; i < OpenSkyhawk::AXIS_CAL_SLOTS; ++i) {
        if (axisCalValid(_calBlob.axes[i])) calibratedMask = (uint8_t)(calibratedMask | (1u << i));
    }

    p[0] = presentMask;
    p[1] = calibratedMask;
    uint16_t o = 2;
    for (uint8_t i = 0; i < OpenSkyhawk::AXIS_CAL_SLOTS; ++i) {
        _put16(p + o, ctrl[i]);                    o += 2;
        _put16(p + o, _calBlob.axes[i].min);       o += 2;
        _put16(p + o, _calBlob.axes[i].centre);    o += 2;
        _put16(p + o, _calBlob.axes[i].max);       o += 2;
        _put16(p + o, _calBlob.axes[i].deadzone);  o += 2;
    }
    _calSend(OpenSkyhawk::CAL_T_CAL_DATA, seq, p, sizeof(p));
}

// Write a candidate blob to flash and, only if that succeeds, make it the live calibration.
// Returns false if the storage layer refused.
//
// **This is the only place _calBlob is assigned after boot.** Handlers build a candidate from
// it, mutate the copy, and hand it over — so the live blob can never hold values that failed
// to persist. Mutating _calBlob first and repairing it afterwards would work too, but it
// leaves a window where the invariant is false and a repair path that a future handler can
// forget. Here there is nothing to forget.
//
// What that prevents: on a failed write the client is told NO_STORAGE — "nothing was written"
// — and the read-back it is instructed to perform must agree. If the candidate had already
// gone live, GET_CAL would report the new values as stored and the verification step would
// confirm the opposite of the truth. RESET is the sharper case: a failed RESET-all would
// silently drop every axis to uncalibrated while reporting that nothing happened, then
// restore them on the next power cycle.
//
// The erase runs with interrupts disabled for ~28 ms (measured), while the PL011's 32-entry
// RX FIFO fills in 1.28 ms at 250000 baud — so inbound UART bytes are lost and the overrun
// flag is set every single time. Without the recovery below, each successful save latches the
// FAULT LED red for its 2 s minimum hold, and a HID parser stranded mid-frame consumes the
// next four arriving bytes as a controlId/value pair.
//
// The failure branch has no automated coverage: EEPROM.commit() only returns false when
// begin() never ran or its allocation failed, and it is stubbed to true in test builds. That
// is why the invariant is structural rather than a repair — it cannot be regression-tested.
bool _calPersist(const OpenSkyhawk::CalBlob& candidate) {
    OpenSkyhawk::CalBlob staged = candidate;
    OpenSkyhawk::calBlobSeal(staged);

#ifndef SIMGATEWAY_TEST
    EEPROM.put(0, staged);
    const bool ok = EEPROM.commit();

    // Recovery, in order, and it runs whether or not the write succeeded — the erase may
    // have started regardless. The overrun is self-inflicted and expected, not a link
    // fault, so do not "fix" this RSR clear away.
    while (_uart && _uart->available()) (void)_uart->read();
    uart0_hw->rsr = 0;
    _state    = ParserState::IDLE;
    _framePos = 0;
#else
    const bool ok = true;   // test builds never touch flash
#endif

    if (ok) _calBlob = staged;
    return ok;
}

// Stream one sample of the axis under calibration.
//
// Only the selected axis streams. The dialog calibrates one axis at a time — which is why
// SESSION_OPEN names one and STREAM_SELECT switches it — so streaming the other seven would be
// bytes nobody is looking at. Every other axis still dispatches to HID exactly as normal; it
// simply emits no RAW.
//
// Carries both values from the same sample: `raw` as read off the wire, and `cal` after the
// stored calibration. That is what lets the dialog show the sensor reading beside what DCS is
// receiving without the client reimplementing the transform. Note `cal` reflects the calibration
// the device currently *holds* — there is no pending state, so it does not preview the endpoints
// being captured.
//
// **Droppable by design.** _writeCdc() blocks when the host stops reading, and a stalled write
// here would stall the UART drain and overrun the RX FIFO — corrupting the DCS-BIOS stream to
// protect a preview sample. So the whole frame is skipped when the CDC buffer is short. Checked
// once, before the first byte: a per-byte check would emit truncated frames, which is worse than
// emitting nothing.
//
// The sequence counter advances for every sample that *would* have been sent, including dropped
// ones. That gap is the only way a client can distinguish lost samples from an axis that stopped
// moving — the stream is genuinely silent at rest.
void _calEmitRaw(uint8_t axisIndex, uint16_t raw) {
    if (!_calSession || axisIndex != _calStreamAxis) return;

    const uint8_t seq = _calRawSeq++;

    constexpr uint16_t RAW_FRAME_BYTES = CAL_ENVELOPE_BYTES + 5;
#ifndef SIMGATEWAY_TEST
    if ((uint16_t)Serial.availableForWrite() < RAW_FRAME_BYTES) return;   // drop, never truncate
#endif

    const uint16_t cal = (axisIndex < OpenSkyhawk::AXIS_CAL_SLOTS)
                       ? axisCalApply(_calBlob.axes[axisIndex], raw)
                       : raw;

    uint8_t p[5];
    p[0] = axisIndex;
    _put16(p + 1, raw);
    _put16(p + 3, cal);
    _calSend(OpenSkyhawk::CAL_T_RAW, seq, p, sizeof(p));
}

void _calEndSession() {
    _calSession    = false;
    _calStreamAxis = CAL_AXIS_NONE;
    _calRawSeq     = 0;
}

// One axis per COMMIT, never a batch. The framing gate has already fixed len at 9.
//
// An earlier draft carried a count and up to eight records. Dropping it removes the only
// variable-length type — so "one legal length per type" holds with no exception — and closes
// a hazard the batch form had: nothing stopped a batch naming the same axis twice with
// different values, and the handler would silently apply the last one. Committing the axis
// the user just finished also matches the capture flow, where only one axis streams at a time,
// and keeps a bad axis from discarding good ones alongside it.
void _calHandleCommit(uint8_t seq, const uint8_t* pay, uint16_t len) {
    (void)len;
    const uint8_t idx = pay[0];

    if (idx >= OpenSkyhawk::AXIS_CAL_SLOTS) {
        _calNack(OpenSkyhawk::CAL_T_COMMIT, seq, OpenSkyhawk::CAL_NACK_BAD_INDEX, idx);
        return;
    }
    if (_get16(pay + 7) != 0) {
        _calNack(OpenSkyhawk::CAL_T_COMMIT, seq, OpenSkyhawk::CAL_NACK_BAD_DEADZONE, idx);
        return;
    }
    const OpenSkyhawk::AxisCal axis = {
        _get16(pay + 1), _get16(pay + 3), _get16(pay + 5), _get16(pay + 7)
    };
    if (!axisCalValid(axis)) {
        _calNack(OpenSkyhawk::CAL_T_COMMIT, seq, OpenSkyhawk::CAL_NACK_BAD_ORDER, idx);
        return;
    }

    // Applied to a copy — the live blob changes only once the write has succeeded.
    OpenSkyhawk::CalBlob cand = _calBlob;
    cand.axes[idx] = axis;

    if (_calPersist(cand)) _calAck(OpenSkyhawk::CAL_T_COMMIT, seq);
    else _calNack(OpenSkyhawk::CAL_T_COMMIT, seq, OpenSkyhawk::CAL_NACK_NO_STORAGE, CAL_AXIS_NONE);
}

void _calHandleFrame(const uint8_t* f, uint16_t n) {
    const uint8_t  type = f[4];
    const uint8_t  seq  = f[5];
    const uint16_t len  = _get16(f + 6);
    const uint8_t* pay  = f + 8;
    (void)n;

    _calLastRxMs = millis();

    // Session gating. HELLO / GET_CAL / KEEPALIVE are answered at any time — that is what
    // lets the client show badges without opening a dialog.
    switch (type) {
        case OpenSkyhawk::CAL_T_COMMIT:
        case OpenSkyhawk::CAL_T_RESET:
        case OpenSkyhawk::CAL_T_STREAM_SELECT:
            if (!_calSession) {
                _calNack(type, seq, OpenSkyhawk::CAL_NACK_NO_SESSION, CAL_AXIS_NONE);
                return;
            }
            break;
        default: break;
    }

    switch (type) {
        case OpenSkyhawk::CAL_T_HELLO:
            _calSendHelloAck(seq);
            break;

        case OpenSkyhawk::CAL_T_GET_CAL:
            _calSendCalData(seq);
            break;

        case OpenSkyhawk::CAL_T_SESSION_OPEN: {
            const uint8_t axis = pay[0];
            if (axis != CAL_AXIS_NONE && axis >= OpenSkyhawk::AXIS_CAL_SLOTS) {
                _calNack(type, seq, OpenSkyhawk::CAL_NACK_BAD_INDEX, axis);
                break;
            }
            _calSession    = true;
            _calStreamAxis = axis;
            _calRawSeq     = 0;
            uint8_t p[5];
            _put16(p + 0, (uint16_t)(CAL_SESSION_TIMEOUT_MS & 0xFFFF));
            _put16(p + 2, (uint16_t)(CAL_SESSION_TIMEOUT_MS >> 16));
            p[4] = axis;
            _calSend(OpenSkyhawk::CAL_T_SESSION_ACK, seq, p, sizeof(p));
            break;
        }

        case OpenSkyhawk::CAL_T_STREAM_SELECT: {
            const uint8_t axis = pay[0];
            if (axis != CAL_AXIS_NONE && axis >= OpenSkyhawk::AXIS_CAL_SLOTS) {
                _calNack(type, seq, OpenSkyhawk::CAL_NACK_BAD_INDEX, axis);
                break;
            }
            _calStreamAxis = axis;
            _calAck(type, seq);
            break;
        }

        case OpenSkyhawk::CAL_T_SESSION_CLOSE:
            _calEndSession();
            _calAck(type, seq);
            break;

        case OpenSkyhawk::CAL_T_KEEPALIVE:
            _calAck(type, seq);
            break;

        case OpenSkyhawk::CAL_T_COMMIT:
            _calHandleCommit(seq, pay, len);
            break;

        case OpenSkyhawk::CAL_T_RESET: {
            const uint8_t idx = pay[0];
            if (idx != CAL_AXIS_NONE && idx >= OpenSkyhawk::AXIS_CAL_SLOTS) {
                _calNack(type, seq, OpenSkyhawk::CAL_NACK_BAD_INDEX, idx);
                break;
            }
            // Deleting calibration, not restoring a default profile: the axis reverts to
            // identity passthrough. Persists immediately — no following COMMIT needed.
            //
            // Cleared on a copy for the same reason as COMMIT, and it matters more here: a
            // failed RESET-all would otherwise drop every axis to uncalibrated in RAM while
            // telling the client nothing was written.
            OpenSkyhawk::CalBlob cand = _calBlob;
            if (idx == CAL_AXIS_NONE) {
                for (uint8_t i = 0; i < OpenSkyhawk::AXIS_CAL_SLOTS; ++i)
                    cand.axes[i] = OpenSkyhawk::AxisCal{ 0, 0, 0, 0 };
            } else {
                cand.axes[idx] = OpenSkyhawk::AxisCal{ 0, 0, 0, 0 };
            }
            if (_calPersist(cand)) _calAck(type, seq);
            else _calNack(type, seq, OpenSkyhawk::CAL_NACK_NO_STORAGE, CAL_AXIS_NONE);
            break;
        }

        default:
            // Unreachable: calLenValidForType() rejects unknown types before the payload is
            // buffered, so this cannot be entered from the wire. Kept as a belt-and-braces
            // answer rather than a silent drop.
            _calNack(type, seq, OpenSkyhawk::CAL_NACK_BAD_TYPE, CAL_AXIS_NONE);
            break;
    }
}

// Give every held byte back to the relay, in order, and start scanning fresh.
void _calFlushHeld() {
    for (uint16_t i = 0; i < _calRxPos; ++i) _writeUart(_calRx[i]);
    _calRxPos = 0;
}

// One inbound byte. Returns true if this function took responsibility for it — either it is
// being held as part of a candidate, it belonged to a consumed frame, or it was handed back
// to the relay here. False means the caller should relay it.
bool _calInboundByte(uint8_t b) {
    // Magic phase.
    if (_calRxPos < 4) {
        if (b == CAL_FRAME_MAGIC[_calRxPos]) { _calRx[_calRxPos++] = b; return true; }
        _calFlushHeld();
        // The mismatching byte may itself begin a new candidate — 0xAA appears only at
        // index 0 of the magic, so this single re-test is enough to be lossless.
        if (b == CAL_FRAME_MAGIC[0]) { _calRx[_calRxPos++] = b; return true; }
        return false;
    }

    _calRx[_calRxPos++] = b;

    // Header complete: check LEN against TYPE *before* buffering any payload, so a false
    // magic followed by noise cannot make us wait for up to 65535 bytes.
    if (_calRxPos == 8) {
        if (!OpenSkyhawk::calLenValidForType(_calRx[4], _get16(_calRx + 6))) _calFlushHeld();
        return true;
    }
    if (_calRxPos < 8) return true;

    const uint16_t total = (uint16_t)(CAL_ENVELOPE_BYTES + _get16(_calRx + 6));
    if (_calRxPos < total) return true;

    if (OpenSkyhawk::calFrameCrcOk(_calRx, total)) {
        _calHandleFrame(_calRx, total);
        _calRxPos = 0;
    } else {
        _calFlushHeld();   // never was a frame — the relay gets its bytes back
    }
    return true;
}

// Process one UART byte through the state machine.
// Returns true if a HID setter fired this byte.
bool _processByte(uint8_t b) {
    switch (_state) {

        case ParserState::IDLE:
            if (b == 0xAA) {
                _state = ParserState::GOT_AA;
            } else {
                _writeCdc(b); // DCS-BIOS byte → CDC
            }
            return false;

        case ParserState::GOT_AA:
            if (b == 0x55) {
                _framePos = 0;
                _state    = ParserState::IN_FRAME;
            } else {
                // Resync: 0xAA was not magic; forward both bytes and resume.
                _writeCdc(0xAA);
                _writeCdc(b);
                _state = ParserState::IDLE;
            }
            return false;

        case ParserState::IN_FRAME: {
            _frameBuf[_framePos++] = b;
            if (_framePos < 4) return false;

            uint16_t controlId = (uint16_t)_frameBuf[0] | ((uint16_t)_frameBuf[1] << 8);
            uint16_t value     = (uint16_t)_frameBuf[2] | ((uint16_t)_frameBuf[3] << 8);
            bool fired = false;

            for (auto* a = OpenSkyhawk::HIDAxis::head(); a; a = a->next()) {
                if (a->controlId() == controlId) {
                    // Forking here rather than beside the controlId decode gives the axis
                    // index for free and emits nothing for button or hat frames. It must not
                    // touch `fired` or send a HID report: one report per drain is the
                    // contract, and breaking it only while calibrating would change what DCS
                    // sees mid-session.
                    _calEmitRaw(a->axisIndex(), value);
                    a->dispatch(value);
                    fired = true;
                }
            }
            for (auto* btn = OpenSkyhawk::HIDButton::head(); btn; btn = btn->next()) {
                if (btn->controlId() == controlId) { btn->dispatch(value); fired = true; }
            }
            for (auto* hat = OpenSkyhawk::HIDHatSwitch::head(); hat; hat = hat->next()) {
                if (hat->controlId() == controlId) { hat->dispatch(value); fired = true; }
            }

#ifdef SIMGATEWAY_TEST
            if (fired) {
                _sgtest_lastControlId = controlId;
                _sgtest_lastValue     = value;
                _sgtest_dispatchCount++;
            }
#endif

            _state = ParserState::IDLE;
            return fired;
        }
    }
    return false;
}

} // anonymous namespace

// ── Status LED state machine ──────────────────────────────────────────────────
//
// Drives the two board-mounted SimGateway status LEDs (RED = GP3, GREEN = GP2,
// active-high) with a non-blocking millis() animator, ticked from loop().
//
// The state-selection + animation-phase logic is PURE: it takes `now` as a
// parameter, touches no hardware, and never calls millis() internally — so
// SIMGATEWAY_TEST builds unit-test it with injected inputs (statusInject /
// statusFaultStep). Only _applyLed() touches GPIO, and the TinyUSBDevice.mounted()
// poll + PL011 RSR read live behind #ifndef SIMGATEWAY_TEST.

#ifndef SIMGATEWAY_TEST
#include "hardware/structs/uart.h" // uart0_hw — PL011 registers
#include "hardware/regs/uart.h"    // UART_UARTRSR_*_BITS error-flag masks
#endif

namespace {

using SimGateway::Anim;
using SimGateway::LedState;

enum class LedColor : uint8_t { NONE, RED, GREEN };

constexpr uint8_t  PIN_LED_GREEN     = 2;    // GP2
constexpr uint8_t  PIN_LED_RED       = 3;    // GP3
constexpr uint32_t STREAM_WINDOW_MS  = 500;  // CDC-RX recency → STREAMING
constexpr uint32_t INIT_WINDOW_MS    = 2000; // boot grace before NO_HOST if never mounted
constexpr uint32_t FAULT_MIN_HOLD_MS = 2000; // FAULT visibility floor (~8 fast flashes)
constexpr uint32_t SLOW_PERIOD_MS    = 1000; // 1 Hz
constexpr uint32_t FAST_PERIOD_MS    = 250;  // 4 Hz
constexpr uint32_t ALT_PERIOD_MS     = 500;  // reserved
constexpr bool     ENABLE_TRAFFIC_PULSE = false; // STREAMING is plain SOLID per AC

struct StatusInputs {
    uint32_t now;
    bool     mounted;
    uint32_t lastCdcRxMs;
    bool     everMounted;
    bool     faultActive;
};

struct LedOutput {
    LedState state;
    LedColor color;
    Anim     anim;
    bool     redOn;
    bool     greenOn;
};

// Sampled signal state (updated by loop() / statusTick()).
uint32_t _lastCdcRxMs      = 0;
bool     _uartMovedThisTick = false;
bool     _everMounted      = false;

// FAULT latch state.
bool     _faultLatched  = false;
bool     _faultEverSeen = false;
uint32_t _lastFaultMs   = 0;
uint32_t _lastUartRxMs  = 0;

// Pure: pick the active state from sampled inputs (priority high → low).
LedState _selectState(const StatusInputs& in) {
    if (in.faultActive) return LedState::FAULT;
    if (!in.mounted && (in.everMounted || in.now >= INIT_WINDOW_MS)) return LedState::NO_HOST;
    if (in.mounted && (uint32_t)(in.now - in.lastCdcRxMs) <= STREAM_WINDOW_MS) return LedState::STREAMING;
    if (in.mounted) return LedState::USB_IDLE;
    return LedState::INIT;
}

// Pure: map a state to its colour + animation.
void _animFor(LedState s, LedColor& color, Anim& anim) {
    switch (s) {
        case LedState::FAULT:     color = LedColor::RED;   anim = Anim::FAST;  break;
        case LedState::NO_HOST:   color = LedColor::RED;   anim = Anim::SOLID; break;
        case LedState::STREAMING: color = LedColor::GREEN; anim = ENABLE_TRAFFIC_PULSE ? Anim::PULSE : Anim::SOLID; break;
        case LedState::USB_IDLE:  color = LedColor::GREEN; anim = Anim::SLOW;  break;
        case LedState::INIT:      color = LedColor::RED;   anim = Anim::SLOW;  break;
    }
}

// Pure: on/off for an animation at time `now` (50% duty for blinks).
bool _animOn(Anim anim, uint32_t now) {
    switch (anim) {
        case Anim::OFF:   return false;
        case Anim::SOLID: return true;
        case Anim::SLOW:  return (now % SLOW_PERIOD_MS) < (SLOW_PERIOD_MS / 2);
        case Anim::FAST:  return (now % FAST_PERIOD_MS) < (FAST_PERIOD_MS / 2);
        case Anim::ALT:   return (now % ALT_PERIOD_MS)  < (ALT_PERIOD_MS / 2);
        case Anim::PULSE: return true; // baseline solid; PULSE off-blip disabled by default
    }
    return false;
}

// Pure: resolve full LED output (state + colour + anim + pin levels) for `now`.
LedOutput _resolveStatus(const StatusInputs& in) {
    LedOutput out{};
    out.state = _selectState(in);
    _animFor(out.state, out.color, out.anim);
    bool on = _animOn(out.anim, in.now);
    out.redOn   = (out.color == LedColor::RED)   && on;
    out.greenOn = (out.color == LedColor::GREEN) && on;
    return out;
}

// Update the FAULT latch from this tick's signals. Shared by production sampling
// (statusTick) and the SIMGATEWAY_TEST statusFaultStep() hook.
//   rsrError    — a PL011 error bit was set this tick.
//   uartRxMoved — ≥1 error-free byte was read from the UART this tick.
// FAULT latches on any error and re-stamps while errors persist; it clears only
// when (a) ≥ FAULT_MIN_HOLD_MS has elapsed since the last error AND (b) an
// error-free byte arrived on the UART *after* that error. A silent bus therefore
// holds FAULT until clean data resumes. Returns the resolved faultActive flag.
bool _updateFaultLatch(uint32_t now, bool rsrError, bool uartRxMoved) {
    if (rsrError) {
        _faultEverSeen = true;
        _faultLatched  = true;
        _lastFaultMs   = now;
    } else if (uartRxMoved) {
        _lastUartRxMs = now;
    }
    if (_faultLatched &&
        (uint32_t)(now - _lastFaultMs) >= FAULT_MIN_HOLD_MS &&
        (int32_t)(_lastUartRxMs - _lastFaultMs) > 0) {
        _faultLatched = false;
    }
    return _faultLatched;
}

#ifdef SIMGATEWAY_TEST
bool      _sgtest_redLevel   = false;
bool      _sgtest_greenLevel = false;
StatusInputs _sgtest_inputs  = {};
LedOutput    _sgtest_out     = {};
#endif

// The only function that touches the LED GPIO (active-high). In test builds it
// captures the resolved levels instead of writing pins.
void _applyLed(const LedOutput& out) {
#ifdef SIMGATEWAY_TEST
    _sgtest_redLevel   = out.redOn;
    _sgtest_greenLevel = out.greenOn;
#else
    digitalWrite(PIN_LED_RED,   out.redOn   ? HIGH : LOW);
    digitalWrite(PIN_LED_GREEN, out.greenOn ? HIGH : LOW);
#endif
}

} // anonymous namespace

// ── SimGateway ────────────────────────────────────────────────────────────────

namespace SimGateway {

void setup(SerialUART& uart, uint8_t txPin, uint8_t rxPin) {
    _uart = &uart;

#ifndef SIMGATEWAY_TEST
    // USB identity must be set before TinyUSB begins enumerating.
    TinyUSBDevice.setID(0x2E8A, 0x4134);
    TinyUSBDevice.setManufacturerDescriptor("OpenSkyhawk");
    TinyUSBDevice.setProductDescriptor("A-4E Skyhawk");
    Serial.begin(250000); // start Adafruit_USBD_CDC; required before available()/write() work
                          // (baud arg ignored by USB CDC; set to nominal 250000 to match docs)
    // Name the CDC interface (iInterface) so the serial port is identifiable by name, not just
    // VID/PID + CDC class. Must follow Serial.begin(), which otherwise leaves the library
    // default "TinyUSB Serial".
    Serial.setStringDescriptor("A-4E Skyhawk DCS-BIOS");
#endif

    _uart->setTX(txPin);
    _uart->setRX(rxPin);
    _uart->begin(250000);

    _calLoad(); // RAM-only in SIMGATEWAY_TEST builds — tests never touch flash

    _hidBegin(); // no-op in SIMGATEWAY_TEST builds

    statusLedBegin(); // configure GP2/GP3 status LEDs (both off)

#ifndef SIMGATEWAY_TEST
    Serial.println(F("=============================="));
    Serial.println(F("  SimGateway"));
    Serial.println(F("=============================="));
#endif
}

const OpenSkyhawk::CalBlob& calibration() { return _calBlob; }

void loop() {
    // 1. Forward CDC → UART (PC DCS-BIOS stream to PanelBridge).
    //    Bytes moving here is the "host is talking" signal that drives STREAMING.
    //    Every byte passes the calibration parser first. It consumes only complete, valid
    //    frames; anything else reaches PanelBridge untouched, including the bytes of a
    //    rejected candidate. Cost is a comparison per byte against a UART that is three
    //    orders of magnitude slower.
    bool cdcMoved = false;
    while (Serial.available()) {
        const uint8_t b = (uint8_t)Serial.read();
        if (!_calInboundByte(b)) _writeUart(b);
        cdcMoved = true;
    }
    //    Calibration bytes still count as "the host is talking" — a session is host traffic
    //    even though none of it is forwarded.
    if (cdcMoved) _lastCdcRxMs = millis();

    // 2. Drain UART; HID frames dispatched, DCS-BIOS bytes forwarded to CDC.
    //    UART RX moving (error-free) is the proof-of-recovery signal for FAULT.
    bool anyFired = false;
    while (_uart->available()) {
        anyFired |= _processByte(_uart->read());
        _uartMovedThisTick = true;
    }

    // 3. Flush one HID report if any setter fired this iteration
    if (anyFired) _hidSend();

    // 3b. Expire an abandoned calibration session. Placed after the HID send so the joystick
    //     path is never delayed by it. An expired session degrades to exactly normal
    //     operation — the outbound direction was never taken over.
    if (_calSession && (uint32_t)(millis() - _calLastRxMs) > CAL_SESSION_TIMEOUT_MS) {
        _calEndSession();
    }

    // 4. Advance the status-LED state machine (non-blocking). Reads the uart0 RSR
    //    after the drain so this tick's UART errors are visible.
    statusTick();
}

void statusLedBegin() {
#ifndef SIMGATEWAY_TEST
    pinMode(PIN_LED_RED,   OUTPUT);
    pinMode(PIN_LED_GREEN, OUTPUT);
    digitalWrite(PIN_LED_RED,   LOW);
    digitalWrite(PIN_LED_GREEN, LOW);
#endif
}

void statusTick() {
    uint32_t now = millis();

#ifndef SIMGATEWAY_TEST
    bool mounted = TinyUSBDevice.mounted();

    // Read the PL011 sticky error flags. Serial1 == uart0 on this board
    // (SerialUART.cpp: `#define __SERIAL1_DEVICE uart0`); a future board wiring the
    // status UART to uart1 must change STATUS_UART_HW. Clear on every read
    // (write-to-clear via the ECR alias) or a single overrun pins FAULT forever.
    auto* const        STATUS_UART_HW = uart0_hw;
    constexpr uint32_t RSR_ERR = UART_UARTRSR_OE_BITS | UART_UARTRSR_BE_BITS |
                                 UART_UARTRSR_PE_BITS | UART_UARTRSR_FE_BITS;
    bool rsrError = (STATUS_UART_HW->rsr & RSR_ERR) != 0;
    if (rsrError) STATUS_UART_HW->rsr = 0;
#else
    bool mounted  = false; // test builds drive the state machine via the hooks below
    bool rsrError = false;
#endif

    if (mounted) _everMounted = true;
    bool faultActive   = _updateFaultLatch(now, rsrError, _uartMovedThisTick);
    _uartMovedThisTick = false;

    StatusInputs in{ now, mounted, _lastCdcRxMs, _everMounted, faultActive };
    _applyLed(_resolveStatus(in));
}

#ifdef SIMGATEWAY_TEST
bool feedByte(uint8_t b)  { return _processByte(b); }
void resetParser()         { _state = ParserState::IDLE; _framePos = 0; }
void resetCdcCapture()     { _sgtest_cdcCount = 0; _sgtest_cdcOverflow = false; }
size_t cdcCaptureCount()   { return _sgtest_cdcCount; }
uint8_t cdcCaptureByte(size_t index) {
    return (index < _sgtest_cdcCount) ? _sgtest_cdcBytes[index] : 0;
}
bool cdcCaptureOverflow()  { return _sgtest_cdcOverflow; }

// Mirrors loop() step 1 exactly, relay included — a hook that only ran the parser would let
// a test pass while the bytes it declined went nowhere.
bool feedCdcByte(uint8_t b) {
    const bool absorbed = _calInboundByte(b);
    if (!absorbed) _writeUart(b);
    return absorbed;
}
void resetUartCapture()       { _sgtest_uartCount = 0; _sgtest_uartOverflow = false; }
size_t uartCaptureCount()     { return _sgtest_uartCount; }
uint8_t uartCaptureByte(size_t index) {
    return (index < _sgtest_uartCount) ? _sgtest_uartBytes[index] : 0;
}
bool uartCaptureOverflow()    { return _sgtest_uartOverflow; }
void calResetForTest()        { _calEndSession(); _calRxPos = 0; _calLastRxMs = 0; }
bool calSessionOpen()         { return _calSession; }
uint8_t calStreamAxis()       { return _calStreamAxis; }

void calSetForTest(const OpenSkyhawk::CalBlob& blob) { _calBlob = blob; }
int16_t lastAxisValue()    { return _sgtest_axisValue; }
uint8_t lastAxisIndex()    { return _sgtest_axisIndex; }
void resetAxisCapture()    { _sgtest_axisValue = 0; _sgtest_axisIndex = 0xFF; }
uint8_t hidSendCount()     { return _sgtest_sendCount; }
void resetHidSendCount()   { _sgtest_sendCount = 0; }

// ── Status-LED test hooks ─────────────────────────────────────────────────────
void statusInject(uint32_t now, bool mounted, uint32_t lastCdcRxMs, bool faultActive) {
    if (mounted) _everMounted = true;
    _sgtest_inputs = StatusInputs{ now, mounted, lastCdcRxMs, _everMounted, faultActive };
}

void statusResolve() {
    _sgtest_out = _resolveStatus(_sgtest_inputs);
    _applyLed(_sgtest_out);
}

bool statusFaultStep(uint32_t now, bool rsrError, bool uartRxMoved) {
    return _updateFaultLatch(now, rsrError, uartRxMoved);
}

LedState statusState()      { return _sgtest_out.state; }
Anim     statusAnim()       { return _sgtest_out.anim; }
bool     statusRedLevel()   { return _sgtest_redLevel; }
bool     statusGreenLevel() { return _sgtest_greenLevel; }

void statusResetForTest() {
    _everMounted       = false;
    _faultLatched      = false;
    _faultEverSeen     = false;
    _lastFaultMs       = 0;
    _lastUartRxMs      = 0;
    _lastCdcRxMs       = 0;
    _uartMovedThisTick = false;
    _sgtest_redLevel   = false;
    _sgtest_greenLevel = false;
    _sgtest_inputs     = StatusInputs{};
    _sgtest_out        = LedOutput{};
}
#endif

} // namespace SimGateway

#endif // ARDUINO_ARCH_RP2040