diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 5d2b848..67bc3e3 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2999,6 +2999,158 @@ "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" + }, + { + "id": "REQ-ENDPOINT-001", + "title": "GPIO and SPI endpoint-type ids", + "text": "rcp::endpoint::kEndpointTypeGpio shall equal 0x02 and rcp::endpoint::kEndpointTypeSpi shall equal 0x03, matching the ep_type ids this milestone assigns for the GPIO and SPI endpoint types.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ENDPOINT-002", + "title": "evt[2:0] write-semantics decode", + "text": "rcp::endpoint::write_semantics_of shall map evt_op values 0-7 to Replace/Or/And/Xor/Reserved/Add/Subtract/Reconfigure respectively, masking its argument to the low 3 bits before conversion.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ENDPOINT-003", + "title": "Generic bitmask write combinators", + "text": "rcp::endpoint::apply_bitmask_write shall compute Replace/Or/And/Xor of `current` and `operand` correctly for WriteSemantics::Replace/Or/And/Xor respectively.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ENDPOINT-004", + "title": "Saturating add/subtract", + "text": "rcp::endpoint::apply_bitmask_write's Add and Subtract semantics, and the underlying rcp::endpoint::saturating_add/saturating_subtract helpers, shall clamp at the representable range's max/0 respectively rather than wrapping on overflow/underflow.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ENDPOINT-005", + "title": "Reserved and Reconfigure are not generic combinators", + "text": "rcp::endpoint::apply_bitmask_write shall return EndpointErrc::reserved_write_semantics for WriteSemantics::Reserved and EndpointErrc::non_combinable_write_semantics for WriteSemantics::Reconfigure, and EndpointErrc shall report a non-empty diagnostic message in its own error_category.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ENDPOINT-006", + "title": "Generic trigger-signal table", + "text": "rcp::endpoint::TriggerRegistry::notify shall queue a signal for drain() only when that signal id is currently enabled, disable() shall stop further notifications for that id from queuing, and drain() shall return queued signal ids in occurrence order and clear the queue.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-001", + "title": "GPIO payload is a 4-byte big-endian bitmask", + "text": "rcp::gpio::kGpioPayloadLen shall be 4 and kMaxPins shall be 32; encode_gpio_payload/decode_gpio_payload shall round-trip a PinMask value in big-endian byte order, and decode_gpio_payload shall return wire::WireErrc::short_buffer when given fewer than kGpioPayloadLen bytes.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-002", + "title": "GPIO write semantics: generic combinators", + "text": "rcp::gpio::apply_gpio_write shall apply Replace/Or/And/Xor to GpioState::values via rcp::endpoint::apply_bitmask_write.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-003", + "title": "GPIO write semantics: saturating Add/Subtract", + "text": "rcp::gpio::apply_gpio_write shall apply the saturating Add/Subtract rule to GpioState::values, clamping instead of wrapping, using the same rcp::endpoint saturation helpers PWM_OUT is expected to reuse at v2.4.0.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-004", + "title": "GPIO write semantics: Reserved rejected, Reconfigure retargets directions", + "text": "rcp::gpio::apply_gpio_write shall return an error without modifying GpioState for WriteSemantics::Reserved, and shall replace GpioState::directions (leaving GpioState::values unchanged) for WriteSemantics::Reconfigure.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-005", + "title": "GPIO per-pin change/rising/falling trigger signals", + "text": "rcp::gpio::evaluate_gpio_triggers shall notify a pin's Change signal on any bit transition and additionally its Rising or Falling signal depending on transition direction, reporting only signals the caller's rcp::endpoint::TriggerRegistry had armed, and shall notify nothing for a pin whose bit did not change.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-006", + "title": "GPIO functional config block wiring", + "text": "rcp::gpio::encode_gpio_functional_config/decode_gpio_functional_config shall round-trip a pin-direction mask and per-pin enabled-edge bitmasks through rcp::regmap::EndpointFunctionalConfig::data, and decode_gpio_functional_config shall return wire::WireErrc::short_buffer for an undersized blob.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-007", + "title": "GpioEndpoint request-dispatch pattern", + "text": "rcp::gpio::GpioEndpoint::handle_write shall apply one write request's semantics and operand to its internal state, evaluate the resulting transition's trigger signals, and report the endpoint's new pin-value bitmask, in one call.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-GPIO-008", + "title": "GpioEndpoint error propagation and GpioErrc category", + "text": "rcp::gpio::GpioEndpoint::handle_write shall propagate an underlying write-semantics error without modifying its endpoint state, and rcp::gpio::GpioErrc shall report a non-empty diagnostic message in its own error_category.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SPI-001", + "title": "SPI channel selection via evt[2:0]", + "text": "rcp::spi::kMaxChannels shall be 6; rcp::spi::channel_of shall accept evt[2:0] values 0-5 as channel selectors (masking its input to the low 3 bits first) and return SpiErrc::channel_out_of_range for 6 and 7.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SPI-002", + "title": "SPI compound-wait status-byte truncation rule", + "text": "rcp::spi::compound_wait_matches shall compare only the first kCompoundWaitCompareLen (4) bytes of a status transfer against the expected pattern, ignoring any difference beyond that window, shall reject a status transfer longer than kMaxStatusBytes (20), and shall return false when the truncated comparison window is empty.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SPI-003", + "title": "SPI raw PICO-out/POCI-in transfer", + "text": "rcp::spi::SpiEndpoint::transfer shall record the sent and received bytes for the given channel independently of every other channel's last transfer, and shall return SpiErrc::channel_out_of_range for a channel >= kMaxChannels without recording anything.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SPI-004", + "title": "SPI transfer-complete and per-CS trigger signals", + "text": "rcp::spi::SpiEndpoint::transfer shall notify CsAssert, then TransferComplete, then CsDeassert for the transferred channel's rcp::endpoint::TriggerRegistry, spi_signal_id shall assign a distinct SignalId to every (channel, signal) pair, and a channel's armed signals shall not affect notification delivery for any other channel.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-SPI-005", + "title": "SpiErrc is a distinct error category with a non-empty message", + "text": "rcp::spi::make_error_code(SpiErrc::channel_out_of_range) shall report rcp::spi::spi_category() as its category and shall have a non-empty diagnostic message.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" } ] } diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a6950d..a925ed6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.2.0 + VERSION 2.3.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/ROADMAP.md b/ROADMAP.md index 6eda91a..45db7dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -308,6 +308,40 @@ coverage lives in `tests/test_discovery.cpp` (REQ-DISC-001..009). ### 47. Basic Endpoint Types I — GPIO & SPI (v2.3.0) +**Done (v2.3.0):** `rcp/endpoint.hpp` establishes the shared +endpoint-registration/request-dispatch scaffolding this milestone's own +description calls for: `ep_type` id constants (`kEndpointTypeGpio`, +`kEndpointTypeSpi`, with the remaining v2.4.0/v2.7.0 ids named in comments +rather than guessed at), `write_semantics_of` decoding +`AcfMessageInfo::evt_op` into the 8-way `WriteSemantics` enum, the +`saturating_add`/`saturating_subtract` templates implementing the +arithmetic-add/subtract clamping rule generically over the caller's +unsigned integer width, `apply_bitmask_write` covering the six +value-combining semantics (Replace/Or/And/Xor/Add/Subtract) with +Reserved rejected and Reconfigure deliberately left to each endpoint type, +and `TriggerRegistry`, a generic enable/notify/drain trigger-signal table. +`rcp/gpio.hpp` implements GPIO (`ep_type 0x02`): a 4-byte big-endian pin +bitmask payload (`encode_gpio_payload`/`decode_gpio_payload`), +`apply_gpio_write` completing the 8-way semantics by retargeting +Reconfigure at a separate pin-direction mask instead of the pin-value +bitmask, per-pin change/rising/falling trigger signals +(`evaluate_gpio_triggers` over `TriggerRegistry`), a functional-config +codec (`encode_gpio_functional_config`/`decode_gpio_functional_config`) +interpreting `regmap::EndpointFunctionalConfig::data`'s previously-opaque +blob, and `GpioEndpoint` tying all of the above into one +request-dispatch entry point. `rcp/spi.hpp` implements SPI (`ep_type +0x03`): `channel_of` decoding `evt[2:0]` as a 0-5 channel selector (SPI's +own repurposing of that field, distinct from GPIO's write-semantics use of +it), `SpiEndpoint::transfer` recording a raw full-duplex PICO-out/POCI-in +byte exchange per channel and firing that channel's CsAssert / +TransferComplete / CsDeassert trigger signals in observation order, and +`compound_wait_matches` implementing the compound-wait status-byte +truncation rule (comparing only the first 4 of up to 20 status bytes) as +scaffolding for the v2.5.0 compound-wait request kind itself. New coverage +lives in `tests/test_endpoint.cpp` (REQ-ENDPOINT-001..006), +`tests/test_gpio.cpp` (REQ-GPIO-001..008), and `tests/test_spi.cpp` +(REQ-SPI-001..005), traced in `.fusa-reqs.json`. + - GPIO (`ep_type 0x02`): 4-byte bitmask request/response, the 8 write-semantics selected by `evt[2:0]` (replace / OR / AND / XOR / reserved / add / subtract / reconfigure), per-pin change/rising/falling diff --git a/include/rcp/endpoint.hpp b/include/rcp/endpoint.hpp new file mode 100644 index 0000000..d66ad30 --- /dev/null +++ b/include/rcp/endpoint.hpp @@ -0,0 +1,237 @@ +// fusa:req REQ-ENDPOINT-001 +// fusa:req REQ-ENDPOINT-002 +// fusa:req REQ-ENDPOINT-003 +// fusa:req REQ-ENDPOINT-004 +// fusa:req REQ-ENDPOINT-005 +// fusa:req REQ-ENDPOINT-006 + +// Shared endpoint-registration and request-dispatch scaffolding — the pieces +// every concrete OPEN Alliance TC18 Remote Control Protocol Specification +// v0.5.1_RC endpoint type builds request handling on top of, rather than +// each reinventing its own copy (extraction §4.5, §5.x). +// +// ROADMAP.md milestone 47, "Basic Endpoint Types I — GPIO & SPI (v2.3.0)": +// GPIO (rcp/gpio.hpp) and SPI (rcp/spi.hpp) are deliberately sequenced first +// because their request/response payload shapes are the simplest fully +// specified ones, which makes them the right place to establish this +// reusable pattern before later, more complex endpoint types (I2C, UART, +// ADC, PWM_OUT, PWM_IN at v2.4.0; LIN, CAN, ISELED, MDIO, Wakeup at v2.7.0) +// need it too. This header holds exactly the parts of that pattern GPIO and +// SPI both need directly: +// - endpoint-type id constants (ep_type), extended by each later milestone +// - the 8-way evt[2:0] write-semantics decode shared by every endpoint +// type whose functional payload is a combinable value (GPIO now, +// PWM_OUT at v2.4.0) +// - the arithmetic add/subtract saturation rule two of those eight +// semantics require, generic over the caller's unsigned integer width +// - a generic trigger-signal table: an enable/disable set plus a pending- +// delivery queue, addressed by an opaque per-endpoint-type signal id, so +// GPIO's per-pin change/rising/falling signals and SPI's +// transfer-complete/per-CS assert/de-assert signals can both be built on +// the same primitive without duplicating the bookkeeping +// +// This header has no dependency on rcp/wire.hpp's AcfMessageInfo directly — +// it operates on the already-decoded evt[2:0] value a caller extracts from +// AcfMessageInfo::evt_op, keeping this scaffolding usable independent of any +// particular wire encoding decision. +// +// Field names and behavior below implement TC18's *behavior* as described in +// an internal structured extraction of the specification named above; no +// text from that document is reproduced here. The concrete encodings chosen +// in this file are this implementation's own, same as the equivalent +// disclaimers in rcp/wire.hpp, rcp/lifecycle.hpp, rcp/regmap.hpp, and +// rcp/discovery.hpp. +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace rcp { +namespace endpoint { + +// ── Endpoint-type ids (ep_type) ────────────────────────────────────────────── +// Assigned starting at 1, per rcp/regmap.hpp's own header comment reserving +// this numbering for endpoint milestones. Only the two types this milestone +// implements are given real constants; later milestones' ids are named in +// comments here so the numbering stays visibly centralized in one place +// rather than getting re-derived ad hoc by each new header. + +using EndpointTypeId = uint8_t; + +constexpr EndpointTypeId kEndpointTypeGpio = 0x02; // this milestone (v2.3.0) +constexpr EndpointTypeId kEndpointTypeSpi = 0x03; // this milestone (v2.3.0) +// 0x04 I2C, 0x05 UART, 0x07 PWM_OUT, 0x08 PWM_IN, 0x09 ADC — v2.4.0 +// LIN, CAN (incl. CAN XL), ISELED, MDIO, Wakeup control — v2.7.0 (ids TBD there) + +// ── evt[2:0] write semantics ────────────────────────────────────────────────── +// The 8-way write-semantics selector every combinable-payload endpoint type +// reads out of AcfMessageInfo::evt_op on a write request (extraction §4.5). +// SPI does *not* use this enum — it repurposes the same 3-bit field as a +// channel selector instead (see rcp/spi.hpp) — this enum is only meaningful +// for endpoint types whose functional payload is a value to be combined with +// the endpoint's current state. + +enum class WriteSemantics : uint8_t { + Replace = 0, + Or = 1, + And = 2, + Xor = 3, + Reserved = 4, + Add = 5, + Subtract = 6, + Reconfigure = 7, +}; + +// write_semantics_of masks its argument down to the low 3 bits before +// converting, so callers may pass the raw evt[2:0] field (AcfMessageInfo's +// evt_op is already only ever 3 bits wide, but this keeps the conversion +// total regardless of caller discipline). +constexpr WriteSemantics write_semantics_of(uint8_t evt_op) noexcept { + return static_cast(evt_op & 0x07); +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class EndpointErrc : int { + // evt[2:0] == 4 (Reserved) was used in a write request; this value has + // no defined behavior and must never be silently treated as a no-op. + reserved_write_semantics = 1, + // A write-semantics value that apply_bitmask_write cannot itself carry + // out generically (currently only Reconfigure, whose target is + // endpoint-type-specific — see e.g. gpio::apply_gpio_write) was passed + // to it directly. + non_combinable_write_semantics = 2, +}; + +inline const std::error_category& endpoint_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.endpoint"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case EndpointErrc::reserved_write_semantics: + return "rcp/endpoint: evt[2:0]=4 (Reserved) is not a valid write semantics"; + case EndpointErrc::non_combinable_write_semantics: + return "rcp/endpoint: write semantics does not combine via apply_bitmask_write"; + default: + return "rcp/endpoint: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(EndpointErrc e) noexcept { + return {static_cast(e), endpoint_category()}; +} + +// ── Saturating arithmetic ───────────────────────────────────────────────────── +// The add/subtract saturation rule shared by GPIO (this milestone) and +// PWM_OUT (v2.4.0, per the roadmap's own note that PWM_OUT reuses it): +// evt[2:0] values Add/Subtract combine the request operand with the +// endpoint's current value by clamping at the representable range's edges +// rather than wrapping (extraction §4.5). Templated on the caller's unsigned +// integer width so both GPIO's 32-bit pin mask and PWM_OUT's narrower +// period/duration fields can share one implementation. + +template +constexpr UInt saturating_add(UInt a, UInt b) noexcept { + static_assert(std::is_unsigned::value, "saturating_add requires an unsigned integer type"); + const UInt sum = static_cast(a + b); + return (sum < a) ? std::numeric_limits::max() : sum; // wrapped past max -> saturate +} + +template +constexpr UInt saturating_subtract(UInt a, UInt b) noexcept { + static_assert(std::is_unsigned::value, "saturating_subtract requires an unsigned integer type"); + return (b > a) ? UInt{0} : static_cast(a - b); // would go below 0 -> saturate at 0 +} + +// ── Generic bitmask/value write combinator ──────────────────────────────────── +// apply_bitmask_write folds `operand` into `current` according to `op`, +// covering the six of the eight write semantics that are meaningful as a +// pure value combination (Replace/Or/And/Xor/Add/Subtract). Reserved is +// rejected as an error. Reconfigure is deliberately *not* handled here — its +// target (e.g. GPIO pin direction, see gpio::apply_gpio_write) is +// endpoint-type-specific, not a value to combine with `current` — callers +// that receive WriteSemantics::Reconfigure must special-case it themselves +// before reaching this function; passing it here is reported as +// non_combinable_write_semantics rather than silently doing nothing. +inline std::error_code apply_bitmask_write(WriteSemantics op, uint32_t current, uint32_t operand, + uint32_t& out) noexcept { + switch (op) { + case WriteSemantics::Replace: out = operand; return {}; + case WriteSemantics::Or: out = current | operand; return {}; + case WriteSemantics::And: out = current & operand; return {}; + case WriteSemantics::Xor: out = current ^ operand; return {}; + case WriteSemantics::Add: out = saturating_add(current, operand); return {}; + case WriteSemantics::Subtract: out = saturating_subtract(current, operand); return {}; + case WriteSemantics::Reserved: + return make_error_code(EndpointErrc::reserved_write_semantics); + case WriteSemantics::Reconfigure: + return make_error_code(EndpointErrc::non_combinable_write_semantics); + default: + return make_error_code(EndpointErrc::non_combinable_write_semantics); + } +} + +// ── Generic trigger-signal table ────────────────────────────────────────────── +// TriggerRegistry tracks which of an endpoint's trigger signals a client has +// armed and queues occurrences of those signals for delivery, without +// knowing anything about what a "signal" means for a given endpoint type — +// GPIO encodes (pin, edge) pairs into a SignalId (see gpio::gpio_signal_id), +// SPI encodes (channel, transfer-complete-or-CS-edge) pairs into the same +// space (see spi::spi_signal_id), and later endpoint types are expected to +// do the same rather than each building their own enable/pending +// bookkeeping (extraction §4.5 Group A/C). +// +// Signals are level-armed, not edge-latched: notify() on a signal that is +// not currently enabled is simply dropped (returns false) rather than queued +// for later delivery once the client re-arms it — a client must be armed +// *at the time* a signal occurs to see it, matching the request/response +// (not fragmented, not buffered indefinitely) model this milestone targets. +class TriggerRegistry { +public: + using SignalId = uint16_t; + + void enable(SignalId id) { enabled_.insert(id); } + void disable(SignalId id) { enabled_.erase(id); } + bool is_enabled(SignalId id) const { return enabled_.count(id) != 0; } + + // notify records that `id` occurred. Returns true and queues it for + // drain() iff the signal was enabled; returns false (and does not + // queue) otherwise. + bool notify(SignalId id) { + if (!is_enabled(id)) return false; + pending_.push_back(id); + return true; + } + + bool has_pending() const noexcept { return !pending_.empty(); } + + // drain returns every signal id queued since the last drain() call, in + // occurrence order, and clears the queue. + std::vector drain() { + auto out = std::move(pending_); + pending_.clear(); + return out; + } + +private: + std::set enabled_; + std::vector pending_; +}; + +} // namespace endpoint +} // namespace rcp + +// Enable std::error_code construction from rcp::endpoint::EndpointErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/gpio.hpp b/include/rcp/gpio.hpp new file mode 100644 index 0000000..1af1a44 --- /dev/null +++ b/include/rcp/gpio.hpp @@ -0,0 +1,234 @@ +// fusa:req REQ-GPIO-001 +// fusa:req REQ-GPIO-002 +// fusa:req REQ-GPIO-003 +// fusa:req REQ-GPIO-004 +// fusa:req REQ-GPIO-005 +// fusa:req REQ-GPIO-006 +// fusa:req REQ-GPIO-007 +// fusa:req REQ-GPIO-008 + +// GPIO endpoint (ep_type 0x02) — the OPEN Alliance TC18 Remote Control +// Protocol Specification v0.5.1_RC's simplest endpoint type: a 32-pin +// bitmask read/written with one of eight evt[2:0]-selected write semantics, +// plus per-pin change/rising/falling trigger signals (extraction §5.3, +// §4.5 Group C). +// +// ROADMAP.md milestone 47, "Basic Endpoint Types I — GPIO & SPI (v2.3.0)": +// GPIO is the first concrete endpoint type built on rcp/endpoint.hpp's +// shared write-semantics decode, saturating-arithmetic helpers, and +// trigger-signal table (v2.3.0), and is also the vehicle that exercises the +// add/subtract saturation rule PWM_OUT reuses at v2.4.0. It rides on +// rcp/regmap.hpp's generic/functional endpoint config split (v2.1.0) for +// its functional config block and on rcp/wire.hpp's big-endian field codec +// helpers (v2.0.0) for its 4-byte payload, without changing either header. +// +// Field names and behavior below implement TC18's *behavior* as described in +// an internal structured extraction of the specification named above; no +// text from that document is reproduced here. The concrete pin-direction bit +// convention, functional-config layout, and trigger-signal id encoding +// chosen in this file are this implementation's own, same as the equivalent +// disclaimers in rcp/wire.hpp, rcp/regmap.hpp, and rcp/endpoint.hpp. +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace rcp { +namespace gpio { + +// ── Pin mask ────────────────────────────────────────────────────────────────── +// GPIO's request/response payload is a single 4-byte bitmask, one bit per +// pin, up to 32 pins (extraction §5.3). + +using PinMask = uint32_t; +constexpr uint8_t kMaxPins = 32; +constexpr size_t kGpioPayloadLen = sizeof(PinMask); + +// ── GpioState ───────────────────────────────────────────────────────────────── +// `values` is the live per-pin logic-level bitmask every write semantics +// below (other than Reconfigure) reads and updates. `directions` is this +// implementation's own choice of bit convention for which pins are +// currently configured as outputs (1) vs. inputs (0); Reconfigure is the +// only write semantics that targets it rather than `values`. + +struct GpioState { + PinMask values = 0; + PinMask directions = 0; +}; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class GpioErrc : int { + pin_index_out_of_range = 1, // a pin index >= kMaxPins was referenced +}; + +inline const std::error_category& gpio_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.gpio"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case GpioErrc::pin_index_out_of_range: return "rcp/gpio: pin index out of range"; + default: return "rcp/gpio: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(GpioErrc e) noexcept { + return {static_cast(e), gpio_category()}; +} + +// ── Write-semantics application ────────────────────────────────────────────── +// apply_gpio_write is GPIO's endpoint-specific completion of +// endpoint::apply_bitmask_write: it handles the six generic combinators by +// delegating to it against `state.values`, and separately handles +// Reconfigure by treating `operand` as the *new pin-direction mask* +// (replacing, not combining, `state.directions`) rather than a value +// combined with the pin-level state (extraction §5.3, §4.5 Group C). +inline std::error_code apply_gpio_write(endpoint::WriteSemantics op, GpioState& state, + PinMask operand) noexcept { + if (op == endpoint::WriteSemantics::Reconfigure) { + state.directions = operand; + return {}; + } + uint32_t out = 0; + auto ec = endpoint::apply_bitmask_write(op, state.values, operand, out); + if (ec) return ec; + state.values = out; + return {}; +} + +// ── Payload codec ───────────────────────────────────────────────────────────── +// Big-endian 4-byte encoding, matching rcp/wire.hpp's own field convention +// (reusing its internal put_u32/get_u32 rather than re-deriving byte order +// here). + +inline std::vector encode_gpio_payload(PinMask mask) { + std::vector buf(kGpioPayloadLen); + wire::detail::put_u32(buf.data(), mask); + return buf; +} + +inline std::error_code decode_gpio_payload(const uint8_t* buf, size_t len, PinMask& out) noexcept { + if (len < kGpioPayloadLen) return wire::make_error_code(wire::WireErrc::short_buffer); + out = wire::detail::get_u32(buf); + return {}; +} + +// ── Trigger signals ─────────────────────────────────────────────────────────── +// Per-pin change/rising/falling signals (extraction §5.3, §4.5 Group C), +// built on rcp/endpoint.hpp's generic TriggerRegistry. gpio_signal_id packs +// (pin, edge) into one TriggerRegistry::SignalId so all three of a pin's +// signals — and every other pin's — share one registry instance per GPIO +// endpoint. + +enum class GpioEdge : uint8_t { Change = 0, Rising = 1, Falling = 2 }; + +constexpr endpoint::TriggerRegistry::SignalId gpio_signal_id(uint8_t pin, GpioEdge edge) noexcept { + return static_cast((uint16_t(pin) << 2) | uint16_t(edge)); +} + +// evaluate_gpio_triggers compares `old_values` against `new_values` bit by +// bit and, for every pin whose bit changed, notifies TriggerRegistry of that +// pin's Change signal, plus Rising or Falling depending on the direction of +// the transition. Returns every signal id notify() reported as armed (i.e. +// worth delivering to a client), in pin-ascending, Change-before-edge order. +inline std::vector +evaluate_gpio_triggers(endpoint::TriggerRegistry& triggers, PinMask old_values, PinMask new_values) { + std::vector fired; + for (uint8_t pin = 0; pin < kMaxPins; ++pin) { + const bool was_set = ((old_values >> pin) & 1u) != 0; + const bool is_set = ((new_values >> pin) & 1u) != 0; + if (was_set == is_set) continue; + + if (triggers.notify(gpio_signal_id(pin, GpioEdge::Change))) + fired.push_back(gpio_signal_id(pin, GpioEdge::Change)); + + const GpioEdge edge = is_set ? GpioEdge::Rising : GpioEdge::Falling; + if (triggers.notify(gpio_signal_id(pin, edge))) + fired.push_back(gpio_signal_id(pin, edge)); + } + return fired; +} + +// ── Functional config block wiring ──────────────────────────────────────────── +// Interprets regmap::EndpointFunctionalConfig::data (left as an opaque byte +// blob by v2.1.0's generic/functional split) for a GPIO endpoint: the +// pin-direction mask followed by one enabled-edge bitmask byte per pin +// (bits 0-2 corresponding to GpioEdge::Change/Rising/Falling). This is the +// pattern later endpoint types are expected to follow for their own +// functional config content (extraction §6 item 5). + +constexpr size_t kGpioFunctionalConfigLen = kGpioPayloadLen + kMaxPins; + +inline regmap::EndpointFunctionalConfig +encode_gpio_functional_config(PinMask directions, const std::array& enabled_edge_masks) { + regmap::EndpointFunctionalConfig cfg; + cfg.data.resize(kGpioFunctionalConfigLen); + wire::detail::put_u32(cfg.data.data(), directions); + std::copy(enabled_edge_masks.begin(), enabled_edge_masks.end(), + cfg.data.begin() + static_cast(kGpioPayloadLen)); + return cfg; +} + +inline std::error_code decode_gpio_functional_config(const regmap::EndpointFunctionalConfig& cfg, + PinMask& out_directions, + std::array& out_enabled_edge_masks) noexcept { + if (cfg.data.size() < kGpioFunctionalConfigLen) + return wire::make_error_code(wire::WireErrc::short_buffer); + out_directions = wire::detail::get_u32(cfg.data.data()); + std::copy(cfg.data.begin() + static_cast(kGpioPayloadLen), + cfg.data.begin() + static_cast(kGpioFunctionalConfigLen), + out_enabled_edge_masks.begin()); + return {}; +} + +// ── GpioEndpoint ────────────────────────────────────────────────────────────── +// Ties GpioState, apply_gpio_write, and the trigger-signal evaluation above +// into the single request-dispatch entry point a caller (e.g. an RC Server's +// request loop) would invoke per incoming GPIO write — the reusable +// endpoint-registration/dispatch object this milestone establishes the shape +// of for SPI (rcp::spi::SpiEndpoint) and later endpoint types to follow. +class GpioEndpoint { +public: + // handle_write applies one decoded write request (`op` from + // endpoint::write_semantics_of(evt_op), `operand` from the request's + // decoded 4-byte payload) to this endpoint's live state, evaluates the + // resulting transition against the trigger-signal table, and reports + // the endpoint's new pin-value bitmask for the caller to encode into a + // write response. + std::error_code handle_write(endpoint::WriteSemantics op, PinMask operand, PinMask& out_value) noexcept { + const PinMask before = state_.values; + auto ec = apply_gpio_write(op, state_, operand); + if (ec) return ec; + evaluate_gpio_triggers(triggers_, before, state_.values); + out_value = state_.values; + return {}; + } + + PinMask read() const noexcept { return state_.values; } + const GpioState& state() const noexcept { return state_; } + endpoint::TriggerRegistry& triggers() noexcept { return triggers_; } + +private: + GpioState state_; + endpoint::TriggerRegistry triggers_; +}; + +} // namespace gpio +} // namespace rcp + +// Enable std::error_code construction from rcp::gpio::GpioErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/spi.hpp b/include/rcp/spi.hpp new file mode 100644 index 0000000..a826d28 --- /dev/null +++ b/include/rcp/spi.hpp @@ -0,0 +1,161 @@ +// fusa:req REQ-SPI-001 +// fusa:req REQ-SPI-002 +// fusa:req REQ-SPI-003 +// fusa:req REQ-SPI-004 +// fusa:req REQ-SPI-005 + +// SPI endpoint (ep_type 0x03) — up to 6 pre-configured channels selected by +// evt[2:0], raw full-duplex PICO-out/POCI-in byte transfer, the +// compound-wait status-byte truncation rule, and transfer-complete/per-CS +// assert/de-assert trigger signals (extraction §5.4, §4.5 Group A). +// +// ROADMAP.md milestone 47, "Basic Endpoint Types I — GPIO & SPI (v2.3.0)": +// SPI is the second endpoint type built on rcp/endpoint.hpp's shared +// trigger-signal table, following the request-dispatch pattern +// rcp/gpio.hpp establishes (SpiEndpoint mirrors GpioEndpoint's shape). +// Unlike GPIO, SPI does *not* use endpoint::WriteSemantics at all — it +// repurposes the same evt[2:0] field as a 0-5 channel selector instead +// (extraction §5.4). The compound-wait truncation rule implemented here +// (compound_wait_matches) is forward-looking scaffolding: the surrounding +// compound-wait request kind itself (sequencer gating, the `cs` field's +// conditional-start meaning) is v2.5.0 scope — this milestone only +// implements the byte-comparison rule the roadmap calls out as belonging +// here. +// +// Field names and behavior below implement TC18's *behavior* as described in +// an internal structured extraction of the specification named above; no +// text from that document is reproduced here. The concrete channel-config +// encoding and trigger-signal id scheme chosen in this file are this +// implementation's own, same as the equivalent disclaimers in +// rcp/wire.hpp, rcp/regmap.hpp, rcp/endpoint.hpp, and rcp/gpio.hpp. +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace rcp { +namespace spi { + +// ── Channels ────────────────────────────────────────────────────────────────── +// SPI exposes up to 6 pre-configured channels, selected by the request's +// evt[2:0] field taking the value of the channel index directly — 0 through +// 5 are valid channels; 6 and 7 have no defined meaning (extraction §5.4). + +constexpr uint8_t kMaxChannels = 6; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class SpiErrc : int { + channel_out_of_range = 1, // evt[2:0] selected a channel >= kMaxChannels +}; + +inline const std::error_category& spi_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.spi"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case SpiErrc::channel_out_of_range: return "rcp/spi: channel index out of range (evt[2:0] not in 0..5)"; + default: return "rcp/spi: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(SpiErrc e) noexcept { + return {static_cast(e), spi_category()}; +} + +// channel_of decodes a request's evt[2:0] field as an SPI channel selector. +// This is SPI's own decode, distinct from endpoint::write_semantics_of — +// the same 3-bit wire field means something different for SPI than for +// GPIO/PWM_OUT (extraction §5.4). +inline std::error_code channel_of(uint8_t evt_op, uint8_t& out_channel) noexcept { + const uint8_t channel = static_cast(evt_op & 0x07); + if (channel >= kMaxChannels) return make_error_code(SpiErrc::channel_out_of_range); + out_channel = channel; + return {}; +} + +// ── Compound-wait status-byte truncation rule ──────────────────────────────── +// A compound-wait request's condition is evaluated only against the first +// kCompoundWaitCompareLen bytes of an SPI status transfer, regardless of how +// many of the up to kMaxStatusBytes status bytes that transfer actually +// carried (extraction §5.4). compound_wait_matches implements exactly that +// truncated comparison; it takes no position on the rest of compound-wait's +// semantics (sequencer state, sc-field meaning), which are v2.5.0 scope. + +constexpr size_t kMaxStatusBytes = 20; +constexpr size_t kCompoundWaitCompareLen = 4; + +inline bool compound_wait_matches(const std::vector& status, + const std::vector& expected) noexcept { + if (status.size() > kMaxStatusBytes) return false; // exceeds this endpoint's own status-byte ceiling + const size_t n = std::min({status.size(), expected.size(), kCompoundWaitCompareLen}); + if (n == 0) return false; // nothing in the truncated window to compare + return std::equal(status.begin(), status.begin() + static_cast(n), expected.begin()); +} + +// ── Trigger signals ─────────────────────────────────────────────────────────── +// Transfer-complete and per-CS assert/de-assert signals, one instance of +// each per channel, built on rcp/endpoint.hpp's generic TriggerRegistry — +// the same primitive rcp/gpio.hpp's per-pin signals use (extraction §5.4, +// §4.5 Group A). + +enum class SpiSignal : uint8_t { TransferComplete = 0, CsAssert = 1, CsDeassert = 2 }; + +constexpr endpoint::TriggerRegistry::SignalId spi_signal_id(uint8_t channel, SpiSignal sig) noexcept { + return static_cast((uint16_t(channel) << 2) | uint16_t(sig)); +} + +// ── SpiEndpoint ─────────────────────────────────────────────────────────────── +// Mirrors rcp::gpio::GpioEndpoint's shape: one request-dispatch entry point +// per incoming SPI transfer, recording the exchanged bytes and firing the +// channel's trigger signals in the order a client would observe them. +class SpiEndpoint { +public: + // transfer performs one full-duplex byte exchange on `channel`: CS is + // asserted, `pico_out` is the bytes sent out, `poci_in` is this + // implementation's record of the bytes received over the same exchange + // (supplied by the caller — this header models the request/response and + // trigger-signal shape of an SPI transfer, not an actual bus), and CS is + // de-asserted again. Fragmentation is deferred (v2.8.0), so this models + // one transfer per request rather than a CS line held across several. + std::error_code transfer(uint8_t channel, std::vector pico_out, + std::vector poci_in) { + if (channel >= kMaxChannels) return make_error_code(SpiErrc::channel_out_of_range); + + triggers_.notify(spi_signal_id(channel, SpiSignal::CsAssert)); + last_pico_out_[channel] = std::move(pico_out); + last_poci_in_[channel] = std::move(poci_in); + triggers_.notify(spi_signal_id(channel, SpiSignal::TransferComplete)); + triggers_.notify(spi_signal_id(channel, SpiSignal::CsDeassert)); + return {}; + } + + const std::vector& last_sent(uint8_t channel) const { return last_pico_out_.at(channel); } + const std::vector& last_received(uint8_t channel) const { return last_poci_in_.at(channel); } + endpoint::TriggerRegistry& triggers() noexcept { return triggers_; } + +private: + endpoint::TriggerRegistry triggers_; + std::array, kMaxChannels> last_pico_out_; + std::array, kMaxChannels> last_poci_in_; +}; + +} // namespace spi +} // namespace rcp + +// Enable std::error_code construction from rcp::spi::SpiErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index a185b80..88eea50 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.2.0"; +constexpr std::string_view kVersion = "2.3.0"; } // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5e388c1..1e0ae85 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -26,6 +26,18 @@ add_executable(test_discovery test_discovery.cpp) target_link_libraries(test_discovery PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_discovery COMMAND test_discovery) +add_executable(test_endpoint test_endpoint.cpp) +target_link_libraries(test_endpoint PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_endpoint COMMAND test_endpoint) + +add_executable(test_gpio test_gpio.cpp) +target_link_libraries(test_gpio PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_gpio COMMAND test_gpio) + +add_executable(test_spi test_spi.cpp) +target_link_libraries(test_spi PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_spi COMMAND test_spi) + add_executable(test_e2e test_e2e.cpp) target_link_libraries(test_e2e PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_e2e COMMAND test_e2e) diff --git a/tests/test_endpoint.cpp b/tests/test_endpoint.cpp new file mode 100644 index 0000000..cae7fad --- /dev/null +++ b/tests/test_endpoint.cpp @@ -0,0 +1,147 @@ +// fusa:test REQ-ENDPOINT-001 +// fusa:test REQ-ENDPOINT-002 +// fusa:test REQ-ENDPOINT-003 +// fusa:test REQ-ENDPOINT-004 +// fusa:test REQ-ENDPOINT-005 +// fusa:test REQ-ENDPOINT-006 + +// Tests for rcp/endpoint.hpp — the shared endpoint-registration and +// request-dispatch scaffolding (ROADMAP.md milestone 47, "Basic Endpoint +// Types I — GPIO & SPI", v2.3.0). + +#include +#include + +#include + +using namespace rcp::endpoint; + +// ── ep_type ids ─────────────────────────────────────────────────────────────── + +TEST_CASE("GPIO and SPI ep_type ids match extraction §5.3/§5.4", "[endpoint][REQ-ENDPOINT-001]") { + REQUIRE(kEndpointTypeGpio == 0x02); + REQUIRE(kEndpointTypeSpi == 0x03); +} + +// ── evt[2:0] write-semantics decode ────────────────────────────────────────── + +TEST_CASE("write_semantics_of decodes all 8 evt[2:0] values", "[endpoint][REQ-ENDPOINT-002]") { + REQUIRE(write_semantics_of(0) == WriteSemantics::Replace); + REQUIRE(write_semantics_of(1) == WriteSemantics::Or); + REQUIRE(write_semantics_of(2) == WriteSemantics::And); + REQUIRE(write_semantics_of(3) == WriteSemantics::Xor); + REQUIRE(write_semantics_of(4) == WriteSemantics::Reserved); + REQUIRE(write_semantics_of(5) == WriteSemantics::Add); + REQUIRE(write_semantics_of(6) == WriteSemantics::Subtract); + REQUIRE(write_semantics_of(7) == WriteSemantics::Reconfigure); +} + +TEST_CASE("write_semantics_of masks its input down to 3 bits", "[endpoint][REQ-ENDPOINT-002]") { + REQUIRE(write_semantics_of(0xF8) == WriteSemantics::Replace); // low 3 bits are 000 + REQUIRE(write_semantics_of(0xF9) == WriteSemantics::Or); // low 3 bits are 001 +} + +// ── Generic bitmask/value write combinator ─────────────────────────────────── + +TEST_CASE("apply_bitmask_write implements Replace/Or/And/Xor", "[endpoint][REQ-ENDPOINT-003]") { + uint32_t out = 0; + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::Replace, 0xAAAAAAAA, 0x11111111, out)); + REQUIRE(out == 0x11111111); + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::Or, 0x0F0F0F0F, 0xF0F0F0F0, out)); + REQUIRE(out == 0xFFFFFFFF); + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::And, 0xFFFF0000, 0x00FFFF00, out)); + REQUIRE(out == 0x00FF0000); + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::Xor, 0xFFFFFFFF, 0x0000FFFF, out)); + REQUIRE(out == 0xFFFF0000); +} + +TEST_CASE("apply_bitmask_write implements saturating Add/Subtract", "[endpoint][REQ-ENDPOINT-004]") { + uint32_t out = 0; + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::Add, 10, 5, out)); + REQUIRE(out == 15); + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::Add, std::numeric_limits::max(), 100, out)); + REQUIRE(out == std::numeric_limits::max()); // saturates, does not wrap + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::Subtract, 10, 4, out)); + REQUIRE(out == 6); + + REQUIRE_FALSE(apply_bitmask_write(WriteSemantics::Subtract, 4, 10, out)); + REQUIRE(out == 0); // saturates at 0, does not wrap +} + +TEST_CASE("apply_bitmask_write rejects Reserved", "[endpoint][REQ-ENDPOINT-005]") { + uint32_t out = 0; + auto ec = apply_bitmask_write(WriteSemantics::Reserved, 0, 0, out); + REQUIRE(ec == make_error_code(EndpointErrc::reserved_write_semantics)); +} + +TEST_CASE("apply_bitmask_write refuses to combine Reconfigure generically", "[endpoint][REQ-ENDPOINT-005]") { + uint32_t out = 0; + auto ec = apply_bitmask_write(WriteSemantics::Reconfigure, 0, 0, out); + REQUIRE(ec == make_error_code(EndpointErrc::non_combinable_write_semantics)); +} + +// ── Saturating arithmetic helpers, directly ────────────────────────────────── + +TEST_CASE("saturating_add/saturating_subtract clamp instead of wrapping", "[endpoint][REQ-ENDPOINT-004]") { + REQUIRE(saturating_add(250, 10) == 255); + REQUIRE(saturating_add(10, 20) == 30); + REQUIRE(saturating_subtract(5, 10) == 0); + REQUIRE(saturating_subtract(10, 5) == 5); +} + +// ── Generic trigger-signal table ────────────────────────────────────────────── + +TEST_CASE("TriggerRegistry only queues notify() for enabled signals", "[endpoint][REQ-ENDPOINT-006]") { + TriggerRegistry reg; + REQUIRE_FALSE(reg.notify(1)); // not enabled yet + REQUIRE_FALSE(reg.has_pending()); + + reg.enable(1); + REQUIRE(reg.is_enabled(1)); + REQUIRE(reg.notify(1)); + REQUIRE(reg.has_pending()); + + auto drained = reg.drain(); + REQUIRE(drained == std::vector{1}); + REQUIRE_FALSE(reg.has_pending()); +} + +TEST_CASE("TriggerRegistry::disable stops further notifications from queuing", "[endpoint][REQ-ENDPOINT-006]") { + TriggerRegistry reg; + reg.enable(2); + REQUIRE(reg.notify(2)); + reg.drain(); + + reg.disable(2); + REQUIRE_FALSE(reg.is_enabled(2)); + REQUIRE_FALSE(reg.notify(2)); + REQUIRE_FALSE(reg.has_pending()); +} + +TEST_CASE("TriggerRegistry preserves notification order across distinct signal ids", + "[endpoint][REQ-ENDPOINT-006]") { + TriggerRegistry reg; + reg.enable(5); + reg.enable(9); + reg.notify(9); + reg.notify(5); + reg.notify(9); + + auto drained = reg.drain(); + REQUIRE(drained == std::vector{9, 5, 9}); +} + +// ── EndpointErrc category sanity ───────────────────────────────────────────── + +TEST_CASE("EndpointErrc reports a non-empty message in its own category", "[endpoint][REQ-ENDPOINT-005]") { + auto ec = make_error_code(EndpointErrc::reserved_write_semantics); + REQUIRE(ec.category() == endpoint_category()); + REQUIRE_FALSE(ec.message().empty()); +} diff --git a/tests/test_gpio.cpp b/tests/test_gpio.cpp new file mode 100644 index 0000000..15d90f0 --- /dev/null +++ b/tests/test_gpio.cpp @@ -0,0 +1,203 @@ +// fusa:test REQ-GPIO-001 +// fusa:test REQ-GPIO-002 +// fusa:test REQ-GPIO-003 +// fusa:test REQ-GPIO-004 +// fusa:test REQ-GPIO-005 +// fusa:test REQ-GPIO-006 +// fusa:test REQ-GPIO-007 +// fusa:test REQ-GPIO-008 + +// Tests for rcp/gpio.hpp — the GPIO endpoint type (ROADMAP.md milestone 47, +// "Basic Endpoint Types I — GPIO & SPI", v2.3.0). + +#include +#include + +using namespace rcp::gpio; +using rcp::endpoint::WriteSemantics; + +// ── Payload shape ───────────────────────────────────────────────────────────── + +TEST_CASE("GPIO payload is a 4-byte bitmask", "[gpio][REQ-GPIO-001]") { + REQUIRE(kGpioPayloadLen == 4); + REQUIRE(kMaxPins == 32); +} + +TEST_CASE("encode_gpio_payload / decode_gpio_payload round-trip big-endian", "[gpio][REQ-GPIO-001]") { + auto buf = encode_gpio_payload(0x01020304); + REQUIRE(buf == std::vector{0x01, 0x02, 0x03, 0x04}); + + PinMask out = 0; + auto ec = decode_gpio_payload(buf.data(), buf.size(), out); + REQUIRE_FALSE(ec); + REQUIRE(out == 0x01020304); +} + +TEST_CASE("decode_gpio_payload rejects a short buffer", "[gpio][REQ-GPIO-001]") { + std::vector short_buf{0x01, 0x02}; + PinMask out = 0; + auto ec = decode_gpio_payload(short_buf.data(), short_buf.size(), out); + REQUIRE(ec == rcp::wire::make_error_code(rcp::wire::WireErrc::short_buffer)); +} + +// ── Write semantics: the 6 generic combinators ─────────────────────────────── + +TEST_CASE("apply_gpio_write applies Replace/Or/And/Xor to state.values", "[gpio][REQ-GPIO-002]") { + GpioState state; + state.values = 0x0000FFFF; + + REQUIRE_FALSE(apply_gpio_write(WriteSemantics::Or, state, 0xFFFF0000)); + REQUIRE(state.values == 0xFFFFFFFF); + + REQUIRE_FALSE(apply_gpio_write(WriteSemantics::And, state, 0x0F0F0F0F)); + REQUIRE(state.values == 0x0F0F0F0F); + + REQUIRE_FALSE(apply_gpio_write(WriteSemantics::Xor, state, 0xFFFFFFFF)); + REQUIRE(state.values == 0xF0F0F0F0); + + REQUIRE_FALSE(apply_gpio_write(WriteSemantics::Replace, state, 0x00000001)); + REQUIRE(state.values == 0x00000001); +} + +// ── Write semantics: saturating Add/Subtract shared with PWM_OUT ──────────── + +TEST_CASE("apply_gpio_write applies saturating Add/Subtract to state.values", "[gpio][REQ-GPIO-003]") { + GpioState state; + state.values = 0xFFFFFFF0; + + REQUIRE_FALSE(apply_gpio_write(WriteSemantics::Add, state, 0x20)); + REQUIRE(state.values == 0xFFFFFFFF); // saturates, does not wrap past max + + state.values = 5; + REQUIRE_FALSE(apply_gpio_write(WriteSemantics::Subtract, state, 10)); + REQUIRE(state.values == 0); // saturates at 0, does not wrap +} + +// ── Write semantics: Reserved rejected, Reconfigure targets directions ────── + +TEST_CASE("apply_gpio_write rejects Reserved and leaves state untouched", "[gpio][REQ-GPIO-004]") { + GpioState state; + state.values = 0x12345678; + auto ec = apply_gpio_write(WriteSemantics::Reserved, state, 0xFFFFFFFF); + REQUIRE(ec); + REQUIRE(state.values == 0x12345678); +} + +TEST_CASE("apply_gpio_write's Reconfigure replaces state.directions, not state.values", + "[gpio][REQ-GPIO-004]") { + GpioState state; + state.values = 0xAAAAAAAA; + state.directions = 0; + + REQUIRE_FALSE(apply_gpio_write(WriteSemantics::Reconfigure, state, 0x0000FFFF)); + REQUIRE(state.directions == 0x0000FFFF); + REQUIRE(state.values == 0xAAAAAAAA); // unaffected by a Reconfigure write +} + +// ── Per-pin change/rising/falling trigger signals ──────────────────────────── + +TEST_CASE("evaluate_gpio_triggers fires Change+Rising for a 0->1 transition when both are armed", + "[gpio][REQ-GPIO-005]") { + rcp::endpoint::TriggerRegistry triggers; + triggers.enable(gpio_signal_id(3, GpioEdge::Change)); + triggers.enable(gpio_signal_id(3, GpioEdge::Rising)); + triggers.enable(gpio_signal_id(3, GpioEdge::Falling)); + + evaluate_gpio_triggers(triggers, /*old=*/0, /*new=*/(1u << 3)); + + auto drained = triggers.drain(); + REQUIRE(drained.size() == 2); + REQUIRE(drained[0] == gpio_signal_id(3, GpioEdge::Change)); + REQUIRE(drained[1] == gpio_signal_id(3, GpioEdge::Rising)); +} + +TEST_CASE("evaluate_gpio_triggers fires Change+Falling for a 1->0 transition", "[gpio][REQ-GPIO-005]") { + rcp::endpoint::TriggerRegistry triggers; + triggers.enable(gpio_signal_id(7, GpioEdge::Change)); + triggers.enable(gpio_signal_id(7, GpioEdge::Falling)); + + evaluate_gpio_triggers(triggers, /*old=*/(1u << 7), /*new=*/0); + + auto drained = triggers.drain(); + REQUIRE(drained.size() == 2); + REQUIRE(drained[0] == gpio_signal_id(7, GpioEdge::Change)); + REQUIRE(drained[1] == gpio_signal_id(7, GpioEdge::Falling)); +} + +TEST_CASE("evaluate_gpio_triggers fires nothing for a pin whose bit is unchanged", "[gpio][REQ-GPIO-005]") { + rcp::endpoint::TriggerRegistry triggers; + triggers.enable(gpio_signal_id(0, GpioEdge::Change)); + + evaluate_gpio_triggers(triggers, /*old=*/0x1, /*new=*/0x1); + REQUIRE_FALSE(triggers.has_pending()); +} + +TEST_CASE("evaluate_gpio_triggers only reports signals that were actually armed", "[gpio][REQ-GPIO-005]") { + rcp::endpoint::TriggerRegistry triggers; // nothing enabled + auto fired = evaluate_gpio_triggers(triggers, /*old=*/0, /*new=*/1); + REQUIRE(fired.empty()); +} + +// ── Functional config block wiring ──────────────────────────────────────────── + +TEST_CASE("encode/decode_gpio_functional_config round-trips through the opaque regmap blob", + "[gpio][REQ-GPIO-006]") { + std::array edges{}; + edges[0] = 0b001; // Change + edges[5] = 0b110; // Rising + Falling + + auto cfg = encode_gpio_functional_config(0xF0F0F0F0, edges); + REQUIRE(cfg.data.size() == kGpioFunctionalConfigLen); + + PinMask out_directions = 0; + std::array out_edges{}; + auto ec = decode_gpio_functional_config(cfg, out_directions, out_edges); + REQUIRE_FALSE(ec); + REQUIRE(out_directions == 0xF0F0F0F0); + REQUIRE(out_edges == edges); +} + +TEST_CASE("decode_gpio_functional_config rejects an undersized blob", "[gpio][REQ-GPIO-006]") { + rcp::regmap::EndpointFunctionalConfig cfg; + cfg.data = {0x01, 0x02}; // far short of kGpioFunctionalConfigLen + + PinMask out_directions = 0; + std::array out_edges{}; + auto ec = decode_gpio_functional_config(cfg, out_directions, out_edges); + REQUIRE(ec == rcp::wire::make_error_code(rcp::wire::WireErrc::short_buffer)); +} + +// ── GpioEndpoint request-dispatch pattern ──────────────────────────────────── + +TEST_CASE("GpioEndpoint::handle_write updates state and fires triggers in one call", + "[gpio][REQ-GPIO-007]") { + GpioEndpoint ep; + ep.triggers().enable(gpio_signal_id(0, GpioEdge::Change)); + ep.triggers().enable(gpio_signal_id(0, GpioEdge::Rising)); + + PinMask out_value = 0; + auto ec = ep.handle_write(WriteSemantics::Or, /*operand=*/0x1, out_value); + REQUIRE_FALSE(ec); + REQUIRE(out_value == 0x1); + REQUIRE(ep.read() == 0x1); + + auto fired = ep.triggers().drain(); + REQUIRE(fired.size() == 2); +} + +TEST_CASE("GpioEndpoint::handle_write propagates a Reserved-semantics error without changing state", + "[gpio][REQ-GPIO-008]") { + GpioEndpoint ep; + PinMask out_value = 0; + auto ec = ep.handle_write(WriteSemantics::Reserved, 0xFF, out_value); + REQUIRE(ec); + REQUIRE(ep.read() == 0); +} + +// ── GpioErrc category sanity ────────────────────────────────────────────────── + +TEST_CASE("GpioErrc reports a non-empty message in its own category", "[gpio][REQ-GPIO-008]") { + auto ec = make_error_code(GpioErrc::pin_index_out_of_range); + REQUIRE(ec.category() == gpio_category()); + REQUIRE_FALSE(ec.message().empty()); +} diff --git a/tests/test_spi.cpp b/tests/test_spi.cpp new file mode 100644 index 0000000..9dae928 --- /dev/null +++ b/tests/test_spi.cpp @@ -0,0 +1,148 @@ +// fusa:test REQ-SPI-001 +// fusa:test REQ-SPI-002 +// fusa:test REQ-SPI-003 +// fusa:test REQ-SPI-004 +// fusa:test REQ-SPI-005 + +// Tests for rcp/spi.hpp — the SPI endpoint type (ROADMAP.md milestone 47, +// "Basic Endpoint Types I — GPIO & SPI", v2.3.0). + +#include +#include + +using namespace rcp::spi; + +// ── Channel selection via evt[2:0] ─────────────────────────────────────────── + +TEST_CASE("kMaxChannels is 6", "[spi][REQ-SPI-001]") { + REQUIRE(kMaxChannels == 6); +} + +TEST_CASE("channel_of accepts evt[2:0] values 0..5", "[spi][REQ-SPI-001]") { + for (uint8_t v = 0; v < kMaxChannels; ++v) { + uint8_t out = 0xFF; + auto ec = channel_of(v, out); + REQUIRE_FALSE(ec); + REQUIRE(out == v); + } +} + +TEST_CASE("channel_of rejects evt[2:0] values 6 and 7", "[spi][REQ-SPI-001]") { + uint8_t out = 0xFF; + REQUIRE(channel_of(6, out) == make_error_code(SpiErrc::channel_out_of_range)); + REQUIRE(channel_of(7, out) == make_error_code(SpiErrc::channel_out_of_range)); +} + +TEST_CASE("channel_of masks its input down to 3 bits before range-checking", + "[spi][REQ-SPI-001]") { + uint8_t out = 0xFF; + auto ec = channel_of(0xF9, out); // low 3 bits = 1 + REQUIRE_FALSE(ec); + REQUIRE(out == 1); +} + +// ── Compound-wait status-byte truncation rule ──────────────────────────────── + +TEST_CASE("compound_wait_matches compares only the first 4 bytes", "[spi][REQ-SPI-002]") { + std::vector status{0x01, 0x02, 0x03, 0x04, 0xFF, 0xFF, 0xFF}; + std::vector expected{0x01, 0x02, 0x03, 0x04}; + REQUIRE(compound_wait_matches(status, expected)); +} + +TEST_CASE("compound_wait_matches ignores a mismatch beyond byte 4", "[spi][REQ-SPI-002]") { + std::vector status(20, 0x00); + status[0] = 0x01; + status[1] = 0x02; + status[2] = 0x03; + status[3] = 0x04; + status[19] = 0xAA; // differs from expected, but past the truncation window + + std::vector expected{0x01, 0x02, 0x03, 0x04, 0x00 /* ... */}; + REQUIRE(compound_wait_matches(status, expected)); +} + +TEST_CASE("compound_wait_matches reports a mismatch within the first 4 bytes", "[spi][REQ-SPI-002]") { + std::vector status{0x01, 0x02, 0x03, 0x04}; + std::vector expected{0x01, 0x02, 0x03, 0x05}; + REQUIRE_FALSE(compound_wait_matches(status, expected)); +} + +TEST_CASE("compound_wait_matches rejects a status transfer beyond kMaxStatusBytes", + "[spi][REQ-SPI-002]") { + std::vector status(kMaxStatusBytes + 1, 0x00); + std::vector expected(kMaxStatusBytes + 1, 0x00); + REQUIRE_FALSE(compound_wait_matches(status, expected)); +} + +TEST_CASE("compound_wait_matches returns false on empty input", "[spi][REQ-SPI-002]") { + REQUIRE_FALSE(compound_wait_matches({}, {})); + REQUIRE_FALSE(compound_wait_matches({0x01}, {})); +} + +// ── Raw PICO-out/POCI-in transfer ──────────────────────────────────────────── + +TEST_CASE("SpiEndpoint::transfer records sent and received bytes per channel", + "[spi][REQ-SPI-003]") { + SpiEndpoint ep; + auto ec = ep.transfer(/*channel=*/2, {0xDE, 0xAD}, {0xBE, 0xEF}); + REQUIRE_FALSE(ec); + REQUIRE(ep.last_sent(2) == std::vector{0xDE, 0xAD}); + REQUIRE(ep.last_received(2) == std::vector{0xBE, 0xEF}); +} + +TEST_CASE("SpiEndpoint::transfer rejects a channel >= kMaxChannels", "[spi][REQ-SPI-003]") { + SpiEndpoint ep; + auto ec = ep.transfer(/*channel=*/6, {0x01}, {0x02}); + REQUIRE(ec == make_error_code(SpiErrc::channel_out_of_range)); +} + +TEST_CASE("SpiEndpoint tracks each channel's last transfer independently", "[spi][REQ-SPI-003]") { + SpiEndpoint ep; + REQUIRE_FALSE(ep.transfer(0, {0x01}, {0x11})); + REQUIRE_FALSE(ep.transfer(1, {0x02}, {0x22})); + REQUIRE(ep.last_received(0) == std::vector{0x11}); + REQUIRE(ep.last_received(1) == std::vector{0x22}); +} + +// ── Transfer-complete and per-CS assert/de-assert trigger signals ─────────── + +TEST_CASE("SpiEndpoint::transfer fires CsAssert, TransferComplete, CsDeassert in order when armed", + "[spi][REQ-SPI-004]") { + SpiEndpoint ep; + ep.triggers().enable(spi_signal_id(3, SpiSignal::CsAssert)); + ep.triggers().enable(spi_signal_id(3, SpiSignal::TransferComplete)); + ep.triggers().enable(spi_signal_id(3, SpiSignal::CsDeassert)); + + REQUIRE_FALSE(ep.transfer(3, {0x01}, {0x02})); + + auto drained = ep.triggers().drain(); + REQUIRE(drained.size() == 3); + REQUIRE(drained[0] == spi_signal_id(3, SpiSignal::CsAssert)); + REQUIRE(drained[1] == spi_signal_id(3, SpiSignal::TransferComplete)); + REQUIRE(drained[2] == spi_signal_id(3, SpiSignal::CsDeassert)); +} + +TEST_CASE("SpiEndpoint's trigger signals are scoped per channel", "[spi][REQ-SPI-004]") { + SpiEndpoint ep; + ep.triggers().enable(spi_signal_id(0, SpiSignal::TransferComplete)); + // Channel 1's TransferComplete signal is deliberately left disabled. + + REQUIRE_FALSE(ep.transfer(1, {0xAA}, {0xBB})); + REQUIRE_FALSE(ep.triggers().has_pending()); + + REQUIRE_FALSE(ep.transfer(0, {0xAA}, {0xBB})); + REQUIRE(ep.triggers().has_pending()); +} + +TEST_CASE("spi_signal_id gives every (channel, signal) pair a distinct id", "[spi][REQ-SPI-004]") { + REQUIRE(spi_signal_id(0, SpiSignal::TransferComplete) != spi_signal_id(0, SpiSignal::CsAssert)); + REQUIRE(spi_signal_id(0, SpiSignal::TransferComplete) != spi_signal_id(1, SpiSignal::TransferComplete)); +} + +// ── SpiErrc category sanity ─────────────────────────────────────────────────── + +TEST_CASE("SpiErrc reports a non-empty message in its own category", "[spi][REQ-SPI-005]") { + auto ec = make_error_code(SpiErrc::channel_out_of_range); + REQUIRE(ec.category() == spi_category()); + REQUIRE_FALSE(ec.message().empty()); +}