From 02e256425f39480e170870319a9ae60ae848352f Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:45:02 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Table=2030=20Row-2=20evt[2:?= =?UTF-8?q?0]=20validation=20=E2=80=94=20CAN=20(5th=20endpoint=20type)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TC18 section 13.5 Table 33 Row 2's 3-way evt[2:0] classification (Plain/Reserved/ConfigWrite), applied to the CAN controller endpoint (section 13.7.11), following the exact shape already merged for I2C (#114), ADC (#115), PWM_IN (#116), and LIN (#117): - rcp::can::CanEndpoint::handle_request(evt_op, frame) classifies via the shared rcp::endpoint::evt_row2_kind_of before touching any transfer state: Plain (000b) delegates straight to the existing transmit(); Reserved (001b-110b) returns endpoint::EndpointErrc::reserved_evt_row2 without recording anything as transmitted; ConfigWrite (111b) returns the new CanErrc::config_write_not_supported (section 12.7.1 functional-config write is out of scope for this milestone, same as the other four endpoint types). - rcp::mock::Server wires CAN in as its 7th representative endpoint (byte_bus_id 7), with dispatch_can(), wire_error_code_for mappings for every CanErrc value, and REQ-MOCK-001 updated to describe seven endpoints including CAN at byte_bus_id 7 (avoiding the stale-text mistake #116 left for #117 to fix). - .fusa-reqs.json: REQ-CANEP-008/009 and REQ-MOCK-019/020 added, each tc18_master_id independently checked against RELAY's docs/tc18-master-catalog.json (TC18-13.5-001 for Table 33, TC18-12.7.1-001 for the config-write clause — both already verified to describe CAN by name). REQ-CANEP-008 tagged severity=cybersecurity, matching the REQ-ADC-008/REQ-PWM-008/ REQ-LINEP-005 precedent, keeping the cybersecurity-tagged ratio at 17% (69/398, integer division) — unchanged from before this PR. CAN-specific judgment calls, verified directly against TC18.txt: - No remote-frame concept and no TriggerRegistry for CAN (both by design per this header's own pre-existing comments) — neither was added. TC18.txt confirms explicitly: "Sending remote frames is not supported" (section 13.7.11.3). - evt[2:0] classification is verified independent of CAN XL's own FrameFormat sub-field: Figure 40 (section 13.7.11.3) draws the Message Info octet's evt[2:0] bits and the Payload's FrameFormat/CAN-ID/CAN-data fields as distinct, non-overlapping regions of the request — confirmed directly against TC18.txt rather than assumed, to avoid the invented-field-encoding mistake this codebase has previously had to remove for ISELED/MDIO. - No set_can_response()-shaped test hook: CAN's Plain request is a fire-a-frame TX whose outcome is fully determined by the request's own frame contents (already validated deterministically by can::validate_frame), unlike I2C/LIN/ADC/PWM_IN's read-back-shaped requests — there is no external 'response data' for such a hook to script, so none was added (documented in mock.hpp). Mutation-tested: temporarily made the ConfigWrite branch call transmit() instead of returning config_write_not_supported; rebuilt; confirmed 2 test_can + 1 test_mock assertions failed as expected; reverted cleanly (git diff clean, no stray leftovers) and confirmed all tests pass again. cpfusa check --strict: 0 errors (2 pre-existing warnings + 1 info, byte-identical to main before this PR). cpfusa trace: 398/398 (100%) requirements annotated and tested. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 43 +++++++++++++++++++- include/rcp/can.hpp | 93 ++++++++++++++++++++++++++++++++++++++++++- include/rcp/mock.hpp | 95 +++++++++++++++++++++++++++++++++++++++----- tests/test_can.cpp | 84 +++++++++++++++++++++++++++++++++++++++ tests/test_mock.cpp | 87 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 383 insertions(+), 19 deletions(-) diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 27d00c7..f32143f 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -82,8 +82,8 @@ }, { "id": "REQ-MOCK-001", - "title": "Server starts HW_UNCONFIGURED with a representative six-endpoint register map", - "text": "rcp::mock::Server shall construct with rcp::lifecycle::ServerLifecycle in state HwUnconfigured and an rcp::regmap::RegisterMap whose endpoint_count, generic_configs, functional_configs, and ep_id_mapping describe exactly six endpoints: GPIO at endpoint id/byte_bus_id 1, SPI at endpoint id/byte_bus_id 2, I2C at endpoint id/byte_bus_id 3, ADC at endpoint id/byte_bus_id 4, PWM_IN at endpoint id/byte_bus_id 5, and LIN at endpoint id/byte_bus_id 6.", + "title": "Server starts HW_UNCONFIGURED with a representative seven-endpoint register map", + "text": "rcp::mock::Server shall construct with rcp::lifecycle::ServerLifecycle in state HwUnconfigured and an rcp::regmap::RegisterMap whose endpoint_count, generic_configs, functional_configs, and ep_id_mapping describe exactly seven endpoints: GPIO at endpoint id/byte_bus_id 1, SPI at endpoint id/byte_bus_id 2, I2C at endpoint id/byte_bus_id 3, ADC at endpoint id/byte_bus_id 4, PWM_IN at endpoint id/byte_bus_id 5, LIN at endpoint id/byte_bus_id 6, and CAN at endpoint id/byte_bus_id 7.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -232,6 +232,24 @@ "level": "ASIL-B", "asil": "ASIL-B" }, + { + "id": "REQ-MOCK-019", + "title": "CAN dispatch applies Table 33 Row 2 evt[2:0] classification before touching endpoint state", + "text": "rcp::mock::Server::dispatch, for a request addressed to the CAN endpoint while RcpConfigured, shall decode the request's evt[2:0] field via endpoint::evt_row2_kind_of and drive can::CanEndpoint::handle_request with it: a Plain (000b) request shall transmit a can::CanDataFrame built from the request payload and be answered as a WriteResponse with an empty byte_msg_payload, a Reserved (001b-110b) evt shall be rejected with endpoint::EndpointErrc::reserved_evt_row2 (wire error code UNSUPPORTED_CMD) without recording anything as transmitted, and a ConfigWrite (111b) evt shall be rejected with can::CanErrc::config_write_not_supported (wire error code UNSUPPORTED_CMD) rather than crashing or being treated as a plain transmit.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B", + "tc18": "§13.5 Table 33 (\"ADC, PWM_IN, I²C, LIN, CAN, UART, ISELED, MDIO: 000b to 110b reserved – request to be rejected with error code = UNSUPPORTED_CMD\" / \"111b: The byte_msg_payload is not presented to the interface but used to change the configuration of the endpoint (see 12.7.1)\"), TC18.txt L4085-4092; corroborating worked example resolving the evt=000b ambiguity in Table 33's own favor: §13.7.9.3 Figure 34 (\"RC Client sends a standard read request\", ADC endpoint, evt field all-zero), TC18.txt L5590-5607", + "tc18_master_id": "TC18-13.5-001" + }, + { + "id": "REQ-MOCK-020", + "title": "Operational CAN requests are rejected before RCP_CONFIGURED, same gating as GPIO/SPI/I2C/ADC/PWM_IN/LIN", + "text": "rcp::mock::Server::dispatch, for a request addressed to the CAN endpoint while the server's lifecycle state is not RcpConfigured, shall return regmap::RegMapErrc::request_rejected and an ErrorResponse without touching the endpoint's live state, the same operational-request gating REQ-MOCK-006/REQ-MOCK-012/REQ-MOCK-014/REQ-MOCK-016/REQ-MOCK-018 already apply to GPIO/SPI/I2C/ADC/PWM_IN/LIN.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, { "id": "REQ-UDP-001", "title": "Frame round-trips an NTSCF-framed ACF_ABB request", @@ -3264,6 +3282,27 @@ "level": "ASIL-B", "asil": "ASIL-B" }, + { + "id": "REQ-CANEP-008", + "title": "CanEndpoint::handle_request applies Table 33 Row 2 evt[2:0] classification before transmitting", + "text": "rcp::can::CanEndpoint::handle_request shall classify its evt_op argument via rcp::endpoint::evt_row2_kind_of (masking to the low 3 bits first) and, for EvtRow2Kind::Plain, shall delegate to transmit() with frame unchanged; for EvtRow2Kind::Reserved, shall return endpoint::EndpointErrc::reserved_evt_row2 without recording anything as transmitted (last_transmitted() left untouched).", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B", + "tc18": "§13.5 Table 33 (\"ADC, PWM_IN, I²C, LIN, CAN, UART, ISELED, MDIO: 000b to 110b reserved – request to be rejected with error code = UNSUPPORTED_CMD\"), TC18.txt L4085-4092; corroborating worked example resolving the evt=000b ambiguity in Table 33's own favor: §13.7.9.3 Figure 34 (\"RC Client sends a standard read request\", ADC endpoint, evt field all-zero), TC18.txt L5590-5607", + "tc18_master_id": "TC18-13.5-001", + "severity": "cybersecurity" + }, + { + "id": "REQ-CANEP-009", + "title": "CanEndpoint::handle_request reports config-write (evt[2:0]==111b) as not yet supported", + "text": "rcp::can::CanEndpoint::handle_request, for EvtRow2Kind::ConfigWrite (evt[2:0]==111b), shall return CanErrc::config_write_not_supported without recording anything as transmitted and without crashing, rather than silently accepting the request as an ordinary transmit or silently doing nothing; full §12.7.1 configuration-write handling is deliberately deferred to a follow-up milestone, the same scope decision I2C's I2cErrc::config_write_not_supported, ADC's AdcErrc::config_write_not_supported, PWM_IN's PwmErrc::config_write_not_supported, and LIN's LinErrc::config_write_not_supported already made. CanErrc::config_write_not_supported shall report a non-empty diagnostic message in its own error_category.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B", + "tc18": "§12.7.1 (\"This can be done by setting evt[2:0] = 111b in request as described above.\"; Figure 19 – \"Configuration request\"; \"The data of the byte_msg_payload from a write request is written into the section of the EP that is addressed by the byte_bus_id.\"), TC18.txt L2844-2865", + "tc18_master_id": "TC18-12.7.1-001" + }, { "id": "REQ-ISELED-001", "title": "ISELED's ep_type id is 0x0C", diff --git a/include/rcp/can.hpp b/include/rcp/can.hpp index 51eb741..66dfdd4 100644 --- a/include/rcp/can.hpp +++ b/include/rcp/can.hpp @@ -5,6 +5,8 @@ // fusa:req REQ-CANEP-005 // fusa:req REQ-CANEP-006 // fusa:req REQ-CANEP-007 +// fusa:req REQ-CANEP-008 +// fusa:req REQ-CANEP-009 // CAN controller endpoint (ep_type 0x0B) — the OPEN Alliance TC18 Remote // Control Protocol Specification v0.5.1_RC's Classical/FD/XL frame-format @@ -46,15 +48,31 @@ // should first confirm the extraction actually defines one for this // endpoint type, not assume it was merely forgotten here. // +// Table 30/33 Row 2 evt[2:0] validation (post-v2.7.0, fifth endpoint type +// after I2C, ADC, PWM_IN, and LIN): CanEndpoint::handle_request is this +// header's own wiring of rcp::endpoint::evt_row2_kind_of — the shared +// 3-way evt[2:0] classifier for Table 33's {ADC, PWM_IN, I2C, LIN, CAN, +// UART, ISELED, MDIO} row — into CAN's request decode, following the exact +// shape rcp/i2c.hpp's I2cEndpoint::handle_request, rcp/adc.hpp's +// AdcEndpoint::handle_request, rcp/pwm.hpp's PwmInEndpoint::handle_request, +// and rcp/lin.hpp's LinEndpoint::handle_request established. See +// handle_request's own doc comment for why this is a completely separate +// concern from Figure 40's FrameFormat sub-field and from point 1 above's +// "no remote-frame concept" — evt[2:0] classification must not be confused +// with either. +// // 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 bit-timing // register field layout, the accepted single-AVTPDU XL payload bound, and // the acceptance-filter matching rule chosen in this file are this // implementation's own, same as the equivalent disclaimers in rcp/avtp.hpp, -// rcp/regmap.hpp, rcp/endpoint.hpp, and rcp/uart.hpp. +// rcp/regmap.hpp, rcp/endpoint.hpp, rcp/uart.hpp, rcp/i2c.hpp, rcp/adc.hpp, +// rcp/pwm.hpp, and rcp/lin.hpp. #pragma once +#include + #include #include #include @@ -118,6 +136,14 @@ enum class CanErrc : int { payload_exceeds_format_limit = 2, // payload length exceeds the selected FrameFormat's own max xl_payload_exceeds_single_avtpdu_bound = 3, // within the spec's 2054-byte ceiling, but beyond this // implementation's accepted single-AVTPDU bound (see header comment) + // evt_row2_kind_of classified the request as ConfigWrite (evt[2:0] == + // 111b, §12.7.1). This milestone's follow-up deliberately does not + // implement the configuration-write shape (relative EP_functional- + // config start address + configuration data) — see handle_request's + // own comment. Reported explicitly rather than silently accepted as a + // plain transmit or silently ignored, same as I2C's, ADC's, PWM_IN's, + // and LIN's own config_write_not_supported variants. + config_write_not_supported = 4, }; inline const std::error_category& can_category() noexcept { @@ -131,6 +157,8 @@ inline const std::error_category& can_category() noexcept { return "rcp/can: payload exceeds the selected FrameFormat's payload limit"; case CanErrc::xl_payload_exceeds_single_avtpdu_bound: return "rcp/can: CAN XL payload requires multi-AVTPDU fragmentation, not supported this cycle"; + case CanErrc::config_write_not_supported: + return "rcp/can: evt[2:0]=111b configuration-write requests are not yet implemented"; default: return "rcp/can: unknown error"; } @@ -255,6 +283,69 @@ class CanEndpoint { return {}; } + // handle_request is CAN's request-decode entry point — the piece this + // header previously had none of, mirroring rcp::i2c::I2cEndpoint:: + // handle_request's shape (this repo's fifth Table 33 Row 2 endpoint + // type after I2C, ADC, PWM_IN, and LIN). It classifies the incoming + // request's evt[2:0] field via rcp::endpoint::evt_row2_kind_of before + // doing anything else, so a Reserved value can never reach transmit() + // and be misread as an ordinary transmit request, and a ConfigWrite + // value can never be silently accepted or silently dropped: + // - Plain (evt[2:0] == 000b): delegates straight to transmit() with + // `frame` unchanged — CAN's existing data-frame transmit model + // (extraction §13.7.11.3) already IS this row's correct "plain + // request" behavior; evt[2:0] carries no combinable value or + // channel selector for this row. + // - Reserved (evt[2:0] in 001b-110b): returns + // endpoint::EndpointErrc::reserved_evt_row2 without touching any + // endpoint state (last_tx_ is left exactly as it was — `frame` is + // simply discarded) — TC18 requires this be rejected with error + // code UNSUPPORTED_CMD. + // - ConfigWrite (evt[2:0] == 111b): §12.7.1's configuration-write + // shape targets the CAN EP's own functional-config block (Table 56 + // — bit-timing registers, acceptance/receive filters, clock + // divider, ...), not a frame transmission at all. Full handling is + // deliberately out of scope for this milestone (nontrivial — it + // needs EP_functional-config wiring this header does not yet have, + // the same gap I2C's, ADC's, PWM_IN's, and LIN's own handle_request + // comments defer for the identical reason); this returns + // CanErrc::config_write_not_supported rather than crashing, + // silently accepting the request as a transmit, or silently doing + // nothing. + // + // NOT to be confused with Figure 40's own "FrameFormat" sub-field + // (CBFF/CEFF/FBFF/FEFF/XL-classic/XL-new, Table 57), carried inside the + // request's byte_msg_payload alongside the CAN ID and CAN data: that + // field selects the CAN frame's own on-wire format and lives entirely + // in the payload, verified directly against TC18.txt's own Figure 40 + // layout (§13.7.11.3), where the Message Info octet's evt[2:0] bits and + // the Payload's FrameFormat/CAN-ID/CAN-data fields are drawn as + // distinct, non-overlapping regions of the request. Reading evt[2:0] as + // if it also selected (or combined with) FrameFormat, or as if it + // selected a remote-frame vs data-frame request — this endpoint type + // has no remote-frame concept at all (see header comment point 1; + // TC18's own text states outright "Sending remote frames is not + // supported") — would be exactly the kind of invented, non-spec- + // derived field encoding this codebase has had to remove elsewhere once + // discovered (e.g. rcp/iseled.hpp's and rcp/mdio.hpp's own header + // comments on previously invented, non-spec-derived field encodings + // later corrected, and the identical class of mistake rcp/lin.hpp's own + // handle_request comment calls out against confusing evt[2:0] with + // §13.7.10.1's separate compound-wait match-condition text). + // handle_request below calls the same shared evt_row2_kind_of every + // other Row 2 endpoint type uses and invents nothing of its own. + std::error_code handle_request(uint8_t evt_op, CanDataFrame frame) { + switch (endpoint::evt_row2_kind_of(evt_op)) { + case endpoint::EvtRow2Kind::Plain: + return transmit(std::move(frame)); + case endpoint::EvtRow2Kind::Reserved: + return endpoint::make_error_code(endpoint::EndpointErrc::reserved_evt_row2); + case endpoint::EvtRow2Kind::ConfigWrite: + return make_error_code(CanErrc::config_write_not_supported); + } + return endpoint::make_error_code(endpoint::EndpointErrc::reserved_evt_row2); // unreachable + } + // receive models one inbound data frame arriving off the bus. Returns // false (and does not record the frame) when the frame's format-scoped // filter bank is non-empty and none of its entries match — true diff --git a/include/rcp/mock.hpp b/include/rcp/mock.hpp index 502daab..f1e1cd2 100644 --- a/include/rcp/mock.hpp +++ b/include/rcp/mock.hpp @@ -16,6 +16,8 @@ // fusa:req REQ-MOCK-016 // fusa:req REQ-MOCK-017 // fusa:req REQ-MOCK-018 +// fusa:req REQ-MOCK-019 +// fusa:req REQ-MOCK-020 // In-process RC Server simulator — a small, representative OPEN Alliance // TC18 Remote Control Protocol Specification v0.5.1_RC server built @@ -41,12 +43,13 @@ // mock::Server holds a real rcp::lifecycle::ServerLifecycle (v2.1.0), a // real rcp::regmap::RegisterMap plus rcp::regmap::Ep0 (v2.1.0, including // EP0 whole-map-read and root-client write semantics), and one instance -// each of six fully-built endpoint types — rcp::gpio::GpioEndpoint and +// each of seven fully-built endpoint types — rcp::gpio::GpioEndpoint and // rcp::spi::SpiEndpoint (both v2.3.0), plus rcp::i2c::I2cEndpoint, -// rcp::adc::AdcEndpoint, rcp::pwm::PwmInEndpoint, and rcp::lin::LinEndpoint -// (v2.4.0/post-v2.7.0, wired in by the Table 30/33 Row 2 evt[2:0] -// validation pilot and its ADC, PWM_IN, and LIN follow-ups, in that order) -// — as its representative endpoint set. dispatch() below is the single +// rcp::adc::AdcEndpoint, rcp::pwm::PwmInEndpoint, rcp::lin::LinEndpoint, and +// rcp::can::CanEndpoint (v2.4.0/post-v2.7.0, wired in by the Table 30/33 +// Row 2 evt[2:0] validation pilot and its ADC, PWM_IN, LIN, and CAN +// follow-ups, in that order) — as its representative endpoint set. +// dispatch() below is the single // request/response entry point a test drives, decoding the standard // request kind's evt[2:0]/op fields (rcp/acf.hpp, v2.0.0) the same way a // real request-dispatch loop would. Conditional request kinds (v2.5.0), @@ -67,19 +70,20 @@ // in an internal structured extraction of the specification named above; // no text from that document is reproduced here. The concrete endpoint // numbering (GPIO at endpoint id / byte_bus_id 1, SPI at 2, I2C at 3, ADC at -// 4, PWM_IN at 5, LIN at 6), access-policy choice for operational requests -// (gated on lifecycle state only, not per-endpoint ownership — see +// 4, PWM_IN at 5, LIN at 6, CAN at 7), access-policy choice for operational +// requests (gated on lifecycle state only, not per-endpoint ownership — see // dispatch()'s own comment), and EP0 partial-read encoding chosen in this // file are this implementation's own, purely for the purposes of being a // usable in-process simulator — full bit-for-bit conformance against other // TC18 implementations is not claimed, same as the equivalent disclaimers // in rcp/regmap.hpp, rcp/lifecycle.hpp, rcp/gpio.hpp, rcp/spi.hpp, -// rcp/i2c.hpp, rcp/adc.hpp, rcp/pwm.hpp, and rcp/lin.hpp. +// rcp/i2c.hpp, rcp/adc.hpp, rcp/pwm.hpp, rcp/lin.hpp, and rcp/can.hpp. #pragma once #include #include #include +#include #include #include #include @@ -116,12 +120,14 @@ constexpr EndpointId kI2cEndpointId = 3; constexpr EndpointId kAdcEndpointId = 4; constexpr EndpointId kPwmInEndpointId = 5; constexpr EndpointId kLinEndpointId = 6; +constexpr EndpointId kCanEndpointId = 7; constexpr avtp::ByteBusId kGpioByteBusId = static_cast(kGpioEndpointId); constexpr avtp::ByteBusId kSpiByteBusId = static_cast(kSpiEndpointId); constexpr avtp::ByteBusId kI2cByteBusId = static_cast(kI2cEndpointId); constexpr avtp::ByteBusId kAdcByteBusId = static_cast(kAdcEndpointId); constexpr avtp::ByteBusId kPwmInByteBusId = static_cast(kPwmInEndpointId); constexpr avtp::ByteBusId kLinByteBusId = static_cast(kLinEndpointId); +constexpr avtp::ByteBusId kCanByteBusId = static_cast(kCanEndpointId); // A discovery-shaped EP0 read only ever answers with the register map's // magic number below — see this header's own scope note above. @@ -164,6 +170,14 @@ inline acf::WireErrorCode wire_error_code_for(const std::error_code& ec) noexcep return acf::WireErrorCode::UnsupportedCmd; // evt[2:0]==111b config-write, not yet implemented by this mock if (ec == make_error_code(lin::LinErrc::no_response)) return acf::WireErrorCode::EpError; // internal no-response condition, not a TC18-defined LIN error code — mirrors i2c::I2cErrc::nack's mapping above + if (ec == make_error_code(can::CanErrc::config_write_not_supported)) + return acf::WireErrorCode::UnsupportedCmd; // evt[2:0]==111b config-write, not yet implemented by this mock + if (ec == make_error_code(can::CanErrc::identifier_out_of_range)) + return acf::WireErrorCode::InvalidParameter; // CanIdentifier::value exceeds its 11-/29-bit range + if (ec == make_error_code(can::CanErrc::payload_exceeds_format_limit)) + return acf::WireErrorCode::InvalidParameter; // frame payload exceeds the selected FrameFormat's own ceiling + if (ec == make_error_code(can::CanErrc::xl_payload_exceeds_single_avtpdu_bound)) + return acf::WireErrorCode::EpError; // internal single-AVTPDU capability bound, not a TC18-defined CAN error code — mirrors i2c::I2cErrc::nack's mapping above return acf::WireErrorCode::UnsupportedCmd; } @@ -196,6 +210,7 @@ class Server final { adc::AdcEndpoint& adc() noexcept { return adc_; } pwm::PwmInEndpoint& pwm_in() noexcept { return pwm_in_; } lin::LinEndpoint& lin() noexcept { return lin_; } + can::CanEndpoint& can() noexcept { return can_; } // set_spi_poci scripts the bytes a subsequent dispatch()/transfer() // call on `channel` reads back as POCI-in data. A real SPI peripheral's @@ -264,6 +279,22 @@ class Server final { lin_responded_ = responded; } + // No set_can_response()-shaped hook here: deliberately, not an + // oversight. set_spi_poci/set_i2c_response/set_adc_response/ + // set_pwm_in_response/set_lin_response above all script data this mock + // reads back into a *response* payload for the next dispatched request. + // CAN's request-side operation (extraction §13.7.11.3) is transmit() — + // a fire-a-frame TX call whose outcome is entirely determined by the + // request's own frame contents (identifier range + FrameFormat payload + // ceiling, both already validated deterministically by + // can::validate_frame), not by any external "current bus state" a test + // would need to inject. A successful Plain CAN request has no response + // *data* to script in the first place (see dispatch_can's own + // comment): out_resp_payload stays empty, unlike every other + // dispatch_*() above. can() above already exposes CanEndpoint directly + // for any test that wants to inspect last_transmitted()/ + // last_received() or drive receive()/acceptance filters itself. + // advance_to_rcp_configured is a convenience for tests/simulators that // don't care about exercising ServerLifecycle's intermediate // plausibility-check gating themselves and just want a fully live @@ -303,6 +334,7 @@ class Server final { if (req.byte_bus_id == kAdcByteBusId) return dispatch_adc(req, req_payload, out_resp, out_resp_payload); if (req.byte_bus_id == kPwmInByteBusId) return dispatch_pwm_in(req, req_payload, out_resp, out_resp_payload); if (req.byte_bus_id == kLinByteBusId) return dispatch_lin(req, req_payload, out_resp, out_resp_payload); + if (req.byte_bus_id == kCanByteBusId) return dispatch_can(req, req_payload, out_resp, out_resp_payload); return set_error_response(req, make_error_code(regmap::RegMapErrc::invalid_parameter), out_resp, out_resp_payload); @@ -323,9 +355,9 @@ class Server final { static regmap::RegisterMap make_initial_register_map() { regmap::RegisterMap regs; - regs.endpoint_count = 6; - regs.generic_configs.resize(6); - regs.functional_configs.resize(6); + regs.endpoint_count = 7; + regs.generic_configs.resize(7); + regs.functional_configs.resize(7); regs.ep_id_mapping = { {kGpioEndpointId, kGpioByteBusId}, {kSpiEndpointId, kSpiByteBusId}, @@ -333,6 +365,7 @@ class Server final { {kAdcEndpointId, kAdcByteBusId}, {kPwmInEndpointId, kPwmInByteBusId}, {kLinEndpointId, kLinByteBusId}, + {kCanEndpointId, kCanByteBusId}, }; return regs; } @@ -526,6 +559,45 @@ class Server final { return {}; } + // CAN, unlike I2C/LIN, is a fire-a-frame TX operation with no + // synchronous read-back — this dispatch path drives one + // CanEndpoint::handle_request per request, same Table 33 Row 2 + // Plain/Reserved/ConfigWrite classification (rcp::endpoint:: + // evt_row2_kind_of) every other dispatch_*() above already applies, + // which handle_request checks before ever touching `payload` as frame + // data — see its own doc comment for why a Reserved or ConfigWrite evt + // must never reach transmit(). Figure 40's own wire layout for a CAN + // request's byte_msg_payload (FrameFormat + CAN ID + CAN data, + // §13.7.11.3) is not decoded here — no byte-level CAN wire codec exists + // anywhere in this codebase yet (see rcp/can.hpp's own header comment + // on what it does and does not lay out); this dispatch path's own + // simplification, scoped identically to every other "full bit-for-bit + // conformance ... not claimed" disclaimer in this file, is to treat + // `payload` directly as the frame's data bytes, addressed with a fixed + // standard identifier (CanIdentifier{0, false}) and + // FrameFormat::Classical — enough to exercise the evt[2:0] + // classification end-to-end without inventing a real wire decode. A + // successful Plain request has no response data to send back (see + // set_can_response's own comment above for why there is no scripting + // hook), so out_resp_payload stays empty and the response is a plain + // Acknowledge/WriteResponse, mirroring dispatch_gpio's write-response + // shape rather than dispatch_i2c/dispatch_lin's read-response shape. + std::error_code dispatch_can(const acf::AcfMessageInfo& req, const std::vector& payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload) noexcept { + if (!operational_requests_allowed()) { + return set_error_response(req, make_error_code(regmap::RegMapErrc::request_rejected), + out_resp, out_resp_payload); + } + can::CanDataFrame frame; + frame.data = payload; + auto ec = can_.handle_request(req.evt_op, frame); + if (ec) return set_error_response(req, ec, out_resp, out_resp_payload); + out_resp = acf::make_response(req, req.evt_ack ? acf::ResponseKind::Acknowledge + : acf::ResponseKind::WriteResponse); + return {}; + } + lifecycle::ServerLifecycle lifecycle_; regmap::RegisterMap regs_; regmap::Ep0 ep0_; @@ -535,6 +607,7 @@ class Server final { adc::AdcEndpoint adc_; pwm::PwmInEndpoint pwm_in_; lin::LinEndpoint lin_; + can::CanEndpoint can_; std::array, spi::kMaxChannels> spi_poci_{}; std::vector i2c_response_{}; bool i2c_acked_ = true; diff --git a/tests/test_can.cpp b/tests/test_can.cpp index 0d65f06..82d2a21 100644 --- a/tests/test_can.cpp +++ b/tests/test_can.cpp @@ -5,6 +5,8 @@ // fusa:test REQ-CANEP-005 // fusa:test REQ-CANEP-006 // fusa:test REQ-CANEP-007 +// fusa:test REQ-CANEP-008 +// fusa:test REQ-CANEP-009 // Tests for rcp/can.hpp — the CAN controller endpoint type (ROADMAP.md // milestone 51, "Remaining Endpoint Types — LIN, CAN (incl. CAN XL), @@ -176,6 +178,81 @@ TEST_CASE("CanEndpoint exposes no TriggerRegistry accessor", "[can][REQ-CANEP-00 // No ep.triggers() call exists to make here — that is the point. } +// ── Table 33 Row 2 evt[2:0] validation (handle_request) ───────────────────── + +TEST_CASE("CanEndpoint::handle_request delegates a Plain (evt[2:0]==000b) request to transmit()", + "[can][REQ-CANEP-008]") { + CanEndpoint ep; + CanDataFrame f; + f.id.value = 0x123; + f.format = FrameFormat::Classical; + f.data = {0xDE, 0xAD}; + + auto ec = ep.handle_request(/*evt_op=*/0, f); + REQUIRE_FALSE(ec); + REQUIRE(ep.last_transmitted().id.value == 0x123); + REQUIRE(ep.last_transmitted().data == std::vector{0xDE, 0xAD}); +} + +TEST_CASE("CanEndpoint::handle_request rejects every reserved evt[2:0] value (001b-110b) without " + "touching transmit state", + "[can][REQ-CANEP-008]") { + for (uint8_t evt_op = 1; evt_op <= 6; ++evt_op) { + CanEndpoint ep; + CanDataFrame f; + f.id.value = 0x123; + f.data = {0xAA}; + auto ec = ep.handle_request(evt_op, f); + REQUIRE(ec == rcp::endpoint::make_error_code(rcp::endpoint::EndpointErrc::reserved_evt_row2)); + // A rejected reserved evt must not record anything as transmitted — + // last_transmitted() stays at its default-constructed value. + REQUIRE(ep.last_transmitted().id.value == 0); + REQUIRE(ep.last_transmitted().data.empty()); + } +} + +TEST_CASE("CanEndpoint::handle_request reports config_write_not_supported for evt[2:0]==111b " + "without crashing or touching transmit state", + "[can][REQ-CANEP-009]") { + CanEndpoint ep; + CanDataFrame f; + f.id.value = 0x123; + f.data = {0x00, 0xAB}; + auto ec = ep.handle_request(/*evt_op=*/7, f); + REQUIRE(ec == make_error_code(CanErrc::config_write_not_supported)); + REQUIRE(ep.last_transmitted().id.value == 0); + REQUIRE(ep.last_transmitted().data.empty()); +} + +TEST_CASE("CanEndpoint::handle_request masks evt_op down to 3 bits before classifying", + "[can][REQ-CANEP-008]") { + CanEndpoint ep; + CanDataFrame f; + f.id.value = 0x123; + REQUIRE_FALSE(ep.handle_request(/*evt_op=*/0xF8, f)); // low 3 bits 000 -> Plain + auto ec = ep.handle_request(/*evt_op=*/0xF9, f); // low 3 bits 001 -> Reserved + REQUIRE(ec == rcp::endpoint::make_error_code(rcp::endpoint::EndpointErrc::reserved_evt_row2)); +} + +TEST_CASE("CanEndpoint::handle_request Reserved/ConfigWrite classification is independent of " + "FrameFormat/CAN-ID — evt[2:0] carries no frame-format or remote-frame selector", + "[can][REQ-CANEP-008]") { + // Guards against confusing Table 33's evt[2:0] classification with + // Figure 40's separate FrameFormat payload sub-field (see + // handle_request's own header comment): a Reserved/ConfigWrite evt is + // rejected identically no matter which FrameFormat the frame itself + // carries. + for (auto fmt : {FrameFormat::Classical, FrameFormat::Fd, FrameFormat::Xl}) { + CanEndpoint ep; + CanDataFrame f; + f.id.value = 0x123; + f.format = fmt; + REQUIRE(ep.handle_request(/*evt_op=*/3, f) == + rcp::endpoint::make_error_code(rcp::endpoint::EndpointErrc::reserved_evt_row2)); + REQUIRE(ep.handle_request(/*evt_op=*/7, f) == make_error_code(CanErrc::config_write_not_supported)); + } +} + // ── CanErrc category sanity ─────────────────────────────────────────────────── TEST_CASE("CanErrc reports a non-empty message in its own category", "[can][REQ-CANEP-007]") { @@ -183,3 +260,10 @@ TEST_CASE("CanErrc reports a non-empty message in its own category", "[can][REQ- REQUIRE(ec.category() == can_category()); REQUIRE_FALSE(ec.message().empty()); } + +TEST_CASE("CanErrc::config_write_not_supported reports a non-empty message in its own category", + "[can][REQ-CANEP-009]") { + auto ec = make_error_code(CanErrc::config_write_not_supported); + REQUIRE(ec.category() == can_category()); + REQUIRE_FALSE(ec.message().empty()); +} diff --git a/tests/test_mock.cpp b/tests/test_mock.cpp index 7ca03df..c0771bd 100644 --- a/tests/test_mock.cpp +++ b/tests/test_mock.cpp @@ -16,6 +16,8 @@ // fusa:test REQ-MOCK-016 // fusa:test REQ-MOCK-017 // fusa:test REQ-MOCK-018 +// fusa:test REQ-MOCK-019 +// fusa:test REQ-MOCK-020 // Tests for rcp/mock.hpp — the in-process RC Server simulator (ROADMAP.md // milestone 56, "Test & Simulation Harness Rebuild", v2.12.0). See @@ -42,14 +44,14 @@ acf::AcfMessageInfo standard_request(avtp::ByteBusId bus_id, bool write, uint8_t // ── Construction / register map ────────────────────────────────────────────── -TEST_CASE("Server starts HW_UNCONFIGURED with a six-endpoint register map", +TEST_CASE("Server starts HW_UNCONFIGURED with a seven-endpoint register map", "[mock][REQ-MOCK-001]") { mock::Server server; REQUIRE(server.lifecycle().state() == lifecycle::ServerState::HwUnconfigured); - REQUIRE(server.registers().endpoint_count == 6); - REQUIRE(server.registers().generic_configs.size() == 6); - REQUIRE(server.registers().functional_configs.size() == 6); - REQUIRE(server.registers().ep_id_mapping.size() == 6); + REQUIRE(server.registers().endpoint_count == 7); + REQUIRE(server.registers().generic_configs.size() == 7); + REQUIRE(server.registers().functional_configs.size() == 7); + REQUIRE(server.registers().ep_id_mapping.size() == 7); REQUIRE(server.registers().ep_id_mapping[0].ep_id == mock::kGpioEndpointId); REQUIRE(server.registers().ep_id_mapping[0].byte_bus_id == mock::kGpioByteBusId); REQUIRE(server.registers().ep_id_mapping[1].ep_id == mock::kSpiEndpointId); @@ -62,6 +64,8 @@ TEST_CASE("Server starts HW_UNCONFIGURED with a six-endpoint register map", REQUIRE(server.registers().ep_id_mapping[4].byte_bus_id == mock::kPwmInByteBusId); REQUIRE(server.registers().ep_id_mapping[5].ep_id == mock::kLinEndpointId); REQUIRE(server.registers().ep_id_mapping[5].byte_bus_id == mock::kLinByteBusId); + REQUIRE(server.registers().ep_id_mapping[6].ep_id == mock::kCanEndpointId); + REQUIRE(server.registers().ep_id_mapping[6].byte_bus_id == mock::kCanByteBusId); } TEST_CASE("advance_to_rcp_configured drives the lifecycle straight to RCP_CONFIGURED", @@ -551,6 +555,79 @@ TEST_CASE("LIN request is rejected before RCP_CONFIGURED, same operational gatin REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); } +// ── CAN ─────────────────────────────────────────────────────────────────────── +// Table 30/33 Row 2 evt[2:0] validation, fifth endpoint type after I2C, +// ADC, PWM_IN, and LIN: CanEndpoint wired into dispatch() at byte_bus_id 7. +// Unlike I2C/LIN, CAN's Plain request is a fire-a-frame TX with no +// read-back data — see dispatch_can's and set_can_response's own comments +// in rcp/mock.hpp for why there is no set_can_response() to script and why +// a successful Plain request answers WriteResponse with an empty payload. + +TEST_CASE("CAN plain request (evt[2:0]==000b) transmits the request payload as frame data and " + "answers WriteResponse with an empty payload", + "[mock][REQ-MOCK-019]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + auto req = standard_request(mock::kCanByteBusId, /*write=*/true, /*evt_op=*/0); + std::vector data{0xDE, 0xAD, 0xBE, 0xEF}; + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(server.dispatch(0, req, data, resp, resp_payload)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::WriteResponse); + REQUIRE(resp_payload.empty()); + REQUIRE(server.can().last_transmitted().data == data); +} + +TEST_CASE("CAN request with a reserved evt[2:0] (001b-110b) is rejected with wire error code " + "UNSUPPORTED_CMD and does not touch endpoint state", + "[mock][REQ-MOCK-019]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + for (uint8_t evt_op = 1; evt_op <= 6; ++evt_op) { + auto req = standard_request(mock::kCanByteBusId, /*write=*/true, evt_op); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, {0xAA}, resp, resp_payload); + REQUIRE(ec == endpoint::make_error_code(endpoint::EndpointErrc::reserved_evt_row2)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); + REQUIRE(resp_payload == + std::vector{static_cast(acf::WireErrorCode::UnsupportedCmd)}); + REQUIRE(server.can().last_transmitted().data.empty()); + } +} + +TEST_CASE("CAN request with evt[2:0]==111b (config-write) is rejected with wire error code " + "UNSUPPORTED_CMD rather than crashing or being treated as a plain transmit", + "[mock][REQ-MOCK-019]") { + mock::Server server; + REQUIRE_FALSE(server.advance_to_rcp_configured()); + + auto req = standard_request(mock::kCanByteBusId, /*write=*/true, /*evt_op=*/7); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, {0x00, 0xAB}, resp, resp_payload); + REQUIRE(ec == can::make_error_code(can::CanErrc::config_write_not_supported)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); + REQUIRE(resp_payload == + std::vector{static_cast(acf::WireErrorCode::UnsupportedCmd)}); + REQUIRE(server.can().last_transmitted().data.empty()); +} + +TEST_CASE("CAN request is rejected before RCP_CONFIGURED, same operational gating as GPIO/SPI/I2C/ADC/PWM_IN/LIN", + "[mock][REQ-MOCK-020]") { + mock::Server server; + REQUIRE(server.lifecycle().state() == lifecycle::ServerState::HwUnconfigured); + + auto req = standard_request(mock::kCanByteBusId, /*write=*/true, /*evt_op=*/0); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = server.dispatch(0, req, {0xAA}, resp, resp_payload); + REQUIRE(ec == regmap::make_error_code(regmap::RegMapErrc::request_rejected)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ErrorResponse); +} + // ── Unmapped endpoints ──────────────────────────────────────────────────────── TEST_CASE("dispatch to an unmapped byte_bus_id reports invalid_parameter",