diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 67bc3e3..0905508 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -3151,6 +3151,206 @@ "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" + }, + { + "id": "REQ-I2C-001", + "title": "i2c_mode open item \u2014 coarse high-speed decode only", + "text": "rcp::i2c::i2c_mode_of shall map AcfMessageInfo::hs=false to I2cMode::Standard and hs=true to I2cMode::HighSpeed, and shall not attempt to distinguish any finer I2C speed grade, per this milestone's documented open item pending a future spec errata pass.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-I2C-002", + "title": "I2C compound-wait arbitrary-bit-sequence match", + "text": "rcp::i2c::compound_wait_matches_bits shall compare exactly the leading `bit_len` bits of `received` against `expected` (MSB-first within a byte, comparing only the top `bit_len % 8` bits of a partial final byte), shall return false when either buffer is shorter than `bit_len` requires, and shall return false for `bit_len == 0`.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-I2C-003", + "title": "I2C controller-only raw byte-stream transfer", + "text": "rcp::i2c::I2cEndpoint::transfer shall record the exact byte sequences passed as `out_bytes` and `in_bytes` \u2014 including any address byte(s) the caller embeds in `out_bytes` \u2014 retrievable via last_sent()/last_received().", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-I2C-004", + "title": "I2C TransferComplete and Nack trigger signals", + "text": "rcp::i2c::I2cEndpoint::transfer shall notify TransferComplete on every transfer, shall additionally notify Nack and return I2cErrc::nack when called with acked=false, and shall notify only TransferComplete for an acknowledged transfer.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-I2C-005", + "title": "I2cErrc is a distinct error category with a non-empty message", + "text": "rcp::i2c::make_error_code(I2cErrc::nack) shall report rcp::i2c::i2c_category() as its category and shall have a non-empty diagnostic message.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UART-001", + "title": "UART TX queue is independent of the RX FIFO", + "text": "rcp::uart::UartEndpoint::enqueue_tx shall append to a TX queue independent of the RX FIFO's contents, drain_tx shall return and clear exactly what was queued, and enqueue_tx shall return UartErrc::tx_queue_overflow rather than partially enqueueing when the push would exceed kTxQueueCapacity.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UART-002", + "title": "UART RX FIFO fill/drain semantics", + "text": "rcp::uart::UartEndpoint::rx_fill shall append bytes to the RX FIFO, rx_available shall report its current occupancy, and rx_fill shall return UartErrc::rx_fifo_overflow rather than partially filling when the push would exceed kRxFifoCapacity.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UART-003", + "title": "UART read completion: read_size reached or uart_timeout elapsed", + "text": "rcp::uart::UartEndpoint::handle_read shall drain up to `read_size` bytes currently available in the RX FIFO, shall report out_timed_out=true only when read_size was not fully reached and elapsed_ms >= uart_timeout_ms, and shall return UartErrc::read_size_exceeds_bound for a read_size greater than kMaxReadSize without modifying the RX FIFO.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UART-004", + "title": "UART payload-less pure read requests", + "text": "rcp::uart::UartEndpoint::handle_pure_read shall drain and return every byte currently buffered in the RX FIFO without waiting on read_size or uart_timeout, and shall return an empty result when the RX FIFO is empty.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UART-005", + "title": "UART sub-octet bit-width padding rules", + "text": "rcp::uart::pack_frame_to_octet shall mask a frame's value to its low bits_per_frame bits (zero-padding the remaining high bits of the octet), rcp::uart::unpack_frame_bits shall invert that masking, and both shall return UartErrc::bits_per_frame_out_of_range for bits_per_frame outside [kMinBitsPerFrame, kMaxBitsPerFrame].", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UART-006", + "title": "UART RX FIFO / read_size single-AVTPDU accepted limitation", + "text": "rcp::uart::kRxFifoCapacity and rcp::uart::kTxQueueCapacity shall both equal kMaxReadSize, documenting the accepted limitation that both are bounded so a UART read response always fits one unfragmented AVTPDU, consistent with ROADMAP.md milestone 52's fragmentation no-go decision.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-UART-007", + "title": "UartErrc is a distinct error category with a non-empty message", + "text": "rcp::uart::make_error_code(UartErrc::rx_fifo_overflow) shall report rcp::uart::uart_category() as its category and shall have a non-empty diagnostic message.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ADC-001", + "title": "ADC arithmetic-mean averaging combinator", + "text": "rcp::adc::compute_average shall compute the arithmetic mean of a non-empty sample set and shall return AdcErrc::no_signal for an empty sample set.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ADC-002", + "title": "ADC three-level averaging model", + "text": "rcp::adc::AdcEndpoint::request_reading shall average adc_avg_intervals_per_request raw samples per averaged-interval value (level 1), shall combine adc_combine_avg_values such averaged-interval values into the reported result (level 2), and shall return AdcErrc::invalid_averaging_config when either configuration field is 0.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ADC-003", + "title": "ADC request-driven sampling only", + "text": "rcp::adc::AdcEndpoint::request_reading shall invoke its sample source exactly adc_avg_intervals_per_request * adc_combine_avg_values times per call \u2014 no more, no fewer \u2014 and shall return AdcErrc::no_signal (this endpoint's analog of PWM_IN_NO_SIGNAL on the request path) the first time the sample source reports a missing capture, without free-running or pushing samples outside a call.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ADC-004", + "title": "ADC ExternalTrigger cadence pattern", + "text": "rcp::adc::AdcEndpoint::request_reading_from_trigger_queue shall consume exactly adc_avg_intervals_per_request * adc_combine_avg_values entries from the front of the supplied trigger-sample queue, shall erase only the consumed entries on success, and shall return AdcErrc::no_signal on a queue holding fewer entries than required.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ADC-005", + "title": "ADC no-signal handling for a missing triggered capture", + "text": "rcp::adc::AdcEndpoint::request_reading_from_trigger_queue shall return AdcErrc::no_signal when a consumed queue entry is std::nullopt, without producing a result derived from that entry.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-ADC-006", + "title": "AdcErrc is a distinct error category with a non-empty message", + "text": "rcp::adc::make_error_code(AdcErrc::no_signal) shall report rcp::adc::adc_category() as its category and shall have a non-empty diagnostic message.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWM-001", + "title": "PWM_OUT/PWM_IN shared period/active-duration payload shape", + "text": "rcp::pwm::PwmValue shall default both period and active_duration to 0, and both rcp::pwm::PwmOutEndpoint and rcp::pwm::PwmInEndpoint shall operate on this same two-field type.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWM-002", + "title": "PWM_OUT write semantics: generic combinators applied independently", + "text": "rcp::pwm::PwmOutEndpoint::handle_write shall apply Replace/Or/And/Xor to `period` and `active_duration` independently via rcp::endpoint::apply_bitmask_write.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWM-003", + "title": "PWM_OUT write semantics: saturating Add/Subtract shared with GPIO", + "text": "rcp::pwm::PwmOutEndpoint::handle_write shall apply the saturating Add/Subtract rule to `period` and `active_duration` independently, clamping instead of wrapping, via the same rcp::endpoint saturation helpers rcp::gpio::apply_gpio_write uses.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWM-004", + "title": "PWM_OUT write semantics: Reserved and Reconfigure rejected", + "text": "rcp::pwm::PwmOutEndpoint::handle_write shall return an error without modifying its state for WriteSemantics::Reserved, and shall return rcp::endpoint::EndpointErrc::non_combinable_write_semantics for WriteSemantics::Reconfigure (PWM_OUT defines no Reconfigure target of its own).", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWM-005", + "title": "PWM_IN response-only read model and PWM_IN_NO_SIGNAL", + "text": "rcp::pwm::PwmInEndpoint::handle_read shall return PwmErrc::no_signal before any record_measurement call and again after clear_signal(), and shall otherwise return the most recently recorded PwmValue.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWM-006", + "title": "PWM_IN mid-pulse trigger signal", + "text": "rcp::pwm::PwmInEndpoint::record_measurement shall notify the MidPulse trigger signal on every call, observable via drain() only when MidPulse was armed via triggers().enable().", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWM-007", + "title": "PwmErrc is a distinct error category with a non-empty message", + "text": "rcp::pwm::make_error_code(PwmErrc::no_signal) shall report rcp::pwm::pwm_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 a925ed6..47d02e2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.3.0 + VERSION 2.4.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 45db7dd..d780fb0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -361,6 +361,49 @@ lives in `tests/test_endpoint.cpp` (REQ-ENDPOINT-001..006), ### 48. Basic Endpoint Types II — I²C, UART, ADC, PWM_OUT, PWM_IN (v2.4.0) +**Done (v2.4.0):** `rcp/endpoint.hpp` gains real `ep_type` id constants for +all five endpoint types this milestone covers +(`kEndpointTypeI2c`/`kEndpointTypeUart`/`kEndpointTypePwmOut`/ +`kEndpointTypePwmIn`/`kEndpointTypeAdc`), replacing the comment placeholder +milestone 47 left for them. `rcp/i2c.hpp` implements I²C (`ep_type 0x04`): +a controller-only `I2cEndpoint::transfer` recording a raw byte stream +(address bytes included, since I²C has no separate address field in this +milestone's scope), `compound_wait_matches_bits` implementing the +arbitrary-bit-sequence compound-wait match (distinct from SPI's fixed +4-of-20-byte truncation), and `i2c_mode_of` deliberately decoding only the +coarse `hs` high-speed-requested bit — the finer `i2c_mode` speed-grade +mapping is flagged as an open item in the header's own comments rather than +guessed at, pending a future spec errata pass. `rcp/uart.hpp` implements +UART (`ep_type 0x05`): independent TX/RX queues (`enqueue_tx`/`drain_tx` vs. +`rx_fill`/`handle_read`), `handle_read`'s read-size-or-timeout completion +rule, `handle_pure_read` for payload-less reads, and +`pack_frame_to_octet`/`unpack_frame_bits` for sub-octet bit-width padding; +`kMaxReadSize`/`kRxFifoCapacity`/`kTxQueueCapacity` bound both the RX FIFO +and configurable read size to a conservative single-AVTPDU ceiling, +documented explicitly as an accepted limitation tied to milestone 52's +already-final fragmentation no-go decision, not left implicit. `rcp/adc.hpp` +implements ADC (`ep_type 0x09`): `AdcEndpoint::request_reading` (SelfTimed +cadence) and `request_reading_from_trigger_queue` (ExternalTrigger cadence, +keyed to `rcp/pwm.hpp`'s PWM_IN mid-pulse signal) both drive the same +three-level `compute_average` combinator +(`adc_avg_intervals_per_request` → `adc_combine_avg_values`), request-driven +only — sampling never free-runs outside a call — with `AdcErrc::no_signal` +as the no-signal timeout/underrun path analogous to PWM_IN_NO_SIGNAL. +`rcp/pwm.hpp` implements both PWM_OUT (`ep_type 0x07`) and PWM_IN +(`ep_type 0x08`) sharing one `PwmValue{period, active_duration}` payload +struct: `PwmOutEndpoint::handle_write` reuses `rcp::endpoint:: +apply_bitmask_write` directly (so GPIO's saturating add/subtract rule +applies to both fields without re-derivation) and leaves Reconfigure to that +function's own built-in rejection rather than inventing a PWM_OUT-specific +target; `PwmInEndpoint` is response-only (`handle_read`/ +`record_measurement`), returns `PwmErrc::no_signal` (PWM_IN_NO_SIGNAL) +before any measurement or after `clear_signal()`, and fires the MidPulse +trigger signal `rcp/adc.hpp`'s ExternalTrigger cadence pattern is meant to +key off of. New coverage lives in `tests/test_i2c.cpp` (REQ-I2C-001..005), +`tests/test_uart.cpp` (REQ-UART-001..007), `tests/test_adc.cpp` +(REQ-ADC-001..006), and `tests/test_pwm.cpp` (REQ-PWM-001..007), traced in +`.fusa-reqs.json`. + - I²C (`ep_type 0x04`): controller-only, raw byte stream including address bytes, compound-wait against arbitrary received-bit-sequence match; flag the unresolved `i2c_mode` high-speed enum ambiguity in code comments as an diff --git a/include/rcp/adc.hpp b/include/rcp/adc.hpp new file mode 100644 index 0000000..4525df6 --- /dev/null +++ b/include/rcp/adc.hpp @@ -0,0 +1,206 @@ +// fusa:req REQ-ADC-001 +// fusa:req REQ-ADC-002 +// fusa:req REQ-ADC-003 +// fusa:req REQ-ADC-004 +// fusa:req REQ-ADC-005 +// fusa:req REQ-ADC-006 + +// ADC endpoint (ep_type 0x09) — the three-level averaging model +// (adc_sample_interval -> adc_avg_intervals_per_request -> +// adc_combine_avg_values), request-driven sampling only (no free-running +// push), two self-triggering cadence patterns, and a no-signal timeout +// handling path analogous to PWM_IN's PWM_IN_NO_SIGNAL error (extraction +// §5.9). +// +// ROADMAP.md milestone 48, "Basic Endpoint Types II — I2C, UART, ADC, +// PWM_OUT, PWM_IN (v2.4.0)": ADC has no combinable write-request payload +// (it is read/request-driven only), so unlike rcp/gpio.hpp and +// rcp/pwm.hpp's PwmOutEndpoint it does not build on +// rcp/endpoint.hpp's WriteSemantics/apply_bitmask_write at all — its shared +// surface with the rest of this milestone is only the general +// request-dispatch shape (one entry point per request) and the no-signal +// error-path pattern rcp/pwm.hpp's PwmInEndpoint also implements +// (PwmErrc::no_signal / AdcErrc::no_signal are deliberately parallel). +// +// Three-level averaging model (extraction §5.9), as implemented below: +// 1. adc_sample_interval: spacing between individual raw +// samples (a caller/clock concern — +// this header does not itself model +// wall-clock time, see AdcCadence) +// 2. adc_avg_intervals_per_request: how many adc_sample_interval-spaced +// raw samples are averaged into one +// "averaged-interval" value +// 3. adc_combine_avg_values: how many averaged-interval values +// are further combined into the +// single value returned to the +// request +// +// Two self-triggering cadence patterns (extraction §5.9), as implemented +// below: SelfTimed, where the caller supplies raw samples already paced by +// adc_sample_interval (e.g. a wall-clock-driven sampler), and +// ExternalTrigger, where raw samples are instead paced by an external +// trigger-signal occurrence — the roadmap's own example being rcp/pwm.hpp's +// PwmInEndpoint mid-pulse signal keying ADC sampling cadence. +// +// 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 averaging +// combinator (arithmetic mean at both levels) and cadence-pattern API shape +// chosen in this file are this implementation's own, same as the equivalent +// disclaimers in rcp/wire.hpp, rcp/regmap.hpp, rcp/endpoint.hpp, +// rcp/gpio.hpp, and rcp/spi.hpp. +#pragma once + +#include +#include +#include +#include +#include + +namespace rcp { +namespace adc { + +// ── Averaging configuration ─────────────────────────────────────────────────── + +struct AdcAveragingConfig { + uint32_t adc_sample_interval_us = 0; // level 1 pacing; not enforced by this header, see AdcCadence + uint16_t adc_avg_intervals_per_request = 1; // level 1 sample count per averaged-interval value + uint16_t adc_combine_avg_values = 1; // level 2 averaged-interval count per request result +}; + +// ── Cadence pattern selector ────────────────────────────────────────────────── + +enum class AdcCadence : uint8_t { + SelfTimed = 0, // raw samples paced internally by adc_sample_interval (caller-supplied pacing) + ExternalTrigger = 1, // raw samples paced by an external trigger signal occurrence +}; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class AdcErrc : int { + no_signal = 1, // analogous to PWM_IN_NO_SIGNAL: no valid sample before timeout/underrun + invalid_averaging_config = 2, // adc_avg_intervals_per_request or adc_combine_avg_values is 0 +}; + +inline const std::error_category& adc_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.adc"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case AdcErrc::no_signal: + return "rcp/adc: no signal — no valid sample captured (ADC_NO_SIGNAL)"; + case AdcErrc::invalid_averaging_config: + return "rcp/adc: adc_avg_intervals_per_request/adc_combine_avg_values must be >= 1"; + default: + return "rcp/adc: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(AdcErrc e) noexcept { + return {static_cast(e), adc_category()}; +} + +// ── Level 1 / level 2 combinators ───────────────────────────────────────────── +// Both levels of the averaging model use the same arithmetic-mean +// combinator in this implementation — the extraction does not mandate a +// different combination function per level, so reusing one keeps the two +// levels' behavior obviously consistent with each other. + +inline std::error_code compute_average(const std::vector& samples, uint32_t& out_avg) noexcept { + if (samples.empty()) return make_error_code(AdcErrc::no_signal); + uint64_t sum = 0; + for (const uint32_t s : samples) sum += s; + out_avg = static_cast(sum / samples.size()); + return {}; +} + +// ── AdcEndpoint ─────────────────────────────────────────────────────────────── +// Request-driven sampling only (extraction §5.9): a reading only happens as +// the direct result of one of the request_reading_* calls below — there is +// no free-running/self-pushed sample stream modeled anywhere in this +// header. +class AdcEndpoint { +public: + // request_reading implements the SelfTimed cadence pattern: `take_sample` + // is invoked synchronously once per required raw sample (already paced + // by adc_sample_interval on the caller's side, e.g. by a wall-clock + // driven sampler) and must return std::nullopt to report a failed/ + // missing capture, which this function surfaces as AdcErrc::no_signal — + // this endpoint's analog of PWM_IN's PWM_IN_NO_SIGNAL on the request + // path. + template + std::error_code request_reading(const AdcAveragingConfig& cfg, SampleFn take_sample, + uint32_t& out_value) { + if (cfg.adc_avg_intervals_per_request == 0 || cfg.adc_combine_avg_values == 0) + return make_error_code(AdcErrc::invalid_averaging_config); + + std::vector averaged_intervals; + averaged_intervals.reserve(cfg.adc_combine_avg_values); + for (uint16_t i = 0; i < cfg.adc_combine_avg_values; ++i) { + std::vector raw; + raw.reserve(cfg.adc_avg_intervals_per_request); + for (uint16_t j = 0; j < cfg.adc_avg_intervals_per_request; ++j) { + std::optional sample = take_sample(); + if (!sample.has_value()) return make_error_code(AdcErrc::no_signal); + raw.push_back(*sample); + } + uint32_t interval_avg = 0; + auto ec = compute_average(raw, interval_avg); + if (ec) return ec; + averaged_intervals.push_back(interval_avg); + } + return compute_average(averaged_intervals, out_value); + } + + // request_reading_from_trigger_queue implements the ExternalTrigger + // cadence pattern: raw samples are not pulled on demand but have + // already been captured once per external trigger occurrence (e.g. + // rcp/pwm.hpp's PwmInEndpoint mid-pulse signal) and queued by the + // caller in `triggered_samples`, oldest first. This function consumes + // exactly enough entries to satisfy the averaging config, reporting + // AdcErrc::no_signal on either a std::nullopt entry (a trigger occurred + // with no valid capture) or a queue underrun (fewer trigger occurrences + // than required). + std::error_code request_reading_from_trigger_queue( + const AdcAveragingConfig& cfg, std::vector>& triggered_samples, + uint32_t& out_value) { + if (cfg.adc_avg_intervals_per_request == 0 || cfg.adc_combine_avg_values == 0) + return make_error_code(AdcErrc::invalid_averaging_config); + + const size_t needed = + static_cast(cfg.adc_avg_intervals_per_request) * cfg.adc_combine_avg_values; + if (triggered_samples.size() < needed) return make_error_code(AdcErrc::no_signal); + + std::vector averaged_intervals; + averaged_intervals.reserve(cfg.adc_combine_avg_values); + size_t cursor = 0; + for (uint16_t i = 0; i < cfg.adc_combine_avg_values; ++i) { + std::vector raw; + raw.reserve(cfg.adc_avg_intervals_per_request); + for (uint16_t j = 0; j < cfg.adc_avg_intervals_per_request; ++j, ++cursor) { + if (!triggered_samples[cursor].has_value()) return make_error_code(AdcErrc::no_signal); + raw.push_back(*triggered_samples[cursor]); + } + uint32_t interval_avg = 0; + auto ec = compute_average(raw, interval_avg); + if (ec) return ec; + averaged_intervals.push_back(interval_avg); + } + triggered_samples.erase(triggered_samples.begin(), + triggered_samples.begin() + static_cast(needed)); + return compute_average(averaged_intervals, out_value); + } +}; + +} // namespace adc +} // namespace rcp + +// Enable std::error_code construction from rcp::adc::AdcErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/endpoint.hpp b/include/rcp/endpoint.hpp index d66ad30..23a6a6a 100644 --- a/include/rcp/endpoint.hpp +++ b/include/rcp/endpoint.hpp @@ -10,14 +10,16 @@ // 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: +// ROADMAP.md milestone 47, "Basic Endpoint Types I — GPIO & SPI (v2.3.0)", +// and milestone 48, "Basic Endpoint Types II — I2C, UART, ADC, PWM_OUT, +// PWM_IN (v2.4.0)": GPIO (rcp/gpio.hpp) and SPI (rcp/spi.hpp) were +// deliberately sequenced first because their request/response payload +// shapes are the simplest fully specified ones, which made them the right +// place to establish this reusable pattern before the milestone 48 endpoint +// types (rcp/i2c.hpp, rcp/uart.hpp, rcp/adc.hpp, rcp/pwm.hpp) — and later, +// LIN, CAN, ISELED, MDIO, Wakeup at v2.7.0 — needed it too. This header +// holds exactly the parts of that pattern every endpoint type needs +// 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, @@ -56,16 +58,22 @@ 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. +// this numbering for endpoint milestones. Ids are added here as each +// milestone's endpoint types are implemented, so the numbering stays +// visibly centralized in one place rather than getting re-derived ad hoc by +// each new header. 0x06 has no assignment in either v2.3.0 or v2.4.0's +// scope (the extraction does not name an endpoint type for it) and is left +// unallocated rather than guessed at. 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 +constexpr EndpointTypeId kEndpointTypeGpio = 0x02; // v2.3.0 +constexpr EndpointTypeId kEndpointTypeSpi = 0x03; // v2.3.0 +constexpr EndpointTypeId kEndpointTypeI2c = 0x04; // this milestone (v2.4.0) +constexpr EndpointTypeId kEndpointTypeUart = 0x05; // this milestone (v2.4.0) +constexpr EndpointTypeId kEndpointTypePwmOut = 0x07; // this milestone (v2.4.0) +constexpr EndpointTypeId kEndpointTypePwmIn = 0x08; // this milestone (v2.4.0) +constexpr EndpointTypeId kEndpointTypeAdc = 0x09; // this milestone (v2.4.0) // LIN, CAN (incl. CAN XL), ISELED, MDIO, Wakeup control — v2.7.0 (ids TBD there) // ── evt[2:0] write semantics ────────────────────────────────────────────────── diff --git a/include/rcp/i2c.hpp b/include/rcp/i2c.hpp new file mode 100644 index 0000000..06ebea4 --- /dev/null +++ b/include/rcp/i2c.hpp @@ -0,0 +1,173 @@ +// fusa:req REQ-I2C-001 +// fusa:req REQ-I2C-002 +// fusa:req REQ-I2C-003 +// fusa:req REQ-I2C-004 +// fusa:req REQ-I2C-005 + +// I2C endpoint (ep_type 0x04) — controller-only raw byte-stream transfer +// (including address bytes, per the OPEN Alliance TC18 Remote Control +// Protocol Specification v0.5.1_RC's own framing) and the compound-wait +// arbitrary-bit-sequence match rule (extraction §5.7, §7). +// +// ROADMAP.md milestone 48, "Basic Endpoint Types II — I2C, UART, ADC, +// PWM_OUT, PWM_IN (v2.4.0)": I2C is built on rcp/endpoint.hpp's shared +// TriggerRegistry, following the request-dispatch shape rcp/gpio.hpp and +// rcp/spi.hpp establish (I2cEndpoint mirrors SpiEndpoint's shape: a raw +// byte-exchange transfer() call plus per-endpoint trigger signals). Unlike +// SPI, I2C has no pre-configured channel concept in this milestone's scope +// — a single I2cEndpoint instance models one controller-mode I2C bus, and +// the target device address travels inside the raw byte stream itself +// rather than as a separate selector field. +// +// OPEN ITEM, called out explicitly per the roadmap rather than guessed at: +// the extraction leaves the mapping of I2C's `i2c_mode` field (device +// speed grade — Standard/Fast/Fast-mode-Plus/High-Speed) onto wire values +// ambiguous beyond the coarse `AcfMessageInfo::hs` "high-speed requested" +// flag rcp/wire.hpp already reserves for this purpose. This header +// therefore only decodes that coarse bit (see i2c_mode_of below) and does +// not attempt to further distinguish the standard-speed grades from each +// other; extending i2c_mode_of to do so must wait for a spec errata pass +// that resolves the ambiguity, not a guess made 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 transfer-shape +// 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, rcp/endpoint.hpp, rcp/gpio.hpp, and rcp/spi.hpp. +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace rcp { +namespace i2c { + +// ── i2c_mode (OPEN ITEM — see header comment above) ────────────────────────── +// Only the coarse high-speed-requested bit is unambiguous from the +// extraction available to this implementation; finer speed grades are +// deliberately not modeled here. +enum class I2cMode : uint8_t { + Standard = 0, // hs not set — this implementation's catch-all for every non-high-speed grade + HighSpeed = 1, // hs set — sub-mode (if any) is the unresolved part of this open item +}; + +// i2c_mode_of decodes AcfMessageInfo::hs into the two-way distinction this +// implementation can make without guessing (see OPEN ITEM above). +constexpr I2cMode i2c_mode_of(bool hs) noexcept { + return hs ? I2cMode::HighSpeed : I2cMode::Standard; +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class I2cErrc : int { + nack = 1, // the addressed device did not acknowledge (controller-only view) +}; + +inline const std::error_category& i2c_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.i2c"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case I2cErrc::nack: return "rcp/i2c: addressed device did not acknowledge (NACK)"; + default: return "rcp/i2c: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(I2cErrc e) noexcept { + return {static_cast(e), i2c_category()}; +} + +// ── Compound-wait arbitrary-bit-sequence match ──────────────────────────────── +// I2C's compound-wait condition is matched against an arbitrary-length bit +// sequence rather than SPI's fixed 4-of-20-byte truncation (rcp/spi.hpp's +// compound_wait_matches) — extraction §5.7. `bit_len` is the number of +// leading bits of `received` to compare against `expected`, MSB-first +// within each byte; a `bit_len` that is not a multiple of 8 compares only +// the high `bit_len % 8` bits of the final partial byte, leaving the low +// bits of that byte unconstrained. This function takes no position on the +// rest of compound-wait's semantics (sequencer state, the `cs` field), +// which are v2.5.0 scope, same disclaimer as rcp/spi.hpp's own +// compound_wait_matches. +inline bool compound_wait_matches_bits(const std::vector& received, + const std::vector& expected, + size_t bit_len) noexcept { + if (bit_len == 0) return false; + const size_t full_bytes = bit_len / 8; + const size_t rem_bits = bit_len % 8; + const size_t needed_bytes = full_bytes + (rem_bits != 0 ? 1 : 0); + if (received.size() < needed_bytes || expected.size() < needed_bytes) return false; + + if (!std::equal(received.begin(), received.begin() + static_cast(full_bytes), expected.begin())) + return false; + + if (rem_bits != 0) { + const uint8_t mask = static_cast(0xFFu << (8u - rem_bits)); // top rem_bits of the byte + if ((received[full_bytes] & mask) != (expected[full_bytes] & mask)) return false; + } + return true; +} + +// ── Trigger signals ─────────────────────────────────────────────────────────── +// One TransferComplete/Nack pair per I2cEndpoint instance (no per-channel +// scoping — see the header-comment note on I2C having no channel concept in +// this milestone's scope), built on rcp/endpoint.hpp's generic +// TriggerRegistry, same primitive rcp/gpio.hpp and rcp/spi.hpp use. + +enum class I2cSignal : uint8_t { TransferComplete = 0, Nack = 1 }; + +constexpr endpoint::TriggerRegistry::SignalId i2c_signal_id(I2cSignal sig) noexcept { + return static_cast(sig); +} + +// ── I2cEndpoint ─────────────────────────────────────────────────────────────── +// Mirrors rcp::spi::SpiEndpoint's shape: one request-dispatch entry point +// per incoming I2C transfer. `out_bytes` is the raw stream sent to the bus, +// address byte(s) included per this milestone's "raw byte stream including +// address bytes" scope; `in_bytes` is this implementation's record of the +// bytes received over the same transfer (supplied by the caller — this +// header models the request/response and trigger-signal shape of an I2C +// transfer, not an actual bus controller). Controller-only: this header has +// no target/peripheral-mode behavior at all. +class I2cEndpoint { +public: + std::error_code transfer(std::vector out_bytes, std::vector in_bytes, + bool acked = true) { + last_out_ = std::move(out_bytes); + last_in_ = std::move(in_bytes); + triggers_.notify(i2c_signal_id(I2cSignal::TransferComplete)); + if (!acked) { + triggers_.notify(i2c_signal_id(I2cSignal::Nack)); + return make_error_code(I2cErrc::nack); + } + return {}; + } + + const std::vector& last_sent() const noexcept { return last_out_; } + const std::vector& last_received() const noexcept { return last_in_; } + endpoint::TriggerRegistry& triggers() noexcept { return triggers_; } + +private: + endpoint::TriggerRegistry triggers_; + std::vector last_out_; + std::vector last_in_; +}; + +} // namespace i2c +} // namespace rcp + +// Enable std::error_code construction from rcp::i2c::I2cErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/pwm.hpp b/include/rcp/pwm.hpp new file mode 100644 index 0000000..f24da1c --- /dev/null +++ b/include/rcp/pwm.hpp @@ -0,0 +1,168 @@ +// fusa:req REQ-PWM-001 +// fusa:req REQ-PWM-002 +// fusa:req REQ-PWM-003 +// fusa:req REQ-PWM-004 +// fusa:req REQ-PWM-005 +// fusa:req REQ-PWM-006 +// fusa:req REQ-PWM-007 + +// PWM_OUT (ep_type 0x07) and PWM_IN (ep_type 0x08) endpoints — the shared +// period/active-duration two-field payload shape, PWM_OUT's 8-way +// write-semantics reuse from GPIO, PWM_IN's response-only read model and +// PWM_IN_NO_SIGNAL error path, and the mid-pulse trigger signal used to key +// ADC sampling cadence (extraction §5.5, §5.6). +// +// ROADMAP.md milestone 48, "Basic Endpoint Types II — I2C, UART, ADC, +// PWM_OUT, PWM_IN (v2.4.0)": kept in one header, mirroring how +// rcp/endpoint.hpp is shared scaffolding for rcp/gpio.hpp and rcp/spi.hpp +// rather than each having its own copy — PWM_OUT and PWM_IN share one +// payload struct (PwmValue) and differ only in read/write direction and +// trigger-signal behavior. PWM_OUT reuses rcp/endpoint.hpp's +// WriteSemantics/apply_bitmask_write directly (including the +// saturating-add/subtract rule it already implements generically), the +// same primitive rcp/gpio.hpp's GpioEndpoint builds on, rather than +// re-deriving its own combinator. PWM_OUT defines no Reconfigure target of +// its own (unlike GPIO's pin-direction retarget) — the extraction does not +// name one for PWM_OUT, so a Reconfigure write is left to +// apply_bitmask_write's own built-in rejection +// (EndpointErrc::non_combinable_write_semantics) rather than this header +// inventing a meaning for it. +// +// 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 period/duration +// unit (left as an opaque implementation-defined tick count, same as +// rcp/regmap.hpp's own field-width disclaimers) 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 + +namespace rcp { +namespace pwm { + +// ── Shared period/active-duration payload shape ─────────────────────────────── +// `period` and `active_duration` share one implementation-defined tick unit +// (extraction §5.5, §5.6); this header does not itself fix that unit to a +// physical time value. + +struct PwmValue { + uint32_t period = 0; + uint32_t active_duration = 0; +}; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class PwmErrc : int { + no_signal = 1, // PWM_IN_NO_SIGNAL: no pulse has been measured yet +}; + +inline const std::error_category& pwm_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.pwm"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case PwmErrc::no_signal: return "rcp/pwm: PWM_IN_NO_SIGNAL — no pulse measured"; + default: return "rcp/pwm: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(PwmErrc e) noexcept { + return {static_cast(e), pwm_category()}; +} + +// ── PwmOutEndpoint (ep_type 0x07) ──────────────────────────────────────────── +// Applies one of GPIO's 8 write semantics independently to `period` and to +// `active_duration` — each field is combined against its own current value +// (this implementation's own choice: the extraction does not itself say +// whether the two fields share one combinator application or each combines +// independently, and independent combination is what this header +// implements). Reuses rcp::endpoint::apply_bitmask_write directly, so the +// saturating add/subtract rule endpoint.hpp already implements generically +// applies to both fields without PWM_OUT re-deriving it. +class PwmOutEndpoint { +public: + std::error_code handle_write(endpoint::WriteSemantics op, PwmValue operand, + PwmValue& out_value) noexcept { + uint32_t period_out = 0; + auto ec = endpoint::apply_bitmask_write(op, state_.period, operand.period, period_out); + if (ec) return ec; + uint32_t duration_out = 0; + ec = endpoint::apply_bitmask_write(op, state_.active_duration, operand.active_duration, + duration_out); + if (ec) return ec; + state_.period = period_out; + state_.active_duration = duration_out; + out_value = state_; + return {}; + } + + const PwmValue& read() const noexcept { return state_; } + +private: + PwmValue state_; +}; + +// ── PwmInEndpoint (ep_type 0x08) ───────────────────────────────────────────── +// Response-only read model (extraction §5.6): PWM_IN has no write request +// shape at all in this milestone's scope. record_measurement models one +// input-capture cycle completing and firing the MidPulse trigger signal +// rcp/adc.hpp's AdcCadence::ExternalTrigger pattern is expected to key +// sampling cadence off of. +enum class PwmInSignal : uint8_t { MidPulse = 0 }; + +constexpr endpoint::TriggerRegistry::SignalId pwm_in_signal_id(PwmInSignal sig) noexcept { + return static_cast(sig); +} + +class PwmInEndpoint { +public: + // record_measurement records one completed input-capture cycle and + // fires MidPulse for any armed listener. + void record_measurement(PwmValue value) noexcept { + last_value_ = value; + has_signal_ = true; + triggers_.notify(pwm_in_signal_id(PwmInSignal::MidPulse)); + } + + // clear_signal models signal loss (e.g. the measured line goes idle + // long enough that no pulse is being received) — a subsequent + // handle_read reports PwmErrc::no_signal again until the next + // record_measurement. + void clear_signal() noexcept { has_signal_ = false; } + + // handle_read is PWM_IN's entire read model: return the last measured + // value, or PwmErrc::no_signal (PWM_IN_NO_SIGNAL) if none has ever been + // captured (or the signal was subsequently lost). + std::error_code handle_read(PwmValue& out_value) const noexcept { + if (!has_signal_) return make_error_code(PwmErrc::no_signal); + out_value = last_value_; + return {}; + } + + endpoint::TriggerRegistry& triggers() noexcept { return triggers_; } + +private: + PwmValue last_value_{}; + bool has_signal_ = false; + endpoint::TriggerRegistry triggers_; +}; + +} // namespace pwm +} // namespace rcp + +// Enable std::error_code construction from rcp::pwm::PwmErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/uart.hpp b/include/rcp/uart.hpp new file mode 100644 index 0000000..770a7ee --- /dev/null +++ b/include/rcp/uart.hpp @@ -0,0 +1,204 @@ +// fusa:req REQ-UART-001 +// fusa:req REQ-UART-002 +// fusa:req REQ-UART-003 +// fusa:req REQ-UART-004 +// fusa:req REQ-UART-005 +// fusa:req REQ-UART-006 +// fusa:req REQ-UART-007 + +// UART endpoint (ep_type 0x05) — independent TX/RX queues, RX FIFO +// fill/drain semantics, read completion on either a configured `read_size` +// or `uart_timeout` elapsing, payload-less "pure" read requests, and +// sub-octet bit-width padding (extraction §5.8). +// +// ROADMAP.md milestone 48, "Basic Endpoint Types II — I2C, UART, ADC, +// PWM_OUT, PWM_IN (v2.4.0)": UART follows the request-dispatch shape +// rcp/gpio.hpp and rcp/spi.hpp establish, but — unlike either — has no +// evt[2:0]/channel decode of its own; TX and RX are independent queues +// addressed by separate calls (enqueue_tx/handle_read below) rather than by +// a shared selector field. +// +// ACCEPTED LIMITATION, documented explicitly per the roadmap rather than +// left implicit: ROADMAP.md milestone 52 ("Fragmentation — Go/No-Go +// Decision", v2.8.0) has already made its go/no-go call — fragmentation is +// no-go for this development cycle. Because of that already-final decision, +// this header bounds both the RX FIFO's capacity and the largest +// `read_size` it will accept (kMaxReadSize below) so that a UART read +// response can always be carried in a single, unfragmented AVTPDU. This is +// a conservative implementation-chosen ceiling, not a value derived from +// the wire format's own read_size field width (which is a full uint16_t, +// see rcp/wire.hpp's AcfMessageInfo::read_size_or_segment_num) — a real +// single-AVTPDU budget depends on MTU and other-header overhead this header +// does not model, so kMaxReadSize is deliberately conservative rather than +// computed exactly. +// +// 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 queue-capacity +// values and sub-octet padding convention chosen in this file are this +// implementation's own, same as the equivalent disclaimers in rcp/wire.hpp, +// rcp/regmap.hpp, rcp/endpoint.hpp, rcp/gpio.hpp, and rcp/spi.hpp. +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace rcp { +namespace uart { + +// ── Single-AVTPDU response bound (accepted limitation — see header comment) ── + +constexpr size_t kMaxReadSize = 512; // bytes; conservative single-AVTPDU bound +constexpr size_t kRxFifoCapacity = kMaxReadSize; +constexpr size_t kTxQueueCapacity = kMaxReadSize; + +// ── Sub-octet bit-width padding ─────────────────────────────────────────────── +// UART frames narrower than a full octet (5-8 data bits per frame is this +// implementation's own accepted range) are still carried one-per-byte on +// the wire; the frame's data occupies the low `bits_per_frame` bits of that +// byte, left-justified from bit 0, with the remaining high bits zero-padded +// (this implementation's own explicit packing convention — the extraction +// does not itself dictate which end the padding goes on, so it is called +// out here rather than left to reader inference, matching this repo's +// convention of flagging implementation-chosen encodings elsewhere). + +constexpr uint8_t kMinBitsPerFrame = 5; +constexpr uint8_t kMaxBitsPerFrame = 8; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class UartErrc : int { + read_size_exceeds_bound = 1, // requested read_size > kMaxReadSize + rx_fifo_overflow = 2, // rx_fill would push the RX FIFO past kRxFifoCapacity + tx_queue_overflow = 3, // enqueue_tx would push the TX queue past kTxQueueCapacity + bits_per_frame_out_of_range = 4, // bits_per_frame outside [kMinBitsPerFrame, kMaxBitsPerFrame] +}; + +inline const std::error_category& uart_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.uart"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case UartErrc::read_size_exceeds_bound: + return "rcp/uart: read_size exceeds the single-AVTPDU accepted bound"; + case UartErrc::rx_fifo_overflow: + return "rcp/uart: RX FIFO overflow"; + case UartErrc::tx_queue_overflow: + return "rcp/uart: TX queue overflow"; + case UartErrc::bits_per_frame_out_of_range: + return "rcp/uart: bits_per_frame out of accepted range"; + default: + return "rcp/uart: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(UartErrc e) noexcept { + return {static_cast(e), uart_category()}; +} + +// pack_frame_to_octet / unpack_frame_bits implement the sub-octet padding +// convention documented above. +inline std::error_code pack_frame_to_octet(uint8_t value, uint8_t bits_per_frame, uint8_t& out) noexcept { + if (bits_per_frame < kMinBitsPerFrame || bits_per_frame > kMaxBitsPerFrame) + return make_error_code(UartErrc::bits_per_frame_out_of_range); + const uint8_t mask = static_cast((1u << bits_per_frame) - 1u); + out = static_cast(value & mask); + return {}; +} + +inline std::error_code unpack_frame_bits(uint8_t octet, uint8_t bits_per_frame, uint8_t& out) noexcept { + if (bits_per_frame < kMinBitsPerFrame || bits_per_frame > kMaxBitsPerFrame) + return make_error_code(UartErrc::bits_per_frame_out_of_range); + const uint8_t mask = static_cast((1u << bits_per_frame) - 1u); + out = static_cast(octet & mask); + return {}; +} + +// ── UartEndpoint ────────────────────────────────────────────────────────────── +// TX and RX are modeled as independent queues (extraction §5.8): writes +// enqueue onto the TX queue via enqueue_tx and are later drained by the +// caller's transport/driver layer via drain_tx; bytes arriving from the +// wire are pushed into the RX FIFO via rx_fill (also driven by that same +// external layer — this header does not itself own a UART transceiver) and +// consumed by read requests via handle_read/handle_pure_read. +class UartEndpoint { +public: + // enqueue_tx appends to the independent TX queue, bounded by + // kTxQueueCapacity per the single-AVTPDU accepted limitation. + std::error_code enqueue_tx(const std::vector& bytes) { + if (tx_queue_.size() + bytes.size() > kTxQueueCapacity) + return make_error_code(UartErrc::tx_queue_overflow); + tx_queue_.insert(tx_queue_.end(), bytes.begin(), bytes.end()); + return {}; + } + + // drain_tx returns and clears everything queued for transmission. + std::vector drain_tx() { + std::vector out(tx_queue_.begin(), tx_queue_.end()); + tx_queue_.clear(); + return out; + } + + // rx_fill pushes bytes that arrived from the wire into the RX FIFO, + // bounded by kRxFifoCapacity per the single-AVTPDU accepted limitation + // (extraction §5.8's RX FIFO fill semantics). + std::error_code rx_fill(const std::vector& bytes) { + if (rx_fifo_.size() + bytes.size() > kRxFifoCapacity) + return make_error_code(UartErrc::rx_fifo_overflow); + rx_fifo_.insert(rx_fifo_.end(), bytes.begin(), bytes.end()); + return {}; + } + + size_t rx_available() const noexcept { return rx_fifo_.size(); } + + // handle_read is UART's read-completion rule: it drains up to + // `read_size` bytes currently available in the RX FIFO (RX FIFO drain + // semantics), then reports whether `read_size` was fully reached or + // whether `elapsed_ms >= uart_timeout_ms` — the two conditions the + // extraction names as completing a read (extraction §5.8). The caller + // supplies elapsed/timeout explicitly since this header has no clock of + // its own, matching every other endpoint type in this milestone. + std::error_code handle_read(uint16_t read_size, uint32_t elapsed_ms, uint32_t uart_timeout_ms, + std::vector& out_data, bool& out_timed_out) noexcept { + if (read_size > kMaxReadSize) return make_error_code(UartErrc::read_size_exceeds_bound); + out_data.clear(); + while (out_data.size() < read_size && !rx_fifo_.empty()) { + out_data.push_back(rx_fifo_.front()); + rx_fifo_.pop_front(); + } + const bool reached = out_data.size() >= read_size && read_size > 0; + out_timed_out = !reached && elapsed_ms >= uart_timeout_ms; + return {}; + } + + // handle_pure_read is UART's payload-less "pure" read request: no + // read_size is carried at all, so there is nothing to wait on — it + // drains and returns whatever is currently buffered in the RX FIFO, + // non-blocking (extraction §5.8). + std::vector handle_pure_read() { + std::vector out(rx_fifo_.begin(), rx_fifo_.end()); + rx_fifo_.clear(); + return out; + } + +private: + std::deque tx_queue_; + std::deque rx_fifo_; +}; + +} // namespace uart +} // namespace rcp + +// Enable std::error_code construction from rcp::uart::UartErrc. +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 88eea50..d39e37d 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.3.0"; +constexpr std::string_view kVersion = "2.4.0"; } // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1e0ae85..e5dbe55 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,6 +38,22 @@ 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_i2c test_i2c.cpp) +target_link_libraries(test_i2c PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_i2c COMMAND test_i2c) + +add_executable(test_uart test_uart.cpp) +target_link_libraries(test_uart PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_uart COMMAND test_uart) + +add_executable(test_adc test_adc.cpp) +target_link_libraries(test_adc PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_adc COMMAND test_adc) + +add_executable(test_pwm test_pwm.cpp) +target_link_libraries(test_pwm PRIVATE rcp::rcp Catch2::Catch2WithMain) +add_test(NAME rcp_pwm COMMAND test_pwm) + 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_adc.cpp b/tests/test_adc.cpp new file mode 100644 index 0000000..390cb13 --- /dev/null +++ b/tests/test_adc.cpp @@ -0,0 +1,149 @@ +// fusa:test REQ-ADC-001 +// fusa:test REQ-ADC-002 +// fusa:test REQ-ADC-003 +// fusa:test REQ-ADC-004 +// fusa:test REQ-ADC-005 +// fusa:test REQ-ADC-006 + +// Tests for rcp/adc.hpp — the ADC endpoint type (ROADMAP.md milestone 48, +// "Basic Endpoint Types II — I2C, UART, ADC, PWM_OUT, PWM_IN", v2.4.0). + +#include +#include + +#include + +using namespace rcp::adc; + +// ── Three-level averaging model ────────────────────────────────────────────── + +TEST_CASE("compute_average computes an arithmetic mean", "[adc][REQ-ADC-001]") { + uint32_t out = 0; + REQUIRE_FALSE(compute_average({10, 20, 30}, out)); + REQUIRE(out == 20); +} + +TEST_CASE("compute_average reports no_signal on an empty sample set", "[adc][REQ-ADC-001]") { + uint32_t out = 0; + REQUIRE(compute_average({}, out) == make_error_code(AdcErrc::no_signal)); +} + +TEST_CASE("request_reading combines level-1 and level-2 averaging correctly", "[adc][REQ-ADC-002]") { + AdcEndpoint ep; + AdcAveragingConfig cfg; + cfg.adc_avg_intervals_per_request = 2; // level 1: average 2 raw samples per interval + cfg.adc_combine_avg_values = 2; // level 2: average 2 intervals + + // Raw samples, consumed in order: interval 1 = {10,20} -> avg 15; + // interval 2 = {30,40} -> avg 35; combined = avg(15,35) = 25. + std::deque raw{10, 20, 30, 40}; + auto take_sample = [&raw]() -> std::optional { + if (raw.empty()) return std::nullopt; + uint32_t v = raw.front(); + raw.pop_front(); + return v; + }; + + uint32_t out_value = 0; + auto ec = ep.request_reading(cfg, take_sample, out_value); + REQUIRE_FALSE(ec); + REQUIRE(out_value == 25); +} + +TEST_CASE("request_reading rejects a zero-valued averaging config field", "[adc][REQ-ADC-002]") { + AdcEndpoint ep; + AdcAveragingConfig cfg; + cfg.adc_avg_intervals_per_request = 0; + auto take_sample = []() -> std::optional { return 1; }; + + uint32_t out_value = 0; + auto ec = ep.request_reading(cfg, take_sample, out_value); + REQUIRE(ec == make_error_code(AdcErrc::invalid_averaging_config)); +} + +// ── Request-driven sampling only / no-signal timeout path ─────────────────── + +TEST_CASE("request_reading reports no_signal when take_sample underruns", "[adc][REQ-ADC-003]") { + AdcEndpoint ep; + AdcAveragingConfig cfg; + cfg.adc_avg_intervals_per_request = 3; + cfg.adc_combine_avg_values = 1; + + auto take_sample = []() -> std::optional { return std::nullopt; }; // no signal captured + + uint32_t out_value = 0; + auto ec = ep.request_reading(cfg, take_sample, out_value); + REQUIRE(ec == make_error_code(AdcErrc::no_signal)); +} + +TEST_CASE("request_reading only invokes take_sample exactly the required number of times", + "[adc][REQ-ADC-003]") { + AdcEndpoint ep; + AdcAveragingConfig cfg; + cfg.adc_avg_intervals_per_request = 2; + cfg.adc_combine_avg_values = 3; + + int calls = 0; + auto take_sample = [&calls]() -> std::optional { + ++calls; + return 42u; + }; + + uint32_t out_value = 0; + REQUIRE_FALSE(ep.request_reading(cfg, take_sample, out_value)); + REQUIRE(calls == 6); // 2 * 3, no free-running/extra sampling + REQUIRE(out_value == 42); +} + +// ── Two self-triggering cadence patterns ───────────────────────────────────── + +TEST_CASE("request_reading_from_trigger_queue implements the ExternalTrigger cadence pattern", + "[adc][REQ-ADC-004]") { + AdcEndpoint ep; + AdcAveragingConfig cfg; + cfg.adc_avg_intervals_per_request = 2; + cfg.adc_combine_avg_values = 2; + + std::vector> queue{10u, 20u, 30u, 40u, 999u}; // one extra entry left over + + uint32_t out_value = 0; + auto ec = ep.request_reading_from_trigger_queue(cfg, queue, out_value); + REQUIRE_FALSE(ec); + REQUIRE(out_value == 25); // same combination as the SelfTimed test above + REQUIRE(queue.size() == 1); // exactly the consumed entries were removed + REQUIRE(*queue[0] == 999u); +} + +TEST_CASE("request_reading_from_trigger_queue reports no_signal on queue underrun", + "[adc][REQ-ADC-004]") { + AdcEndpoint ep; + AdcAveragingConfig cfg; + cfg.adc_avg_intervals_per_request = 2; + cfg.adc_combine_avg_values = 2; + + std::vector> queue{1u, 2u}; // fewer than the 4 needed + uint32_t out_value = 0; + auto ec = ep.request_reading_from_trigger_queue(cfg, queue, out_value); + REQUIRE(ec == make_error_code(AdcErrc::no_signal)); +} + +TEST_CASE("request_reading_from_trigger_queue reports no_signal on a missing-capture entry", + "[adc][REQ-ADC-005]") { + AdcEndpoint ep; + AdcAveragingConfig cfg; + cfg.adc_avg_intervals_per_request = 2; + cfg.adc_combine_avg_values = 1; + + std::vector> queue{10u, std::nullopt}; // trigger occurred, no valid capture + uint32_t out_value = 0; + auto ec = ep.request_reading_from_trigger_queue(cfg, queue, out_value); + REQUIRE(ec == make_error_code(AdcErrc::no_signal)); +} + +// ── AdcErrc category sanity ──────────────────────────────────────────────────── + +TEST_CASE("AdcErrc reports a non-empty message in its own category", "[adc][REQ-ADC-006]") { + auto ec = make_error_code(AdcErrc::no_signal); + REQUIRE(ec.category() == adc_category()); + REQUIRE_FALSE(ec.message().empty()); +} diff --git a/tests/test_i2c.cpp b/tests/test_i2c.cpp new file mode 100644 index 0000000..5050d2d --- /dev/null +++ b/tests/test_i2c.cpp @@ -0,0 +1,100 @@ +// fusa:test REQ-I2C-001 +// fusa:test REQ-I2C-002 +// fusa:test REQ-I2C-003 +// fusa:test REQ-I2C-004 +// fusa:test REQ-I2C-005 + +// Tests for rcp/i2c.hpp — the I2C endpoint type (ROADMAP.md milestone 48, +// "Basic Endpoint Types II — I2C, UART, ADC, PWM_OUT, PWM_IN", v2.4.0). + +#include +#include + +using namespace rcp::i2c; + +// ── i2c_mode open item ─────────────────────────────────────────────────────── + +TEST_CASE("i2c_mode_of decodes only the coarse high-speed-requested bit", "[i2c][REQ-I2C-001]") { + REQUIRE(i2c_mode_of(false) == I2cMode::Standard); + REQUIRE(i2c_mode_of(true) == I2cMode::HighSpeed); +} + +// ── Compound-wait arbitrary-bit-sequence match ─────────────────────────────── + +TEST_CASE("compound_wait_matches_bits compares an exact whole-byte-multiple bit length", + "[i2c][REQ-I2C-002]") { + std::vector received{0b10110000, 0b11110000}; + std::vector expected{0b10110000, 0b11110000}; + REQUIRE(compound_wait_matches_bits(received, expected, 16)); +} + +TEST_CASE("compound_wait_matches_bits compares only the top bits of a partial final byte", + "[i2c][REQ-I2C-002]") { + std::vector received{0b10110000, 0b11111111}; // low nibble of byte 1 differs + std::vector expected{0b10110000, 0b11110000}; + REQUIRE(compound_wait_matches_bits(received, expected, 12)); // 8 + top 4 bits of byte 1 +} + +TEST_CASE("compound_wait_matches_bits reports a mismatch within the compared window", + "[i2c][REQ-I2C-002]") { + std::vector received{0b10110000}; + std::vector expected{0b10100000}; + REQUIRE_FALSE(compound_wait_matches_bits(received, expected, 8)); +} + +TEST_CASE("compound_wait_matches_bits returns false for a zero bit_len or short buffers", + "[i2c][REQ-I2C-002]") { + std::vector received{0x01}; + std::vector expected{0x01}; + REQUIRE_FALSE(compound_wait_matches_bits(received, expected, 0)); + REQUIRE_FALSE(compound_wait_matches_bits(received, expected, 16)); // not enough bytes +} + +// ── Controller-only raw byte-stream transfer, address bytes included ──────── + +TEST_CASE("I2cEndpoint::transfer records the raw sent/received byte streams", + "[i2c][REQ-I2C-003]") { + I2cEndpoint ep; + // First byte models the address+R/W bit, per this milestone's "raw byte + // stream including address bytes" scope. + auto ec = ep.transfer({0xA0, 0x10}, {0xFF}); + REQUIRE_FALSE(ec); + REQUIRE(ep.last_sent() == std::vector{0xA0, 0x10}); + REQUIRE(ep.last_received() == std::vector{0xFF}); +} + +TEST_CASE("I2cEndpoint::transfer reports NACK and fires the Nack signal when unacknowledged", + "[i2c][REQ-I2C-004]") { + I2cEndpoint ep; + ep.triggers().enable(i2c_signal_id(I2cSignal::TransferComplete)); + ep.triggers().enable(i2c_signal_id(I2cSignal::Nack)); + + auto ec = ep.transfer({0xA0}, {}, /*acked=*/false); + REQUIRE(ec == make_error_code(I2cErrc::nack)); + + auto drained = ep.triggers().drain(); + REQUIRE(drained.size() == 2); + REQUIRE(drained[0] == i2c_signal_id(I2cSignal::TransferComplete)); + REQUIRE(drained[1] == i2c_signal_id(I2cSignal::Nack)); +} + +TEST_CASE("I2cEndpoint::transfer fires only TransferComplete on a normal acked transfer", + "[i2c][REQ-I2C-004]") { + I2cEndpoint ep; + ep.triggers().enable(i2c_signal_id(I2cSignal::TransferComplete)); + ep.triggers().enable(i2c_signal_id(I2cSignal::Nack)); + + REQUIRE_FALSE(ep.transfer({0xA0}, {0x01})); + + auto drained = ep.triggers().drain(); + REQUIRE(drained.size() == 1); + REQUIRE(drained[0] == i2c_signal_id(I2cSignal::TransferComplete)); +} + +// ── I2cErrc category sanity ─────────────────────────────────────────────────── + +TEST_CASE("I2cErrc reports a non-empty message in its own category", "[i2c][REQ-I2C-005]") { + auto ec = make_error_code(I2cErrc::nack); + REQUIRE(ec.category() == i2c_category()); + REQUIRE_FALSE(ec.message().empty()); +} diff --git a/tests/test_pwm.cpp b/tests/test_pwm.cpp new file mode 100644 index 0000000..119eb73 --- /dev/null +++ b/tests/test_pwm.cpp @@ -0,0 +1,132 @@ +// fusa:test REQ-PWM-001 +// fusa:test REQ-PWM-002 +// fusa:test REQ-PWM-003 +// fusa:test REQ-PWM-004 +// fusa:test REQ-PWM-005 +// fusa:test REQ-PWM-006 +// fusa:test REQ-PWM-007 + +// Tests for rcp/pwm.hpp — the PWM_OUT and PWM_IN endpoint types +// (ROADMAP.md milestone 48, "Basic Endpoint Types II — I2C, UART, ADC, +// PWM_OUT, PWM_IN", v2.4.0). + +#include +#include + +using namespace rcp::pwm; +using rcp::endpoint::WriteSemantics; + +// ── Shared period/active-duration payload shape ────────────────────────────── + +TEST_CASE("PwmValue defaults both fields to zero", "[pwm][REQ-PWM-001]") { + PwmValue v; + REQUIRE(v.period == 0); + REQUIRE(v.active_duration == 0); +} + +// ── PWM_OUT: 8-way write-semantics reuse from GPIO ─────────────────────────── + +TEST_CASE("PwmOutEndpoint::handle_write applies Replace/Or/And/Xor independently to both fields", + "[pwm][REQ-PWM-002]") { + PwmOutEndpoint ep; + PwmValue out; + + REQUIRE_FALSE(ep.handle_write(WriteSemantics::Replace, {100, 40}, out)); + REQUIRE(out.period == 100); + REQUIRE(out.active_duration == 40); + + REQUIRE_FALSE(ep.handle_write(WriteSemantics::Or, {0x0F, 0x01}, out)); + REQUIRE(out.period == (100 | 0x0F)); + REQUIRE(out.active_duration == (40 | 0x01)); +} + +TEST_CASE("PwmOutEndpoint::handle_write applies saturating Add/Subtract to both fields", + "[pwm][REQ-PWM-003]") { + PwmOutEndpoint ep; + PwmValue out; + REQUIRE_FALSE(ep.handle_write(WriteSemantics::Replace, {0xFFFFFFF0, 5}, out)); + + REQUIRE_FALSE(ep.handle_write(WriteSemantics::Add, {0x20, 0}, out)); + REQUIRE(out.period == 0xFFFFFFFF); // saturates, does not wrap + + REQUIRE_FALSE(ep.handle_write(WriteSemantics::Subtract, {0, 10}, out)); + REQUIRE(out.active_duration == 0); // saturates at 0, does not wrap +} + +TEST_CASE("PwmOutEndpoint::handle_write rejects Reserved without changing state", + "[pwm][REQ-PWM-004]") { + PwmOutEndpoint ep; + PwmValue out; + REQUIRE_FALSE(ep.handle_write(WriteSemantics::Replace, {10, 5}, out)); + + auto ec = ep.handle_write(WriteSemantics::Reserved, {999, 999}, out); + REQUIRE(ec); + REQUIRE(ep.read().period == 10); + REQUIRE(ep.read().active_duration == 5); +} + +TEST_CASE("PwmOutEndpoint::handle_write leaves Reconfigure to apply_bitmask_write's own rejection", + "[pwm][REQ-PWM-004]") { + PwmOutEndpoint ep; + PwmValue out; + auto ec = ep.handle_write(WriteSemantics::Reconfigure, {1, 1}, out); + REQUIRE(ec == rcp::endpoint::make_error_code(rcp::endpoint::EndpointErrc::non_combinable_write_semantics)); +} + +// ── PWM_IN: response-only read model + PWM_IN_NO_SIGNAL ───────────────────── + +TEST_CASE("PwmInEndpoint::handle_read reports no_signal before any measurement", "[pwm][REQ-PWM-005]") { + PwmInEndpoint ep; + PwmValue out; + auto ec = ep.handle_read(out); + REQUIRE(ec == make_error_code(PwmErrc::no_signal)); +} + +TEST_CASE("PwmInEndpoint::handle_read returns the last recorded measurement", "[pwm][REQ-PWM-005]") { + PwmInEndpoint ep; + ep.record_measurement({200, 75}); + + PwmValue out; + auto ec = ep.handle_read(out); + REQUIRE_FALSE(ec); + REQUIRE(out.period == 200); + REQUIRE(out.active_duration == 75); +} + +TEST_CASE("PwmInEndpoint::clear_signal re-arms PWM_IN_NO_SIGNAL", "[pwm][REQ-PWM-005]") { + PwmInEndpoint ep; + ep.record_measurement({200, 75}); + ep.clear_signal(); + + PwmValue out; + auto ec = ep.handle_read(out); + REQUIRE(ec == make_error_code(PwmErrc::no_signal)); +} + +// ── Mid-pulse trigger signal (keys ADC sampling cadence) ───────────────────── + +TEST_CASE("PwmInEndpoint::record_measurement fires MidPulse when armed", "[pwm][REQ-PWM-006]") { + PwmInEndpoint ep; + ep.triggers().enable(pwm_in_signal_id(PwmInSignal::MidPulse)); + + ep.record_measurement({100, 50}); + + auto drained = ep.triggers().drain(); + REQUIRE(drained.size() == 1); + REQUIRE(drained[0] == pwm_in_signal_id(PwmInSignal::MidPulse)); +} + +TEST_CASE("PwmInEndpoint::record_measurement fires nothing when MidPulse is not armed", + "[pwm][REQ-PWM-006]") { + PwmInEndpoint ep; + ep.record_measurement({100, 50}); + REQUIRE_FALSE(ep.triggers().has_pending()); +} + +// ── PwmErrc category sanity ─────────────────────────────────────────────────── + +TEST_CASE("PwmErrc reports a non-empty message in its own category", "[pwm][REQ-PWM-007]") { + auto ec = make_error_code(PwmErrc::no_signal); + REQUIRE(ec.category() == pwm_category()); + REQUIRE_FALSE(ec.message().empty()); +} diff --git a/tests/test_uart.cpp b/tests/test_uart.cpp new file mode 100644 index 0000000..20c0e1f --- /dev/null +++ b/tests/test_uart.cpp @@ -0,0 +1,166 @@ +// fusa:test REQ-UART-001 +// fusa:test REQ-UART-002 +// fusa:test REQ-UART-003 +// fusa:test REQ-UART-004 +// fusa:test REQ-UART-005 +// fusa:test REQ-UART-006 +// fusa:test REQ-UART-007 + +// Tests for rcp/uart.hpp — the UART endpoint type (ROADMAP.md milestone 48, +// "Basic Endpoint Types II — I2C, UART, ADC, PWM_OUT, PWM_IN", v2.4.0). + +#include +#include + +using namespace rcp::uart; + +// ── Independent TX/RX queues ───────────────────────────────────────────────── + +TEST_CASE("enqueue_tx / drain_tx are independent of the RX FIFO", "[uart][REQ-UART-001]") { + UartEndpoint ep; + REQUIRE_FALSE(ep.enqueue_tx({0x01, 0x02})); + REQUIRE_FALSE(ep.rx_fill({0xAA, 0xBB})); + + auto tx = ep.drain_tx(); + REQUIRE(tx == std::vector{0x01, 0x02}); + REQUIRE(ep.rx_available() == 2); // rx_fill's bytes are unaffected by draining tx +} + +TEST_CASE("drain_tx clears the TX queue after returning it", "[uart][REQ-UART-001]") { + UartEndpoint ep; + REQUIRE_FALSE(ep.enqueue_tx({0x01})); + REQUIRE(ep.drain_tx().size() == 1); + REQUIRE(ep.drain_tx().empty()); +} + +TEST_CASE("enqueue_tx rejects a push beyond kTxQueueCapacity", "[uart][REQ-UART-001]") { + UartEndpoint ep; + std::vector big(kTxQueueCapacity + 1, 0x00); + auto ec = ep.enqueue_tx(big); + REQUIRE(ec == make_error_code(UartErrc::tx_queue_overflow)); +} + +// ── RX FIFO fill/drain semantics ───────────────────────────────────────────── + +TEST_CASE("rx_fill appends to the RX FIFO and rx_available reflects it", "[uart][REQ-UART-002]") { + UartEndpoint ep; + REQUIRE_FALSE(ep.rx_fill({0x01, 0x02, 0x03})); + REQUIRE(ep.rx_available() == 3); +} + +TEST_CASE("rx_fill rejects a push beyond kRxFifoCapacity", "[uart][REQ-UART-002]") { + UartEndpoint ep; + std::vector big(kRxFifoCapacity + 1, 0x00); + auto ec = ep.rx_fill(big); + REQUIRE(ec == make_error_code(UartErrc::rx_fifo_overflow)); +} + +// ── Read completion: read_size reached vs. uart_timeout elapsed ───────────── + +TEST_CASE("handle_read completes on read_size reached before timeout", "[uart][REQ-UART-003]") { + UartEndpoint ep; + REQUIRE_FALSE(ep.rx_fill({0x01, 0x02, 0x03, 0x04})); + + std::vector out; + bool timed_out = true; + auto ec = ep.handle_read(/*read_size=*/2, /*elapsed_ms=*/1, /*uart_timeout_ms=*/100, out, timed_out); + REQUIRE_FALSE(ec); + REQUIRE(out == std::vector{0x01, 0x02}); + REQUIRE_FALSE(timed_out); + REQUIRE(ep.rx_available() == 2); // remaining bytes stay buffered (drain semantics) +} + +TEST_CASE("handle_read reports timed_out when read_size is not reached and uart_timeout elapsed", + "[uart][REQ-UART-003]") { + UartEndpoint ep; + REQUIRE_FALSE(ep.rx_fill({0x01})); + + std::vector out; + bool timed_out = false; + auto ec = ep.handle_read(/*read_size=*/4, /*elapsed_ms=*/100, /*uart_timeout_ms=*/50, out, timed_out); + REQUIRE_FALSE(ec); + REQUIRE(out == std::vector{0x01}); // whatever was available is still returned + REQUIRE(timed_out); +} + +TEST_CASE("handle_read neither completes nor times out while still within the timeout window", + "[uart][REQ-UART-003]") { + UartEndpoint ep; + REQUIRE_FALSE(ep.rx_fill({0x01})); + + std::vector out; + bool timed_out = true; + auto ec = ep.handle_read(/*read_size=*/4, /*elapsed_ms=*/10, /*uart_timeout_ms=*/50, out, timed_out); + REQUIRE_FALSE(ec); + REQUIRE_FALSE(timed_out); +} + +TEST_CASE("handle_read rejects a read_size beyond kMaxReadSize", "[uart][REQ-UART-003]") { + UartEndpoint ep; + std::vector out; + bool timed_out = false; + auto ec = ep.handle_read(static_cast(kMaxReadSize + 1), 0, 100, out, timed_out); + REQUIRE(ec == make_error_code(UartErrc::read_size_exceeds_bound)); +} + +// ── Payload-less "pure" read requests ──────────────────────────────────────── + +TEST_CASE("handle_pure_read drains everything currently buffered, non-blocking", + "[uart][REQ-UART-004]") { + UartEndpoint ep; + REQUIRE_FALSE(ep.rx_fill({0x01, 0x02, 0x03})); + + auto out = ep.handle_pure_read(); + REQUIRE(out == std::vector{0x01, 0x02, 0x03}); + REQUIRE(ep.rx_available() == 0); +} + +TEST_CASE("handle_pure_read returns empty when the RX FIFO is empty", "[uart][REQ-UART-004]") { + UartEndpoint ep; + REQUIRE(ep.handle_pure_read().empty()); +} + +// ── Sub-octet bit-width padding rules ──────────────────────────────────────── + +TEST_CASE("pack_frame_to_octet masks a value to bits_per_frame low bits", "[uart][REQ-UART-005]") { + uint8_t out = 0xFF; + auto ec = pack_frame_to_octet(0b11111111, 5, out); + REQUIRE_FALSE(ec); + REQUIRE(out == 0b00011111); // low 5 bits kept, high bits zero-padded +} + +TEST_CASE("unpack_frame_bits round-trips pack_frame_to_octet", "[uart][REQ-UART-005]") { + uint8_t packed = 0; + REQUIRE_FALSE(pack_frame_to_octet(0b01011010, 6, packed)); + uint8_t value = 0xFF; + REQUIRE_FALSE(unpack_frame_bits(packed, 6, value)); + REQUIRE(value == (0b01011010 & 0x3F)); +} + +TEST_CASE("pack_frame_to_octet rejects bits_per_frame outside [5,8]", "[uart][REQ-UART-005]") { + uint8_t out = 0; + REQUIRE(pack_frame_to_octet(0x01, 4, out) == make_error_code(UartErrc::bits_per_frame_out_of_range)); + REQUIRE(pack_frame_to_octet(0x01, 9, out) == make_error_code(UartErrc::bits_per_frame_out_of_range)); +} + +TEST_CASE("pack_frame_to_octet with bits_per_frame=8 is a full-octet passthrough", + "[uart][REQ-UART-005]") { + uint8_t out = 0; + REQUIRE_FALSE(pack_frame_to_octet(0xAB, 8, out)); + REQUIRE(out == 0xAB); +} + +// ── Single-AVTPDU accepted-limitation bound ────────────────────────────────── + +TEST_CASE("kMaxReadSize bounds both the RX FIFO and TX queue capacities", "[uart][REQ-UART-006]") { + REQUIRE(kRxFifoCapacity == kMaxReadSize); + REQUIRE(kTxQueueCapacity == kMaxReadSize); +} + +// ── UartErrc category sanity ────────────────────────────────────────────────── + +TEST_CASE("UartErrc reports a non-empty message in its own category", "[uart][REQ-UART-007]") { + auto ec = make_error_code(UartErrc::rx_fifo_overflow); + REQUIRE(ec.category() == uart_category()); + REQUIRE_FALSE(ec.message().empty()); +}