From 052b5c50504112a32521b30bd746687055b4bacb Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:53:52 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.16.0=20=E2=80=94=20C=20ABI=20&=20CLI?= =?UTF-8?q?=20Rebuild=20(=C2=A7Phase=2015=20milestone=2060)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds capi.h/capi_impl.hpp from scratch against the server+endpoint request model established through v2.15.0, per the Satellite Package Disposition table's REPLACE entry for this pair. rcp_zone_t/rcp_command_t/ rcp_response_t/rcp_priority_t are discarded outright; addressing moves to a caller-chosen rcp_stream_key_t + rcp_byte_bus_id_t, rcp_acf_info_t mirrors rcp::acf::AcfMessageInfo one-for-one, and an rcp_ctrl_h now binds one caller-supplied rcp_request_fn_t callback (a C-linkage RequestFn, the same client-side send-equivalent call shape rcp/record.hpp's and rcp/observe.hpp's own RequestFn standardize on) rather than wrapping rcp::legacy_mock::Controller directly — so the callback can be backed by rcp::mock::Server::dispatch, rcp::udp::Client::request, or real hardware without this header depending on any of them. The registry becomes a fixed 16-entry table (no heap allocation, closing a gap the old std::unordered_map-backed registry left in its own "no heap" claim), and rcp_response_t gains a caller-supplied payload_cap so a response payload is actually written into caller-owned memory instead of the old stub's unconditional payload=nullptr. cli.hpp (and cli/main.cpp, unchanged) is adapted in place: version/ capabilities/status carry forward unchanged in shape; send's protocol- flags form moves from --zone/--type to --server/--endpoint/--op[/--evt-op], dispatching against rcp::mock::Server instead of legacy_mock's Zone-keyed Registry. capabilities_json()'s transports/features are refreshed to the v2.13.0/v2.14.0 transports and v2.3.0/v2.4.0/v2.7.0 endpoint set. adapt.hpp's Adapt() now takes a RequestFn instead of a shared_ptr, the same "primitives, not a wrapped chokepoint" choice every ADAPTed Phase 14/15 header already made. zone_to_relay_id/ zone_from_relay_id's PascalCase-zone scheme is replaced by endpoint_id_to_relay_id/relay_id_to_endpoint_id, encoding a (stream_key, byte_bus_id) pair into relay::Message.id; message_to_request/response_to_message replace message_to_command/ status_to_message, mapping op/evt_op via meta instead of the removed CommandType/Priority pair. subscribe() now reports std::errc::function_not_supported unconditionally — the same call every ADAPTed application bridge made at v2.15.0 for the removed Status push model, which has no analog in the target specification's request/response shape. rcp.hpp and rcp/legacy_mock.hpp are untouched: rcp/config.hpp is the one remaining pre-replacement dependent, and its own rebind is not part of this milestone's scope. Grepping the tree for consumers of the pre-v2.16.0 capi_impl.hpp/cli.hpp/adapt.hpp APIs beyond their own tests found none, so no legacy-shim split file was needed anywhere in this change. tests/test_capi.cpp, tests/test_cli.cpp, and tests/test_relay.cpp are rewritten in place under their existing REQ-CAPI-*/REQ-CLI-*/REQ-RELAY-* prefixes (REQ-CAPI-009 new, for the payload_cap-overrun rejection), traced in .fusa-reqs.json. version.hpp/CMakeLists.txt bumped to 2.16.0; README.md and ROADMAP.md updated with a Done note. Verified locally: full build + ctest (60/60 passing) on GCC and Clang, cpp-FuSa check/lint/analyze/trace, and manual smoke-testing of the CLI binary. The RELAY conform --strict gate only exercises version/ capabilities/status (never send), which are unaffected in shape. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 20 ++- CMakeLists.txt | 2 +- README.md | 8 +- ROADMAP.md | 85 +++++++++++ include/rcp/adapt.hpp | 311 +++++++++++++++++++++----------------- include/rcp/capi.h | 202 +++++++++++++++++-------- include/rcp/capi_impl.hpp | 182 +++++++++++++--------- include/rcp/cli.hpp | 227 ++++++++++++++++++---------- include/rcp/version.hpp | 2 +- tests/test_capi.cpp | 217 ++++++++++++++++++++++++-- tests/test_cli.cpp | 102 ++++++++++--- tests/test_relay.cpp | 208 +++++++++++++++---------- 12 files changed, 1086 insertions(+), 480 deletions(-) diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 18cc905..07d4b61 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2699,7 +2699,7 @@ { "id": "REQ-CAPI-003", "title": "rcp_ctrl_init succeeds with adequate buffer", - "text": "rcp_ctrl_init() shall return RCP_OK and populate the output handle when supplied a buffer of at least rcp_ctrl_sizeof() bytes.", + "text": "rcp_ctrl_init() shall return RCP_OK and populate the output handle when supplied a non-null rcp_request_fn_t and a buffer of at least rcp_ctrl_sizeof() bytes.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -2723,7 +2723,7 @@ { "id": "REQ-CAPI-006", "title": "rcp_send returns RCP_OK on success", - "text": "rcp_send() shall return RCP_OK and populate rcp_response_t when the inner controller succeeds within the timeout.", + "text": "rcp_send() shall return RCP_OK and populate rcp_response_t when ctrl's bound rcp_request_fn_t succeeds and reports no more response bytes than resp->payload_cap allowed.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -2731,7 +2731,7 @@ { "id": "REQ-CAPI-007", "title": "rcp_send with null ctrl returns RCP_ERR_INVALID", - "text": "rcp_send() shall return RCP_ERR_INVALID when ctrl, cmd, or resp is NULL.", + "text": "rcp_send() shall return RCP_ERR_INVALID when ctrl, req, or resp is NULL.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -2744,6 +2744,14 @@ "level": "ASIL-A", "asil": "ASIL-A" }, + { + "id": "REQ-CAPI-009", + "title": "rcp_send rejects a callback that overruns the response payload capacity", + "text": "rcp_send() shall return RCP_ERR_INVALID when the bound rcp_request_fn_t reports resp->payload_len greater than resp->payload_cap.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, { "id": "REQ-CLI-001", "title": "CLI version document conforms to RELAY \u00a712.1", @@ -2803,7 +2811,7 @@ { "id": "REQ-RELAY-004", "title": "Adapt() wraps a Controller as a relay::Caller (\u00a710.3)", - "text": "rcp::Adapt(std::shared_ptr) shall return a non-null relay::Caller whose protocol() is RCP and whose call(), send(), subscribe(), and close() delegate to the wrapped controller.", + "text": "rcp::Adapt(RequestFn) shall return a non-null relay::Caller whose protocol() is RCP and whose call()/send() delegate to the wrapped RequestFn, resolving the target server+endpoint from relay::Message.id; subscribe() shall report std::errc::function_not_supported, and close() shall succeed unconditionally.", "standard": "relay", "level": "QM", "asil": "QM" @@ -2811,7 +2819,7 @@ { "id": "REQ-RELAY-005", "title": "RELAY core bindings and RCP message mapping (\u00a718.2, \u00a714, \u00a715.7.5)", - "text": "rcp::Context shall alias relay::Context and rcp::StatusChannel shall alias relay::Channel; SubscriberOptions shall default channel_depth=64 and expose topic_name; status_to_message() shall match the rcp-status golden vector.", + "text": "rcp::Context shall alias relay::Context and rcp::StatusChannel shall alias relay::Channel; SubscriberOptions shall default channel_depth=64 and expose topic_name; endpoint_id_to_relay_id()/relay_id_to_endpoint_id() shall round-trip a (stream_key, byte_bus_id) pair through relay::Message.id, and message_to_request()/response_to_message() shall correctly decode/encode the op, evt_op, and response_kind fields.", "standard": "relay", "level": "QM", "asil": "QM" @@ -2819,7 +2827,7 @@ { "id": "REQ-CLI-005", "title": "CLI send --format json streaming NDJSON sink (RELAY \u00a711.2)", - "text": "The `send --format json` subcommand shall read relay.Message values as NDJSON on stdin (one per line) and publish each via message_to_command to the matching zone controller until EOF; malformed or undeliverable lines shall be reported and skipped without aborting the stream. `send` without `--format json` shall return exit code 2.", + "text": "The `send --format json` subcommand shall read relay.Message values as NDJSON on stdin (one per line) and publish each via message_to_request to the matching rcp::mock::Server endpoint until EOF; malformed or undeliverable lines shall be reported and skipped without aborting the stream. `send` without `--format json` shall return exit code 2.", "standard": "relay", "level": "QM", "asil": "QM" diff --git a/CMakeLists.txt b/CMakeLists.txt index 2105548..eb332ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.15.1 + VERSION 2.16.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/README.md b/README.md index a6cd1a8..98e0a72 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ reference. | Header | Description | |---|---| | `` | Core interfaces: `Controller`, `Registry`, `Command`, `Response`, `Status`, `Zone`, `Context`, `StatusChannel` | -| `` | `Adapt()` — the RELAY §10.3 entry point, wraps a `Controller` as a `relay::Caller`; `ToMessage`/`FromMessage` conversions | +| `` | `Adapt()` — the RELAY §10.3 entry point, wraps a `RequestFn` (a client-side send-equivalent call) as a `relay::Caller`; `response_to_message`/`message_to_request` conversions, keyed by a server+endpoint identifier (ROADMAP.md v2.16.0) | | `` | `relay::` namespace types (§18.2): `Protocol`, `Message`, `Errc` sentinels, `Channel`, `Context`, `Node`, `Caller` | | `` | In-process TC18 RC Server simulator (lifecycle + register map + GPIO/SPI) — zero I/O, default for unit tests (ROADMAP.md v2.12.0) | -| `` | Pre-replacement in-process `Controller`/`Registry`, kept only for the old-model dependents not yet rebound to the new request model (`capi_impl.hpp`/`cli.hpp`/`config.hpp`, v2.16.0) | -| `` | RELAY-conformant CLI (§11/§12): `version`/`capabilities`/`status`/`send`; `cli/main.cpp` is a thin wrapper around it | +| `` | Pre-replacement in-process `Controller`/`Registry`, kept only for the one old-model dependent not yet rebound to the new request model (`config.hpp`) | +| `` | RELAY-conformant CLI (§11/§12): `version`/`capabilities`/`status`/`send`; `send` addresses an RC Server endpoint via `--server`/`--endpoint` against the `` demo backend; `cli/main.cpp` is a thin wrapper around it (ROADMAP.md v2.16.0) | | `` | Binary version string — single source of truth for the CLI | -| `` / `` | C ABI / FFI surface for RTOS/bare-metal targets (Zephyr/FreeRTOS) | +| `` / `` | C ABI / FFI surface for RTOS/bare-metal targets (Zephyr/FreeRTOS): server+endpoint addressing, a caller-supplied `rcp_request_fn_t` callback, no heap allocation (ROADMAP.md v2.16.0) | ### Protocol bridges diff --git a/ROADMAP.md b/ROADMAP.md index 7e93c9d..5dd049f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1275,6 +1275,91 @@ pre-replacement coverage, traced in `.fusa-reqs.json`. - Depends on: at least the basic endpoint types (v2.3.0/v2.4.0) to have something for `send` to address +**Done (v2.16.0):** `capi.h`/`capi_impl.hpp` are REPLACEd in full, per the +Satellite Package Disposition table's entry for this pair — the old +`rcp_zone_t`/`rcp_command_t`/`rcp_response_t`/`rcp_priority_t` types are +discarded outright, not adapted, since a from-scratch C ABI has no analog +of `Zone`/`Priority` to adapt from. The new surface addresses a request via +a caller-chosen `rcp_stream_key_t` plus an `rcp_byte_bus_id_t` (the same +pair every Phase 14/15 header has keyed on since v2.10.0); `rcp_acf_info_t` +mirrors `rcp::acf::AcfMessageInfo`'s shared-header fields (`rcp/acf.hpp`, +v2.0.0) one-for-one instead of the removed `cmd_type`/`priority` pair. An +`rcp_ctrl_h` now binds one caller-supplied `rcp_request_fn_t` C function +pointer (plus opaque userdata) to one endpoint — the C-linkage analog of +the "client-side send-equivalent call" shape `rcp/record.hpp`'s and +`rcp/observe.hpp`'s own `RequestFn` already standardize on (v2.14.0) — so +the same callback can be backed by `rcp::mock::Server::dispatch` +(v2.12.0), `rcp::udp::Client::request` (v2.13.0), or real hardware, +without `capi_impl.hpp` depending on any of them; `rcp_registry_s` becomes +a small fixed-capacity (16-entry) table of such handles keyed by +`(stream_key, byte_bus_id)` rather than growing on the heap, closing a gap +the pre-replacement registry's `std::unordered_map`-backed storage left in +its own "no heap allocation" claim. `rcp_response_t` gains a caller- +supplied `payload_cap` alongside `payload`/`payload_len`, so a response +payload is written into caller-owned memory rather than the pre- +replacement stub's `payload = nullptr` no-op; `rcp_send()` rejects a +callback that reports more bytes than `payload_cap` allowed +(`REQ-CAPI-009`, new). `rcp_subscribe`/`rcp_status_cb_t` have no +replacement, the same "no analog in the target specification's +request/response shape" call every ADAPTed Phase 14/15 bridge already +made for `Controller`'s status-telemetry push model. Grepping the tree for +consumers of the pre-v2.16.0 `capi_impl.hpp` API beyond its own (now- +rewritten) test found none, so no legacy-shim split file was needed here, +matching `rcp/udp.hpp`'s v2.13.0 precedent. + +`cli.hpp` (and `cli/main.cpp`, unchanged) is ADAPTed in place: the +`version`/`capabilities`/`status` triad carries forward unchanged in +shape, as this milestone's own scope note called for. `send`'s protocol- +flags form moves from `--zone --type ` to `--server + --endpoint --op read|write [--evt-op <0-7>]`, +and both the protocol-flags and streaming NDJSON forms now dispatch +against `rcp::mock::Server` (v2.12.0) — the new RC Server simulator — +instead of `rcp/legacy_mock.hpp`'s Zone-keyed `Registry`, addressed by +`byte_bus_id` the same way that simulator's own representative endpoint +set already is. `capabilities_json()`'s `transports` becomes +`["udp","shmem","mock"]` (v2.13.0/v2.14.0/v2.12.0) and `features` becomes +the full v2.3.0/v2.4.0/v2.7.0 endpoint set plus `"loaning"`; +`optional_interfaces` drops `"LoaningController"`, which `Adapt()` no +longer wraps now that it takes a plain callable instead of a +`shared_ptr`. + +`rcp/adapt.hpp`'s `Adapt()` now takes a `RequestFn` — shaped identically +to `rcp/record.hpp`'s and `rcp/observe.hpp`'s own `RequestFn` (v2.14.0) — +rather than a `shared_ptr`, the same "primitives, not a +wrapped chokepoint" choice every ADAPTed Phase 14/15 header already made; +there is no unified client-side `send()` chokepoint left to wrap. +`zone_to_relay_id`/`zone_from_relay_id`'s PascalCase-zone scheme in +`relay::Message.id` is replaced by `endpoint_id_to_relay_id`/ +`relay_id_to_endpoint_id`, encoding a `(stream_key, byte_bus_id)` pair as +`"<16 lowercase hex digits>:"` — this implementation's own +encoding, not something the RELAY spec mandates a shape for. +`status_to_message`/`message_to_command`/`response_to_message` are +replaced by `message_to_request`/`response_to_message`, mapping +`relay::Message.meta`'s `"rcp.op"`/`"rcp.evt_op"` keys onto +`acf::AcfMessageInfo`'s `op`/`evt_op` fields instead of the removed +`CommandType`/`Priority` pair. `subscribe()` now always reports +`std::errc::function_not_supported` — the same call `rcp/mqttbr.hpp`'s +(and its six ADAPTed siblings') dropped `subscribe()`/`StatusChannel` +method already made at v2.15.0, since the pre-replacement Status push +model this milestone retires has no analog in the target specification's +request/response shape; `close()` still succeeds unconditionally, same as +those seven bridges. `relay/relay.hpp` itself is unchanged, per this +milestone's own scope note. + +`rcp.hpp` and `rcp/legacy_mock.hpp` are untouched: `rcp/config.hpp` is the +one remaining dependent of `rcp.hpp`'s pre-replacement Zone/Command/ +Controller model and `legacy_mock.hpp`'s Registry, so neither file could +be deleted at this milestone; `rcp/config.hpp`'s own rebind is not part of +this milestone's stated scope and remains open. Grepping the tree for +consumers of `cli.hpp`'s/`adapt.hpp`'s pre-v2.16.0 APIs beyond their own +(now-rewritten) tests found none, so no legacy-shim split file was needed +for either. `version.hpp`/`CMakeLists.txt` are bumped to 2.16.0. Coverage +lives in `tests/test_capi.cpp` (`REQ-CAPI-001..009`, `009` new), +`tests/test_cli.cpp` (`REQ-CLI-001..005`), and `tests/test_relay.cpp` +(`REQ-RELAY-001..005`) — all rewritten in place under their existing +prefix identity, the same convention every prior REPLACE/ADAPT milestone +in Phase 13-15 followed — traced in `.fusa-reqs.json`. + --- ### Phase 16 — Deprecation & Certification Refresh --- diff --git a/include/rcp/adapt.hpp b/include/rcp/adapt.hpp index def20a6..e36f224 100644 --- a/include/rcp/adapt.hpp +++ b/include/rcp/adapt.hpp @@ -6,205 +6,236 @@ // RELAY application interface adapter for cpp-RCP (§10.3, §18.2). // -// Adapt() wraps an rcp::Controller as a relay::Caller so application code can -// use the protocol-agnostic relay::Node / relay::Caller interface and swap the -// underlying protocol with a single constructor change. +// Adapt() wraps a client-side send-equivalent call as a relay::Caller so +// application code can use the protocol-agnostic relay::Node / relay::Caller +// interface and swap the underlying protocol with a single constructor +// change. // // Usage: -// auto ctrl = std::make_shared(rcp::Zone::FrontLeft); -// auto caller = rcp::Adapt(ctrl); // relay::Caller* -// auto [resp, ec] = caller->call(ctx, msg); +// auto fn = [&](const rcp::Context& ctx, const rcp::acf::AcfMessageInfo& req, +// const std::vector& payload, +// rcp::acf::AcfMessageInfo& out, std::vector& out_payload) { +// return my_server.dispatch(0, req, payload, out, out_payload); +// }; +// auto caller = rcp::Adapt(fn); // relay::Caller* +// relay::Message req; +// req.id = rcp::endpoint_id_to_relay_id(stream_key, byte_bus_id); +// auto [resp, ec] = caller->call(ctx, req); // -// The example above uses rcp/legacy_mock.hpp's Controller (renamed from -// rcp/mock.hpp at ROADMAP.md milestone 56, v2.12.0 — see its own header -// comment) since this header itself is still built on rcp.hpp's -// pre-replacement Zone/Command/Controller model; per the Satellite Package -// Disposition table's entry for `adapt.hpp`, rebinding Adapt()/ToMessage/ -// FromMessage to the new request/response shapes is v2.16.0 scope. +// ROADMAP.md milestone 60, "C ABI & CLI Rebuild (v2.16.0)": this header is +// ADAPTed in place, per the Satellite Package Disposition table's entry for +// `adapt.hpp` — Adapt()/ToMessage()/FromMessage() are rebound from +// rcp.hpp's pre-replacement Zone/Command/Controller model to the ACF +// request/response shape (rcp/acf.hpp, v2.0.0) established through v2.15.0. +// relay::Message addressing moves from a PascalCase zone name in +// relay::Message.id to a server+endpoint identifier there instead (see +// endpoint_id_to_relay_id/relay_id_to_endpoint_id below); relay/relay.hpp +// itself needs no change, per this milestone's own scope note. Adapt() now +// takes a RequestFn — the same "client-side send-equivalent call" shape +// rcp/record.hpp's and rcp/observe.hpp's own RequestFn already standardize +// on (v2.14.0) — rather than a shared_ptr, the same "primitives, +// not a wrapped chokepoint" choice those two headers and every ADAPTed +// Phase 14/15 bridge already made; there is no unified client-side send() +// chokepoint left to wrap. subscribe() has no analog here and always +// reports std::errc::function_not_supported, the same call +// rcp/mqttbr.hpp's (and its six siblings') dropped subscribe()/ +// StatusChannel method already made at v2.15.0 — it belonged to +// rcp::Controller's status-telemetry push model, which the target +// specification's request/response shape has no equivalent of. #pragma once -#include +#include +#include +#include // for rcp::Context only — see this header's own scope note above #include #include #include +#include +#include +#include #include #include -#include +#include +#include +#include namespace rcp { -// ── Zone ↔ relay::Message.ID helpers (§15.7.5) ─────────────────────────────── - -// zone_to_relay_id returns the RELAY-canonical PascalCase zone name (§4.2). -inline std::string zone_to_relay_id(Zone z) { - switch (z) { - case Zone::FrontLeft: return "FrontLeft"; - case Zone::FrontRight: return "FrontRight"; - case Zone::RearLeft: return "RearLeft"; - case Zone::RearRight: return "RearRight"; - case Zone::Central: return "Central"; - default: return "Unknown"; +// ── server+endpoint ↔ relay::Message.ID helpers (§15.7.5) ──────────────────── +// relay::Message carries a single string `id` field; a server+endpoint +// address (stream_key, byte_bus_id — the pair every Phase 14/15 header has +// keyed on since v2.10.0) is encoded into it as +// ":" — this +// implementation's own encoding, not something the target specification or +// the RELAY spec mandates a shape for. + +// endpoint_id_to_relay_id encodes (stream_key, byte_bus_id) into a +// relay::Message.id string. +inline std::string endpoint_id_to_relay_id(uint64_t stream_key, avtp::ByteBusId byte_bus_id) { + static const char* kHex = "0123456789abcdef"; + std::string out(16, '0'); + for (int i = 15; i >= 0; --i) { + out[static_cast(i)] = kHex[stream_key & 0xF]; + stream_key >>= 4; } + out += ':'; + out += std::to_string(static_cast(byte_bus_id)); + return out; } -// zone_from_relay_id parses a PascalCase zone name from relay::Message.ID. -inline Zone zone_from_relay_id(const std::string& id) { - if (id == "FrontLeft") return Zone::FrontLeft; - if (id == "FrontRight") return Zone::FrontRight; - if (id == "RearLeft") return Zone::RearLeft; - if (id == "RearRight") return Zone::RearRight; - if (id == "Central") return Zone::Central; - return Zone::Unknown; +// relay_id_to_endpoint_id decodes a relay::Message.id string produced by +// endpoint_id_to_relay_id back into (stream_key, byte_bus_id). Returns false +// — leaving both outputs unspecified — for anything that doesn't match the +// "<16 hex digits>:" shape, including the pre-v2.16.0 PascalCase +// zone-name form this replaces. +inline bool relay_id_to_endpoint_id(const std::string& id, uint64_t& out_stream_key, + avtp::ByteBusId& out_byte_bus_id) { + auto colon = id.find(':'); + if (colon == std::string::npos || colon != 16) return false; + + uint64_t stream_key = 0; + auto [p1, ec1] = std::from_chars(id.data(), id.data() + colon, stream_key, 16); + if (ec1 != std::errc{} || p1 != id.data() + colon) return false; + + unsigned bus_id = 0; + const char* rest_begin = id.data() + colon + 1; + const char* rest_end = id.data() + id.size(); + if (rest_begin == rest_end) return false; + auto [p2, ec2] = std::from_chars(rest_begin, rest_end, bus_id, 10); + if (ec2 != std::errc{} || p2 != rest_end || bus_id > 0xFF) return false; + + out_stream_key = stream_key; + out_byte_bus_id = static_cast(bus_id); + return true; } -// priority_from_meta parses the "rcp.priority" meta key. -inline Priority priority_from_meta(const std::map& meta) { - auto it = meta.find("rcp.priority"); - if (it == meta.end()) return Priority::Normal; - if (it->second == "high") return Priority::High; - if (it->second == "critical") return Priority::Critical; - return Priority::Normal; +// ── request/response field ↔ relay::Message.meta helpers ───────────────────── + +// op_from_meta parses the "rcp.op" meta key ("read"/"write"; default read). +inline bool op_from_meta(const std::map& meta) { + auto it = meta.find("rcp.op"); + return it != meta.end() && it->second == "write"; } -// cmd_type_from_meta parses the "rcp.cmd_type" meta key. -inline CommandType cmd_type_from_meta(const std::map& meta) { - auto it = meta.find("rcp.cmd_type"); - if (it == meta.end()) return CommandType::Noop; - const auto& s = it->second; - if (s == "set") return CommandType::Set; - if (s == "get") return CommandType::Get; - if (s == "reset") return CommandType::Reset; - if (s == "watchdog") return CommandType::Watchdog; - if (s == "sleep") return CommandType::Sleep; - if (s == "wake") return CommandType::Wake; - return CommandType::Noop; +// evt_op_from_meta parses the "rcp.evt_op" meta key (decimal 0-7; default 0) +// — the endpoint-defined sub-opcode carried in AcfMessageInfo::evt_op. +inline uint8_t evt_op_from_meta(const std::map& meta) { + auto it = meta.find("rcp.evt_op"); + if (it == meta.end()) return 0; + unsigned v = 0; + auto [p, ec] = std::from_chars(it->second.data(), it->second.data() + it->second.size(), v, 10); + if (ec != std::errc{} || p != it->second.data() + it->second.size() || v > 7) return 0; + return static_cast(v); } // ── ToMessage / FromMessage (§15.7.5) ──────────────────────────────────────── -// status_to_message converts rcp::Status to relay::Message (subscribe direction). -inline relay::Message status_to_message(const Status& s) { - relay::Message msg; - msg.protocol = relay::Protocol::RCP; - msg.id = zone_to_relay_id(s.zone); - msg.payload = s.payload; - msg.seq = s.seq; - msg.timestamp = std::chrono::system_clock::now(); - msg.meta["rcp.healthy"] = s.healthy ? "true" : "false"; - return msg; -} - -// response_to_message converts rcp::Response to relay::Message (call direction). -inline relay::Message response_to_message(const Response& r) { +// response_to_message converts an ACF response (rcp/acf.hpp) plus the +// stream_key it arrived on into a relay::Message (call direction). +inline relay::Message response_to_message(uint64_t stream_key, const acf::AcfMessageInfo& resp, + const std::vector& payload) { relay::Message msg; msg.protocol = relay::Protocol::RCP; - msg.id = zone_to_relay_id(r.zone); - msg.payload = r.payload; + msg.id = endpoint_id_to_relay_id(stream_key, resp.byte_bus_id); + msg.payload = payload; msg.timestamp = std::chrono::system_clock::now(); - msg.meta["rcp.status"] = std::to_string(static_cast(r.status)); + msg.meta["rcp.response_kind"] = std::to_string(static_cast(acf::response_kind_of(resp))); + msg.meta["rcp.err"] = resp.err ? "true" : "false"; return msg; } -// message_to_command converts a relay::Message to rcp::Command (call direction). -inline Command message_to_command(const relay::Message& msg) { - Command cmd; - cmd.zone = zone_from_relay_id(msg.id); - cmd.priority = priority_from_meta(msg.meta); - cmd.type = cmd_type_from_meta(msg.meta); - cmd.payload = msg.payload; - return cmd; +// message_to_request converts a relay::Message into an ACF standard request +// (call/send direction). Returns false — leaving all outputs unspecified — +// if msg.id does not decode as a server+endpoint identifier. +inline bool message_to_request(const relay::Message& msg, uint64_t& out_stream_key, + acf::AcfMessageInfo& out_info, std::vector& out_payload) { + avtp::ByteBusId byte_bus_id = 0; + if (!relay_id_to_endpoint_id(msg.id, out_stream_key, byte_bus_id)) return false; + out_info = acf::AcfMessageInfo{}; + out_info.byte_bus_id = byte_bus_id; + out_info.op = op_from_meta(msg.meta); + out_info.evt_op = evt_op_from_meta(msg.meta); + out_payload = msg.payload; + return true; } -// ── RcpCallerAdapter — implements relay::Caller over rcp::Controller ────────── +// ── RequestFn — the client-side send-equivalent call this header wraps ────── +// Shaped identically to rcp/record.hpp's and rcp/observe.hpp's own RequestFn +// (v2.14.0) — see this file's header comment for why. +using RequestFn = std::function&, + acf::AcfMessageInfo&, + std::vector&)>; + +// ── RcpCallerAdapter — implements relay::Caller over a RequestFn ───────────── class RcpCallerAdapter final : public relay::Caller { public: - explicit RcpCallerAdapter(std::shared_ptr ctrl) - : ctrl_(std::move(ctrl)) {} + explicit RcpCallerAdapter(RequestFn fn) : fn_(std::move(fn)) {} relay::Protocol protocol() const noexcept override { return relay::Protocol::RCP; } - // send maps relay::Message → rcp::Command, discards the response (§10.6). + // send maps relay::Message → ACF request, discards the response (§10.6). std::error_code send(relay::Context ctx, const relay::Message& msg) override { - if (!ctrl_) return make_error_code(Errc::not_found); - Command cmd = message_to_command(msg); - cmd.zone = zone_from_relay_id(msg.id); - Response resp; - return ctrl_->send(ctx, cmd, resp); + if (!fn_) return std::make_error_code(std::errc::not_connected); + uint64_t stream_key = 0; + acf::AcfMessageInfo req; + std::vector payload; + if (!message_to_request(msg, stream_key, req, payload)) + return std::make_error_code(std::errc::invalid_argument); + acf::AcfMessageInfo out_info; + std::vector out_payload; + return fn_(ctx, req, payload, out_info, out_payload); } - // call maps relay::Message → rcp::Command → relay::Message (§10.2). + // call maps relay::Message → ACF request → relay::Message (§10.2). std::pair - call(relay::Context ctx, const relay::Message& req) override { - if (!ctrl_) return {{}, make_error_code(Errc::not_found)}; - Command cmd = message_to_command(req); - Response resp; - auto ec = ctrl_->send(ctx, cmd, resp); + call(relay::Context ctx, const relay::Message& req_msg) override { + if (!fn_) return {{}, std::make_error_code(std::errc::not_connected)}; + uint64_t stream_key = 0; + acf::AcfMessageInfo req; + std::vector payload; + if (!message_to_request(req_msg, stream_key, req, payload)) + return {{}, std::make_error_code(std::errc::invalid_argument)}; + acf::AcfMessageInfo out_info; + std::vector out_payload; + auto ec = fn_(ctx, req, payload, out_info, out_payload); if (ec) return {{}, ec}; - return {response_to_message(resp), {}}; + return {response_to_message(stream_key, out_info, out_payload), {}}; } - // subscribe wraps rcp::Controller::subscribe, forwarding Status as relay::Message. - // Starts a background thread per §10.5 that reads from StatusChannel and pushes - // to the relay::Channel. Thread exits when the status channel closes. + // subscribe: no analog in the target specification's request/response + // shape — see this header's own comment above. std::pair>, std::error_code> - subscribe(relay::SubscriberOptions opts = {}) override { - if (!ctrl_) return {nullptr, make_error_code(Errc::not_found)}; - - std::shared_ptr status_ch; - auto ec = ctrl_->subscribe(relay::Context::background(), status_ch); - if (ec) return {nullptr, ec}; - - auto out = std::make_shared>(opts.channel_depth); - auto bp = opts.back_pressure; - - std::thread([status_ch, out, bp]() mutable { - while (true) { - auto st = status_ch->recv(); - if (!st) break; - auto msg = status_to_message(*st); - switch (bp) { - case relay::BackPressurePolicy::drop_newest: - out->push(std::move(msg)); - break; - case relay::BackPressurePolicy::drop_oldest: - if (!out->push(msg)) { - out->try_recv(); - out->push(std::move(msg)); - } - break; - case relay::BackPressurePolicy::block: - while (!out->push(msg) && !out->is_closed()) { - // spin until space available or channel closed - } - break; - } - } - out->close(); - }).detach(); - - return {out, {}}; + subscribe(relay::SubscriberOptions /*opts*/ = {}) override { + return {nullptr, std::make_error_code(std::errc::function_not_supported)}; } + // close: a RequestFn owns no resource this adapter can release on its + // behalf — the same "still succeeds" choice rcp/mqttbr.hpp's (and its + // six siblings') close() already makes at v2.15.0. std::error_code close() noexcept override { - if (!ctrl_) return {}; - return ctrl_->close(); + return {}; } private: - std::shared_ptr ctrl_; + RequestFn fn_; }; // ── Adapt() (§10.3) ────────────────────────────────────────────────────────── -// Adapt wraps an rcp::Controller as a relay::Caller. -// The returned relay::Caller also satisfies relay::Node. -// Does NOT block or connect; wraps the given controller immediately. -inline std::unique_ptr Adapt(std::shared_ptr ctrl) { - return std::make_unique(std::move(ctrl)); +// Adapt wraps a RequestFn as a relay::Caller. The returned relay::Caller +// also satisfies relay::Node. Does NOT block or connect; wraps `fn` +// immediately. Each relay::Message passed to send()/call() carries its own +// full server+endpoint address in Message.id (see endpoint_id_to_relay_id +// above), so one adapter can address any number of distinct endpoints +// through the same underlying RequestFn. +inline std::unique_ptr Adapt(RequestFn fn) { + return std::make_unique(std::move(fn)); } } // namespace rcp diff --git a/include/rcp/capi.h b/include/rcp/capi.h index 6ead617..f492105 100644 --- a/include/rcp/capi.h +++ b/include/rcp/capi.h @@ -7,19 +7,59 @@ * fusa:req REQ-CAPI-006 * fusa:req REQ-CAPI-007 * fusa:req REQ-CAPI-008 + * fusa:req REQ-CAPI-009 * - * cpp-RCP C API for RTOS / bare-metal integration (v0.40.0). + * cpp-RCP C API for RTOS / bare-metal integration. * - * This header exposes a C-linkage flat API suitable for: + * ROADMAP.md milestone 60, "C ABI & CLI Rebuild (v2.16.0)": this header + * REPLACES its pre-replacement content in full, per the Satellite Package + * Disposition table's entry for `capi.h`/`capi_impl.hpp` — the old + * rcp_zone_t/rcp_command_t/rcp_response_t triple was Zone/CommandType- + * specific and had no way to generalize by adaptation once Zone/Command are + * gone, so this is a from-scratch redesign against the server+endpoint + * addressing and ACF request/response shape established through v2.15.0 + * (rcp/acf.hpp, v2.0.0), not an incremental patch of the old structs. + * + * Addressing (extraction §2.1, via rcp/acf.hpp/rcp/avtp.hpp): a request + * targets one endpoint via a caller-chosen rcp_stream_key_t (typically an + * rcp::avtp::StreamId's to_u64() form) plus an rcp_byte_bus_id_t, unique + * only within that stream_key. rcp_acf_info_t mirrors rcp::acf:: + * AcfMessageInfo's shared-header fields one-for-one (see rcp/acf.hpp) so a + * caller building a request here needs no second field-by-field mapping + * doc; unlike the removed rcp_priority_t, there is no separate priority + * field — the target specification's own execution-priority ordering is a + * server-side property of the request *kind* (rcp/request.hpp, v2.5.0), + * not a client-supplied hint. + * + * This C ABI does not itself implement a transport, a server, or a + * request-dispatch backend: an rcp_ctrl_h binds one caller-supplied + * rcp_request_fn_t callback (plus opaque userdata) to one stream_key/ + * byte_bus_id pair, and rcp_send() is a direct, allocation-free call + * through to that callback. This mirrors the "client-side send-equivalent + * call" shape rcp/record.hpp's and rcp/observe.hpp's C++-level `RequestFn` + * already standardize on for the same purpose (v2.14.0) — expressed here as + * a plain C function pointer, since C has no std::function, so the same + * callback can be backed by rcp::mock::Server::dispatch (v2.12.0), by + * rcp::udp::Client::request (v2.13.0), or by real hardware, without this + * header depending on any of them. + * + * All buffers are caller-supplied, including the response payload output + * buffer (rcp_response_t::payload / payload_cap below) — no heap + * allocation occurs inside this header or its implementation + * (rcp/capi_impl.hpp) at any point. + * + * Suitable for: * - AUTOSAR Classic / OSEK RTOS (no C++ runtime) * - FreeRTOS / Zephyr (minimal C++ support) * - Safety MCUs with C-only toolchains * - * All buffers are caller-supplied. No heap allocation occurs inside the - * library once rcp_registry_init() has been called. - * - * Thread safety: the opaque handle types are not thread-safe. Callers must + * Thread safety: the opaque handle types are not thread-safe. Callers must * provide external locking if multiple tasks share a handle. + * + * Field names and behavior below implement the OPEN Alliance TC18 Remote + * Control Protocol Specification v0.5.1_RC's *behavior* as described in an + * internal structured extraction of that specification; no text from that + * document is reproduced here. */ #ifndef RCP_CAPI_H @@ -45,42 +85,51 @@ typedef int rcp_err_t; #define RCP_ERR_INVALID 6 #define RCP_ERR_NOTSUP 7 -/* ── Zone identifiers ─────────────────────────────────────────────────────── */ - -typedef uint8_t rcp_zone_t; - -#define RCP_ZONE_FRONT_LEFT 0 -#define RCP_ZONE_FRONT_RIGHT 1 -#define RCP_ZONE_REAR_LEFT 2 -#define RCP_ZONE_REAR_RIGHT 3 -#define RCP_ZONE_CENTRAL 4 +/* ── Addressing (v2.16.0): server + endpoint, not Zone ───────────────────── */ -/* ── Priority ─────────────────────────────────────────────────────────────── */ +/* rcp_stream_key_t is the opaque per-connection identifier a request + * targets — typically rcp::avtp::StreamId::to_u64() on the C++ side, but + * this header does not require that origin. */ +typedef uint64_t rcp_stream_key_t; -typedef uint8_t rcp_priority_t; +/* rcp_byte_bus_id_t addresses one endpoint within a stream_key (extraction + * §2.1) — unique only within the owning stream_key, never globally. */ +typedef uint8_t rcp_byte_bus_id_t; -#define RCP_PRIORITY_LOW 0 -#define RCP_PRIORITY_NORMAL 1 -#define RCP_PRIORITY_HIGH 2 -#define RCP_PRIORITY_CRITICAL 3 +/* ── ACF shared header, flattened for C linkage (rcp/acf.hpp AcfMessageInfo) */ -/* ── Command / Response ───────────────────────────────────────────────────── */ +typedef struct { + uint8_t acf_msg_type; /* ACF_ABB (0x0E) or ACF_GBB (0x0D) */ + uint16_t acf_msg_length; /* quadlets, including this header */ + uint8_t pad; /* 0-3 trailing pad bytes */ + uint8_t mtv; /* bool: message_timestamp valid (ACF_GBB only) */ + rcp_byte_bus_id_t byte_bus_id; /* target endpoint within stream_key */ + uint8_t evt_ack; /* bool: evt[3] acknowledge flag */ + uint8_t evt_op; /* evt[2:0]: endpoint-defined sub-opcode */ + uint8_t hs; /* bool: endpoint-specific reserved bit */ + uint8_t cs; /* bool: conditional-start (request-kind-specific) */ + uint8_t transaction_num; /* correlates a request with its response/ack */ + uint8_t op; /* bool: false = read, true = write */ + uint8_t rsp; /* bool: set on every response/ack */ + uint8_t err; /* bool: set alongside rsp for an error response */ + uint8_t ms; /* bool: "more segments" (fragmentation; unused) */ + uint16_t read_size_or_segment_num; /* read_size when !ms, segment_num when ms */ +} rcp_acf_info_t; + +/* ── Request / Response ───────────────────────────────────────────────────── */ typedef struct { - rcp_zone_t zone; - uint16_t cmd_type; - rcp_priority_t priority; - uint8_t reserved; - uint32_t id; - const uint8_t* payload; - uint32_t payload_len; -} rcp_command_t; + rcp_stream_key_t stream_key; + rcp_acf_info_t info; + const uint8_t* payload; /* caller-owned input bytes; may be NULL iff payload_len == 0 */ + uint32_t payload_len; +} rcp_request_t; typedef struct { - uint8_t status; - uint32_t id; - uint8_t* payload; - uint32_t payload_len; + rcp_acf_info_t info; + uint8_t* payload; /* caller-supplied output buffer of payload_cap bytes */ + uint32_t payload_cap; /* capacity of `payload`, set by the caller before rcp_send() */ + uint32_t payload_len; /* bytes actually written; always <= payload_cap on RCP_OK */ } rcp_response_t; /* ── Opaque handles ───────────────────────────────────────────────────────── */ @@ -88,7 +137,27 @@ typedef struct { typedef struct rcp_registry_s* rcp_registry_h; typedef struct rcp_ctrl_s* rcp_ctrl_h; +/* + * rcp_request_fn_t is the caller-supplied client-side send-equivalent call + * an rcp_ctrl_h binds to one stream_key/byte_bus_id — see this header's own + * comment above for why its shape mirrors rcp/record.hpp's and + * rcp/observe.hpp's C++-level RequestFn. `resp->payload`/`payload_cap` are + * already populated by the caller of rcp_send() when this callback runs; + * the callback MUST NOT write more than `resp->payload_cap` bytes and MUST + * set `resp->payload_len` to the number of bytes actually written. + */ +typedef rcp_err_t (*rcp_request_fn_t)(void* userdata, + const rcp_request_t* req, + rcp_response_t* resp, + uint32_t timeout_ms); + /* ── Registry ─────────────────────────────────────────────────────────────── */ +/* A registry is a small fixed-capacity table of rcp_ctrl_h entries keyed by + * (stream_key, byte_bus_id) — the same role the pre-replacement registry + * played keyed by rcp_zone_t, now generalized to the new addressing model. + * Capacity is fixed at rcp_registry_sizeof() bytes; there is no dynamic + * growth, so registry storage is exactly as heap-free as an individual + * rcp_ctrl_h. */ /* * rcp_registry_init initialises a registry in caller-supplied memory. @@ -101,46 +170,61 @@ rcp_err_t rcp_registry_init(void* buf, size_t buf_len, rcp_registry_h* out); /* rcp_registry_sizeof returns the required buffer size for a registry. */ size_t rcp_registry_sizeof(void); -/* rcp_registry_close closes the registry and all registered controllers. */ +/* rcp_registry_close closes the registry. Registered rcp_ctrl_h entries are + * caller-owned (placement-new'd into caller buffers) and are not touched. */ rcp_err_t rcp_registry_close(rcp_registry_h reg); /* ── Controller ───────────────────────────────────────────────────────────── */ /* - * rcp_ctrl_init initialises a mock controller in caller-supplied memory. - * zone – zone this controller serves - * buf – static buffer of at least rcp_ctrl_sizeof() bytes - * Returns RCP_OK on success. + * rcp_ctrl_init binds one (stream_key, byte_bus_id) target to one + * rcp_request_fn_t callback, in caller-supplied memory. + * stream_key / byte_bus_id – the endpoint this handle addresses + * fn – the send-equivalent callback; must not be NULL + * userdata – opaque pointer forwarded to every fn() call + * buf – static buffer of at least rcp_ctrl_sizeof() bytes + * Returns RCP_OK on success, RCP_ERR_NOMEM if buf_len is too small, + * RCP_ERR_INVALID if fn is NULL. */ -rcp_err_t rcp_ctrl_init(rcp_zone_t zone, void* buf, size_t buf_len, rcp_ctrl_h* out); +rcp_err_t rcp_ctrl_init(rcp_stream_key_t stream_key, + rcp_byte_bus_id_t byte_bus_id, + rcp_request_fn_t fn, + void* userdata, + void* buf, + size_t buf_len, + rcp_ctrl_h* out); size_t rcp_ctrl_sizeof(void); -/* rcp_registry_add registers a controller with the registry. */ +/* rcp_registry_add registers ctrl under its own (stream_key, byte_bus_id). + * Returns RCP_ERR_BUSY if that pair is already registered, RCP_ERR_NOMEM if + * the registry's fixed capacity is exhausted. */ rcp_err_t rcp_registry_add(rcp_registry_h reg, rcp_ctrl_h ctrl); -/* rcp_registry_lookup looks up a controller for zone. */ -rcp_err_t rcp_registry_lookup(rcp_registry_h reg, rcp_zone_t zone, rcp_ctrl_h* out); +/* rcp_registry_lookup looks up the ctrl registered for (stream_key, byte_bus_id). */ +rcp_err_t rcp_registry_lookup(rcp_registry_h reg, + rcp_stream_key_t stream_key, + rcp_byte_bus_id_t byte_bus_id, + rcp_ctrl_h* out); /* ── Send ─────────────────────────────────────────────────────────────────── */ /* - * rcp_send sends a command and blocks until a response is received or - * timeout_ms elapses. - * ctrl – controller handle from rcp_registry_lookup - * cmd – command to send - * resp – output: response populated on success - * timeout_ms – deadline in milliseconds; 0 = no timeout + * rcp_send issues one request through ctrl's bound rcp_request_fn_t and + * blocks until it returns. + * ctrl – handle from rcp_ctrl_init (or rcp_registry_lookup) + * req – request to send; req->info.byte_bus_id conventionally + * matches ctrl's own byte_bus_id, but this call does not + * enforce that — ctrl only supplies the transport callback + * resp – output: resp->payload/payload_cap MUST already point at a + * caller-supplied buffer on entry; populated on success + * timeout_ms – forwarded to fn unchanged; 0 = no timeout + * Returns RCP_ERR_INVALID if any pointer argument is NULL, or if the + * callback reports more response bytes than resp->payload_cap allowed. */ -rcp_err_t rcp_send(rcp_ctrl_h ctrl, - const rcp_command_t* cmd, - rcp_response_t* resp, - uint32_t timeout_ms); - -/* ── Status subscribe ─────────────────────────────────────────────────────── */ - -typedef void (*rcp_status_cb_t)(rcp_zone_t zone, uint8_t status, void* userdata); - -rcp_err_t rcp_subscribe(rcp_ctrl_h ctrl, rcp_status_cb_t cb, void* userdata); +rcp_err_t rcp_send(rcp_ctrl_h ctrl, + const rcp_request_t* req, + rcp_response_t* resp, + uint32_t timeout_ms); #ifdef __cplusplus } /* extern "C" */ diff --git a/include/rcp/capi_impl.hpp b/include/rcp/capi_impl.hpp index ead0871..d922f94 100644 --- a/include/rcp/capi_impl.hpp +++ b/include/rcp/capi_impl.hpp @@ -1,17 +1,3 @@ -// C API implementation shim — do not include from C code. -// -// Link this translation unit (capi_impl.cpp) to satisfy the C API symbols -// declared in capi.h. Bridges flat C handles to the C++ mock controller -// and in-process registry. -// -// Still built on rcp.hpp's pre-replacement Zone/Command/Controller/Registry -// model and rcp/legacy_mock.hpp's Controller/Registry backing it (renamed -// from rcp/mock.hpp at ROADMAP.md milestone 56, v2.12.0, when that name -// became the new RC Server simulator — see rcp/legacy_mock.hpp's header -// comment). Per the Satellite Package Disposition table's entry for -// `capi.h`/`capi_impl.hpp`, this file's own REPLACE against the new -// request model is v2.16.0 scope, not this milestone's. -// // fusa:req REQ-CAPI-001 // fusa:req REQ-CAPI-002 // fusa:req REQ-CAPI-003 @@ -20,27 +6,74 @@ // fusa:req REQ-CAPI-006 // fusa:req REQ-CAPI-007 // fusa:req REQ-CAPI-008 +// fusa:req REQ-CAPI-009 + +// C API implementation — do not include from C code. +// +// Link this translation unit (or include it from exactly one .cpp) to +// satisfy the C API symbols declared in capi.h. All storage is placement-new +// into caller-supplied buffers; there is no dynamic allocation. +// +// ROADMAP.md milestone 60, "C ABI & CLI Rebuild (v2.16.0)": this file +// REPLACES its pre-replacement content in full, per the Satellite Package +// Disposition table's entry for `capi.h`/`capi_impl.hpp`. The pre- +// replacement version bridged rcp_registry_s/rcp_ctrl_s directly to +// rcp::legacy_mock::Registry/Controller (rcp.hpp's Zone/Command/Controller +// model); this version has no dependency on rcp.hpp, rcp/legacy_mock.hpp, +// or any other backend at all — an rcp_ctrl_s is just a bound +// (rcp_request_fn_t, userdata, stream_key, byte_bus_id) tuple, and +// rcp_registry_s is a small fixed-capacity table of such handles. Grepping +// the tree for consumers of the pre-v2.16.0 capi_impl.hpp API beyond its +// own (now-rewritten) test found none, so no legacy-shim split file was +// needed here, same as rcp/udp.hpp's v2.13.0 rebuild and the fourteen files +// ADAPTed at v2.14.0/v2.15.0. +// +// Because rcp_request_fn_t is a plain function pointer with the same +// "client-side send-equivalent call" shape as rcp/record.hpp's/ +// rcp/observe.hpp's C++-level RequestFn, this header can be — but is not +// required to be — backed by rcp::mock::Server::dispatch (v2.12.0) or +// rcp::udp::Client::request (v2.13.0) via a small caller-written adapter +// function; see tests/test_capi.cpp for worked examples of both. #pragma once #include "capi.h" -#include "legacy_mock.hpp" -#include "rcp.hpp" -// ── Backing storage layout ──────────────────────────────────────────────────── -// We use placement-new into caller-supplied buffers to avoid heap allocation. +#include +#include -struct rcp_registry_s { - rcp::legacy_mock::Registry impl; -}; +// ── Backing storage ────────────────────────────────────────────────────────── struct rcp_ctrl_s { - std::shared_ptr impl; + rcp_stream_key_t stream_key; + rcp_byte_bus_id_t byte_bus_id; + rcp_request_fn_t fn; + void* userdata; +}; + +namespace rcp { +namespace capi { +namespace detail { +// Fixed registry capacity — chosen so rcp_registry_s comfortably fits the +// same "small caller-supplied buffer" size class the pre-replacement +// registry used (<= 512 bytes), with room to spare for future growth. +constexpr std::size_t kMaxRegistryEntries = 16; +} // namespace detail +} // namespace capi +} // namespace rcp + +struct rcp_registry_s { + rcp_ctrl_h entries[rcp::capi::detail::kMaxRegistryEntries] = {}; + std::size_t count = 0; + bool closed = false; }; static_assert(sizeof(rcp_registry_s) <= 512, "rcp_registry_s too large"); static_assert(sizeof(rcp_ctrl_s) <= 64, "rcp_ctrl_s too large"); +// ── Registry ────────────────────────────────────────────────────────────────── + inline rcp_err_t rcp_registry_init(void* buf, size_t buf_len, rcp_registry_h* out) { + if (!buf || !out) return RCP_ERR_INVALID; if (buf_len < sizeof(rcp_registry_s)) return RCP_ERR_NOMEM; *out = new (buf) rcp_registry_s{}; return RCP_OK; @@ -50,15 +83,28 @@ inline size_t rcp_registry_sizeof() { return sizeof(rcp_registry_s); } inline rcp_err_t rcp_registry_close(rcp_registry_h reg) { if (!reg) return RCP_ERR_INVALID; - auto ec = reg->impl.close(); + reg->closed = true; reg->~rcp_registry_s(); - return ec ? RCP_ERR_CLOSED : RCP_OK; + return RCP_OK; } -inline rcp_err_t rcp_ctrl_init(rcp_zone_t zone, void* buf, size_t buf_len, rcp_ctrl_h* out) { +// ── Controller ──────────────────────────────────────────────────────────────── + +inline rcp_err_t rcp_ctrl_init(rcp_stream_key_t stream_key, + rcp_byte_bus_id_t byte_bus_id, + rcp_request_fn_t fn, + void* userdata, + void* buf, + size_t buf_len, + rcp_ctrl_h* out) { + if (!buf || !out) return RCP_ERR_INVALID; + if (!fn) return RCP_ERR_INVALID; if (buf_len < sizeof(rcp_ctrl_s)) return RCP_ERR_NOMEM; - auto* h = new (buf) rcp_ctrl_s{}; - h->impl = std::make_shared(static_cast(zone)); + auto* h = new (buf) rcp_ctrl_s{}; + h->stream_key = stream_key; + h->byte_bus_id = byte_bus_id; + h->fn = fn; + h->userdata = userdata; *out = h; return RCP_OK; } @@ -67,52 +113,50 @@ inline size_t rcp_ctrl_sizeof() { return sizeof(rcp_ctrl_s); } inline rcp_err_t rcp_registry_add(rcp_registry_h reg, rcp_ctrl_h ctrl) { if (!reg || !ctrl) return RCP_ERR_INVALID; - auto ec = reg->impl.register_ctrl(ctrl->impl); - if (ec == rcp::ErrAlreadyExists) return RCP_ERR_BUSY; - return ec ? RCP_ERR_INVALID : RCP_OK; + if (reg->closed) return RCP_ERR_CLOSED; + for (std::size_t i = 0; i < reg->count; ++i) { + if (reg->entries[i]->stream_key == ctrl->stream_key && + reg->entries[i]->byte_bus_id == ctrl->byte_bus_id) { + return RCP_ERR_BUSY; // already registered for this (stream_key, byte_bus_id) + } + } + if (reg->count >= rcp::capi::detail::kMaxRegistryEntries) return RCP_ERR_NOMEM; + reg->entries[reg->count++] = ctrl; + return RCP_OK; } -inline rcp_err_t rcp_registry_lookup(rcp_registry_h reg, rcp_zone_t zone, rcp_ctrl_h* out) { +inline rcp_err_t rcp_registry_lookup(rcp_registry_h reg, + rcp_stream_key_t stream_key, + rcp_byte_bus_id_t byte_bus_id, + rcp_ctrl_h* out) { if (!reg || !out) return RCP_ERR_INVALID; - std::shared_ptr ctrl; - auto ec = reg->impl.lookup(static_cast(zone), ctrl); - if (ec) return RCP_ERR_NOT_FOUND; - // The handle is owned externally; return a transient view. - // Real RTOS usage would store handles in a static table. - (void)out; - return RCP_ERR_NOTSUP; // handle aliasing requires static table (not in stub) + if (reg->closed) return RCP_ERR_CLOSED; + for (std::size_t i = 0; i < reg->count; ++i) { + if (reg->entries[i]->stream_key == stream_key && + reg->entries[i]->byte_bus_id == byte_bus_id) { + *out = reg->entries[i]; + return RCP_OK; + } + } + return RCP_ERR_NOT_FOUND; } -inline rcp_err_t rcp_send(rcp_ctrl_h ctrl, - const rcp_command_t* cmd, - rcp_response_t* resp, - uint32_t timeout_ms) { - if (!ctrl || !cmd || !resp) return RCP_ERR_INVALID; - - rcp::Command c; - c.zone = static_cast(cmd->zone); - c.type = static_cast(cmd->cmd_type); - c.priority = static_cast(cmd->priority); - c.id = cmd->id; - if (cmd->payload && cmd->payload_len > 0) - c.payload.assign(cmd->payload, cmd->payload + cmd->payload_len); - - rcp::Context ctx; - if (timeout_ms > 0) - ctx = rcp::Context::with_timeout(std::chrono::milliseconds(timeout_ms)); - - rcp::Response r; - auto ec = ctrl->impl->send(ctx, c, r); - if (ec) return RCP_ERR_TIMEOUT; - - resp->status = static_cast(r.status); - resp->id = r.command_id; - resp->payload = nullptr; - resp->payload_len = 0; - return RCP_OK; -} +// ── Send ───────────────────────────────────────────────────────────────────── + +inline rcp_err_t rcp_send(rcp_ctrl_h ctrl, + const rcp_request_t* req, + rcp_response_t* resp, + uint32_t timeout_ms) { + if (!ctrl || !req || !resp) return RCP_ERR_INVALID; + if (!resp->payload && resp->payload_cap > 0) return RCP_ERR_INVALID; -inline rcp_err_t rcp_subscribe(rcp_ctrl_h ctrl, rcp_status_cb_t cb, void* userdata) { - (void)ctrl; (void)cb; (void)userdata; - return RCP_ERR_NOTSUP; // subscription bridging requires an adapter thread + rcp_err_t ec = ctrl->fn(ctrl->userdata, req, resp, timeout_ms); + if (ec != RCP_OK) return ec; + + // Defensive net around the callback's own contract (capi.h's own + // header comment): a callback that overruns the caller-supplied output + // buffer is a callback bug, not something rcp_send() can silently pass + // through as success. + if (resp->payload_len > resp->payload_cap) return RCP_ERR_INVALID; + return RCP_OK; } diff --git a/include/rcp/cli.hpp b/include/rcp/cli.hpp index 7ec0d94..7be7cc5 100644 --- a/include/rcp/cli.hpp +++ b/include/rcp/cli.hpp @@ -15,20 +15,33 @@ // // Binary name: `cpp-rcp` (§13.2). Build with -DRELAY_BUILD_CLI=ON. // -// Still built on rcp.hpp's pre-replacement Zone/Command/Controller model -// and rcp/legacy_mock.hpp's Registry backing send_one/send_stream's -// in-process demo transport (renamed from rcp/mock.hpp at ROADMAP.md -// milestone 56, v2.12.0 — see rcp/legacy_mock.hpp's header comment). Per -// the Satellite Package Disposition table's entry for `cli.hpp`, rebinding -// `send`'s addressing to the new server/endpoint model is v2.16.0 scope. +// ROADMAP.md milestone 60, "C ABI & CLI Rebuild (v2.16.0)": this header (and +// cli/main.cpp, which needs no change) is ADAPTed in place, per the +// Satellite Package Disposition table's entry for `cli.hpp` — the +// `version`/`capabilities`/`status` triad carries forward unchanged in +// shape, per this milestone's own scope note. `send`'s addressing is +// rebound from `--zone ` to `--server --endpoint +// ` (a server+endpoint identifier, the same pair every +// Phase 14/15 header has keyed on since v2.10.0), and its in-process demo +// backend moves from rcp/legacy_mock.hpp's Zone-keyed Registry to +// rcp::mock::Server (v2.12.0), the new RC Server simulator, addressed by +// byte_bus_id the same way rcp/mock.hpp's own representative endpoint set +// already is. `capabilities_json()`'s `transports`/`features` fields are +// refreshed to reflect the transports (v2.13.0/v2.14.0) and endpoint set +// (v2.3.0/v2.4.0/v2.7.0) that now exist; `optional_interfaces` drops +// `LoaningController`, which Adapt() (rcp/adapt.hpp) no longer wraps since +// it moved from a shared_ptr to a plain RequestFn callable at +// this same milestone. #pragma once #include +#include #include -#include +#include +#include #include -#include +#include #include #include #include @@ -90,10 +103,11 @@ inline std::string capabilities_json() { + "\"version\":\"" + std::string(kVersion) + "\"," + "\"spec_version\":\"" + spec + "\"," + "\"commands\":[\"version\",\"capabilities\",\"status\",\"send\"]," - + "\"transports\":[\"mock\",\"sim\",\"shmem\"]," - + "\"features\":[\"loaning\"]," + + "\"transports\":[\"udp\",\"shmem\",\"mock\"]," + + "\"features\":[\"gpio\",\"spi\",\"i2c\",\"uart\",\"adc\",\"pwm\",\"lin\",\"can\"," + "\"iseled\",\"mdio\",\"wakeup\",\"loaning\"]," + "\"interfaces\":[\"Node\",\"Caller\"]," - + "\"optional_interfaces\":[\"LoaningController\"]," + + "\"optional_interfaces\":[]," + "\"adapt\":true" + "}"; } @@ -125,8 +139,10 @@ inline std::string usage() { " version Print tool and spec version\n" " capabilities Print the RELAY capabilities document (JSON)\n" " status Print self-assessed health\n" - " send --zone --type [--payload ] [--format text|json]\n" - " Dispatch a single command to a zone controller (RELAY §11.2)\n" + " send --server --endpoint --op read|write\n" + " [--evt-op <0-7>] [--payload ] [--format text|json]\n" + " Dispatch a single request to an RC Server endpoint\n" + " (RELAY §11.2); addressed by stream_key + byte_bus_id\n" " send --format json Read relay.Message NDJSON on stdin and publish each\n" " (RELAY §11.2 streaming sink / crossbar spoke)\n"); } @@ -134,7 +150,7 @@ inline std::string usage() { // ── §11.2 streaming JSON sink (crossbar spoke) ─────────────────────────────── // // A minimal JSON reader, sufficient for one relay.Message per NDJSON line. Only -// the fields rcp::message_to_command() consumes are surfaced (id, payload, seq, +// the fields rcp::message_to_request() consumes are surfaced (id, payload, seq, // meta); other fields (protocol, version, timestamp) are accepted and ignored. // b64_decode decodes standard base64 (padding optional). Returns false on a @@ -182,22 +198,27 @@ inline bool hex_decode(const std::string& in, std::vector& out) { return true; } -// cmd_type_from_string parses a §11.2 `send --type` flag value (a case- -// insensitive CommandType name) into a CommandType. Returns false for an -// unrecognised name. -inline bool cmd_type_from_string(const std::string& s, CommandType& out) { - std::string lower; - lower.reserve(s.size()); - for (char c : s) lower.push_back(static_cast(std::tolower(static_cast(c)))); - if (lower == "noop") { out = CommandType::Noop; return true; } - if (lower == "set") { out = CommandType::Set; return true; } - if (lower == "get") { out = CommandType::Get; return true; } - if (lower == "reset") { out = CommandType::Reset; return true; } - if (lower == "watchdog") { out = CommandType::Watchdog; return true; } - if (lower == "sleep") { out = CommandType::Sleep; return true; } - if (lower == "wake") { out = CommandType::Wake; return true; } - if (lower == "update") { out = CommandType::Update; return true; } - return false; +// parse_u64 parses a decimal or "0x"-prefixed hexadecimal uint64_t. Returns +// false on an empty string or any trailing non-numeric character. +inline bool parse_u64(const std::string& s, std::uint64_t& out) { + if (s.empty()) return false; + int base = 10; + const char* begin = s.data(); + const char* end = s.data() + s.size(); + if (s.size() > 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) { + base = 16; + begin += 2; + } + auto [p, ec] = std::from_chars(begin, end, out, base); + return ec == std::errc{} && p == end; +} + +// parse_byte_bus_id parses a decimal or "0x"-prefixed hex value in [0,255]. +inline bool parse_byte_bus_id(const std::string& s, avtp::ByteBusId& out) { + std::uint64_t v = 0; + if (!parse_u64(s, v) || v > 0xFF) return false; + out = static_cast(v); + return true; } // JVal is a minimal parsed JSON value. @@ -350,22 +371,40 @@ inline bool message_from_json(const std::string& line, relay::Message& msg) { return true; } +// dispatch_endpoint routes one ACF standard request to `srv`'s single +// representative endpoint set (rcp::mock::Server, v2.12.0) by byte_bus_id — +// the same routing rcp::mock::Server::dispatch itself performs; a +// not-registered byte_bus_id is reported the same way an unregistered zone +// used to be under the pre-v2.16.0 CLI. +inline std::error_code dispatch_endpoint(mock::Server& srv, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload) { + return srv.dispatch(/*client=*/0, req, payload, out_resp, out_resp_payload); +} + // send_one implements the §11.2 protocol-flags form of `send` for RCP: -// `--zone --type [--payload ]`. It dispatches a single -// Command to the matching in-process mock zone controller — the same mock +// `--server --endpoint --op read|write +// [--evt-op <0-7>] [--payload ]`. It dispatches a single standard +// request to the in-process mock::Server demo backend — the same mock // backend send_stream uses, since cpp-rcp's CLI has no external transport of // its own — and reports the result. Exit: 0 sent, 1 error, 2 invalid args. inline int send_one(const std::vector& args, bool json, std::ostream& out, std::ostream& err) { - std::string zone_flag, type_flag, payload_flag; - bool have_zone = false, have_type = false, have_payload = false; + std::string server_flag, endpoint_flag, op_flag, evt_op_flag, payload_flag; + bool have_server = false, have_endpoint = false, have_op = false, have_payload = false; for (std::size_t i = 1; i < args.size(); ++i) { - if (args[i] == "--zone" && i + 1 < args.size()) { - zone_flag = args[++i]; - have_zone = true; - } else if (args[i] == "--type" && i + 1 < args.size()) { - type_flag = args[++i]; - have_type = true; + if (args[i] == "--server" && i + 1 < args.size()) { + server_flag = args[++i]; + have_server = true; + } else if (args[i] == "--endpoint" && i + 1 < args.size()) { + endpoint_flag = args[++i]; + have_endpoint = true; + } else if (args[i] == "--op" && i + 1 < args.size()) { + op_flag = args[++i]; + have_op = true; + } else if (args[i] == "--evt-op" && i + 1 < args.size()) { + evt_op_flag = args[++i]; } else if (args[i] == "--payload" && i + 1 < args.size()) { payload_flag = args[++i]; have_payload = true; @@ -373,61 +412,80 @@ inline int send_one(const std::vector& args, bool json, ++i; // already validated by format_is_json } } - if (!have_zone || !have_type) { - err << "usage: cpp-rcp send --zone --type " - "[--payload ] [--format text|json]\n"; + if (!have_server || !have_endpoint || !have_op) { + err << "usage: cpp-rcp send --server --endpoint " + "--op read|write [--evt-op <0-7>] [--payload ] [--format text|json]\n"; return kInvalidArgs; } - CommandType type{}; - if (!cmd_type_from_string(type_flag, type)) { - err << "error: invalid --type '" << type_flag << "'\n"; + std::uint64_t stream_key = 0; + if (!parse_u64(server_flag, stream_key)) { + err << "error: invalid --server '" << server_flag << "'\n"; + return kInvalidArgs; + } + avtp::ByteBusId byte_bus_id = 0; + if (!parse_byte_bus_id(endpoint_flag, byte_bus_id)) { + err << "error: invalid --endpoint '" << endpoint_flag << "'\n"; return kInvalidArgs; } + bool write_op; + if (op_flag == "read") write_op = false; + else if (op_flag == "write") write_op = true; + else { + err << "error: invalid --op '" << op_flag << "' (want read|write)\n"; + return kInvalidArgs; + } + std::uint8_t evt_op = 0; + if (!evt_op_flag.empty()) { + std::uint64_t v = 0; + if (!parse_u64(evt_op_flag, v) || v > 7) { + err << "error: invalid --evt-op '" << evt_op_flag << "' (want 0-7)\n"; + return kInvalidArgs; + } + evt_op = static_cast(v); + } std::vector payload; if (have_payload && !hex_decode(payload_flag, payload)) { err << "error: invalid --payload hex\n"; return kInvalidArgs; } - // A zone name that doesn't parse maps to Zone::Unknown, which is not a - // registered zone either — it fails the same lookup below as a genuinely - // unregistered zone, matching send_stream's existing not-found handling. - Zone zone = zone_from_relay_id(zone_flag); + acf::AcfMessageInfo req = acf::make_standard_request(byte_bus_id, /*transaction_num=*/0, + write_op, /*read_size=*/0); + req.evt_op = evt_op; - Command cmd; - cmd.zone = zone; - cmd.type = type; - cmd.payload = payload; + mock::Server srv; + (void)srv.advance_to_rcp_configured(); - legacy_mock::Registry reg; - std::shared_ptr ctrl; - if (reg.lookup(zone, ctrl)) { - err << "send: zone '" << zone_flag << "' not found\n"; - return kError; - } - Response resp; - if (ctrl->send(Context{}, cmd, resp)) { - err << "send: zone " << to_string(zone) << ": send failed\n"; + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = dispatch_endpoint(srv, req, payload, resp, resp_payload); + std::string addr = endpoint_id_to_relay_id(stream_key, byte_bus_id); + if (ec) { + err << "send: " << addr << ": send failed\n"; return kError; } if (json) { - out << "{\"sent\":true,\"zone\":\"" << zone_to_relay_id(zone) - << "\",\"status\":\"" << to_string(resp.status) << "\"}\n"; + out << "{\"sent\":true,\"id\":\"" << addr + << "\",\"response_kind\":" << static_cast(acf::response_kind_of(resp)) << "}\n"; } else { - out << "sent to " << zone_to_relay_id(zone) << " (" << to_string(resp.status) << ")\n"; + out << "sent to " << addr << " (response_kind=" + << static_cast(acf::response_kind_of(resp)) << ")\n"; } return kOk; } // send_stream implements `send --format json` (RELAY §11.2): read relay.Message -// values as NDJSON on `in`, publish each via message_to_command → the matching -// mock zone controller until EOF. Malformed/undeliverable lines are reported and -// skipped (a single bad message must not tear down a crossbar route); only the -// final count is written to `out`. Exit code is always kOk on clean EOF. +// values as NDJSON on `in`, publish each via message_to_request → the shared +// mock::Server demo backend until EOF. Malformed/undeliverable lines are +// reported and skipped (a single bad message must not tear down a crossbar +// route); only the final count is written to `out`. Exit code is always kOk +// on clean EOF. inline int send_stream(std::istream& in, std::ostream& out, std::ostream& err) { - legacy_mock::Registry reg; + mock::Server srv; + (void)srv.advance_to_rcp_configured(); + std::string line; std::size_t sent = 0; while (std::getline(in, line)) { @@ -442,15 +500,19 @@ inline int send_stream(std::istream& in, std::ostream& out, std::ostream& err) { err << "send: skipping malformed message\n"; continue; } - Command cmd = message_to_command(msg); - std::shared_ptr ctrl; - if (reg.lookup(cmd.zone, ctrl)) { - err << "send: zone " << to_string(cmd.zone) << " not found\n"; + std::uint64_t stream_key = 0; + acf::AcfMessageInfo req; + std::vector payload; + if (!message_to_request(msg, stream_key, req, payload)) { + err << "send: skipping message with unparseable id '" << msg.id << "'\n"; continue; } - Response resp; - if (ctrl->send(Context{}, cmd, resp)) { - err << "send: zone " << to_string(cmd.zone) << ": send failed\n"; + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = dispatch_endpoint(srv, req, payload, resp, resp_payload); + if (ec) { + err << "send: " << endpoint_id_to_relay_id(stream_key, req.byte_bus_id) + << ": send failed\n"; continue; } ++sent; @@ -494,19 +556,20 @@ inline int run(const std::vector& args, std::istream& in, bool has_protocol_flags = false; for (std::size_t i = 1; i < args.size(); ++i) { - if (args[i] == "--zone" || args[i] == "--type" || args[i] == "--payload") { + if (args[i] == "--server" || args[i] == "--endpoint" || args[i] == "--op" || + args[i] == "--evt-op" || args[i] == "--payload") { has_protocol_flags = true; break; } } if (has_protocol_flags) { - // §11.2 protocol-flags form: --zone --type [--payload ]. + // §11.2 protocol-flags form: --server/--endpoint/--op[/--evt-op/--payload]. return send_one(args, json, out, err); } if (!json) { err << "usage: cpp-rcp send --format json (NDJSON relay.Message on stdin)\n" - " or: cpp-rcp send --zone --type [--payload ] " - "[--format text|json]\n"; + " or: cpp-rcp send --server --endpoint " + "--op read|write [--evt-op <0-7>] [--payload ] [--format text|json]\n"; return kInvalidArgs; } // §11.2 streaming JSON sink form (crossbar spoke). diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index 5e620e9..907a27b 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.15.1"; +constexpr std::string_view kVersion = "2.16.0"; } // namespace rcp diff --git a/tests/test_capi.cpp b/tests/test_capi.cpp index 8915fff..319df61 100644 --- a/tests/test_capi.cpp +++ b/tests/test_capi.cpp @@ -6,9 +6,18 @@ // fusa:test REQ-CAPI-006 // fusa:test REQ-CAPI-007 // fusa:test REQ-CAPI-008 +// fusa:test REQ-CAPI-009 + +// ROADMAP.md milestone 60 (v2.16.0): entirely rewritten against the v2.16.0 +// C ABI (server+endpoint addressing, rcp_request_fn_t) — see rcp/capi.h's +// and rcp/capi_impl.hpp's own header comments. #include +#include "rcp/acf.hpp" #include "rcp/capi_impl.hpp" +#include "rcp/mock.hpp" + +#include // ── Portable alignment helper ───────────────────────────────────────────────── // Placement-new into a uint8_t[] needs the buffer aligned to at least @@ -25,6 +34,69 @@ RCP_ALIGNED_BUF(uint8_t, reg_buf, 512); RCP_ALIGNED_BUF(uint8_t, ctrl_buf, 64); +namespace { + +// echo_request_fn is a minimal rcp_request_fn_t test double: it copies the +// request's own info/payload back as the response, so a round trip can be +// checked without any real backend. +rcp_err_t echo_request_fn(void* /*userdata*/, const rcp_request_t* req, + rcp_response_t* resp, uint32_t /*timeout_ms*/) { + resp->info = req->info; + resp->info.rsp = 1; + uint32_t n = req->payload_len < resp->payload_cap ? req->payload_len : resp->payload_cap; + if (n > 0 && req->payload) std::memcpy(resp->payload, req->payload, n); + resp->payload_len = n; + return RCP_OK; +} + +// overrun_request_fn deliberately violates the rcp_request_fn_t contract by +// reporting more bytes written than resp->payload_cap allowed. +rcp_err_t overrun_request_fn(void* /*userdata*/, const rcp_request_t* /*req*/, + rcp_response_t* resp, uint32_t /*timeout_ms*/) { + resp->payload_len = resp->payload_cap + 1; + return RCP_OK; +} + +// not_found_request_fn always reports RCP_ERR_NOT_FOUND, unconditionally. +rcp_err_t not_found_request_fn(void* /*userdata*/, const rcp_request_t* /*req*/, + rcp_response_t* /*resp*/, uint32_t /*timeout_ms*/) { + return RCP_ERR_NOT_FOUND; +} + +// mock_server_request_fn bridges an rcp::mock::Server::dispatch() call +// (v2.12.0) into rcp_request_fn_t shape, demonstrating the real-backend +// wiring rcp/capi_impl.hpp's own header comment describes. +rcp_err_t mock_server_request_fn(void* userdata, const rcp_request_t* req, + rcp_response_t* resp, uint32_t /*timeout_ms*/) { + auto* srv = static_cast(userdata); + + rcp::acf::AcfMessageInfo in_info{}; + in_info.acf_msg_type = req->info.acf_msg_type; + in_info.byte_bus_id = req->info.byte_bus_id; + in_info.evt_op = req->info.evt_op; + in_info.op = req->info.op != 0; + in_info.transaction_num = req->info.transaction_num; + in_info.read_size_or_segment_num = req->info.read_size_or_segment_num; + + std::vector in_payload(req->payload, req->payload + req->payload_len); + rcp::acf::AcfMessageInfo out_info; + std::vector out_payload; + auto ec = srv->dispatch(0, in_info, in_payload, out_info, out_payload); + if (ec) return RCP_ERR_INVALID; + + if (out_payload.size() > resp->payload_cap) return RCP_ERR_NOMEM; + std::memcpy(resp->payload, out_payload.data(), out_payload.size()); + resp->payload_len = static_cast(out_payload.size()); + resp->info.acf_msg_type = out_info.acf_msg_type; + resp->info.byte_bus_id = out_info.byte_bus_id; + resp->info.rsp = out_info.rsp ? 1 : 0; + resp->info.err = out_info.err ? 1 : 0; + resp->info.op = out_info.op ? 1 : 0; + return RCP_OK; +} + +} // namespace + TEST_CASE("capi: registry init succeeds with adequate buffer", "[capi]") { rcp_registry_h reg = nullptr; rcp_err_t err = rcp_registry_init(reg_buf, sizeof(reg_buf), ®); @@ -42,7 +114,8 @@ TEST_CASE("capi: registry init fails with small buffer", "[capi]") { TEST_CASE("capi: ctrl init succeeds", "[capi]") { rcp_ctrl_h ctrl = nullptr; - rcp_err_t err = rcp_ctrl_init(RCP_ZONE_FRONT_LEFT, ctrl_buf, sizeof(ctrl_buf), &ctrl); + rcp_err_t err = rcp_ctrl_init(0x1122334455667788ULL, 1, echo_request_fn, nullptr, + ctrl_buf, sizeof(ctrl_buf), &ctrl); REQUIRE(err == RCP_OK); REQUIRE(ctrl != nullptr); } @@ -50,11 +123,18 @@ TEST_CASE("capi: ctrl init succeeds", "[capi]") { TEST_CASE("capi: ctrl init fails with small buffer", "[capi]") { uint8_t tiny[1]; rcp_ctrl_h ctrl = nullptr; - rcp_err_t err = rcp_ctrl_init(RCP_ZONE_FRONT_LEFT, tiny, sizeof(tiny), &ctrl); + rcp_err_t err = rcp_ctrl_init(0, 1, echo_request_fn, nullptr, tiny, sizeof(tiny), &ctrl); REQUIRE(err == RCP_ERR_NOMEM); } -TEST_CASE("capi: registry_add and send", "[capi]") { +TEST_CASE("capi: ctrl init fails with a null request fn", "[capi]") { + RCP_ALIGNED_BUF(uint8_t, buf, 64); + rcp_ctrl_h ctrl = nullptr; + rcp_err_t err = rcp_ctrl_init(0, 1, nullptr, nullptr, buf, sizeof(buf), &ctrl); + REQUIRE(err == RCP_ERR_INVALID); +} + +TEST_CASE("capi: registry_add and send round-trips via echo_request_fn", "[capi]") { rcp_registry_h reg = nullptr; rcp_ctrl_h ctrl = nullptr; @@ -62,28 +142,141 @@ TEST_CASE("capi: registry_add and send", "[capi]") { RCP_ALIGNED_BUF(uint8_t, ctrl_buf2, 64); REQUIRE(rcp_registry_init(reg_buf2, sizeof(reg_buf2), ®) == RCP_OK); - REQUIRE(rcp_ctrl_init(RCP_ZONE_FRONT_LEFT, ctrl_buf2, sizeof(ctrl_buf2), &ctrl) == RCP_OK); + REQUIRE(rcp_ctrl_init(42, 1, echo_request_fn, nullptr, ctrl_buf2, sizeof(ctrl_buf2), &ctrl) == RCP_OK); REQUIRE(rcp_registry_add(reg, ctrl) == RCP_OK); - rcp_command_t cmd{}; - cmd.zone = RCP_ZONE_FRONT_LEFT; - cmd.cmd_type = 1; // Set + rcp_ctrl_h found = nullptr; + REQUIRE(rcp_registry_lookup(reg, 42, 1, &found) == RCP_OK); + REQUIRE(found == ctrl); + + uint8_t in[2] = {0xAB, 0xCD}; + uint8_t out[2] = {0, 0}; + rcp_request_t req{}; + req.stream_key = 42; + req.info.byte_bus_id = 1; + req.info.op = 1; // write + req.payload = in; + req.payload_len = sizeof(in); + rcp_response_t resp{}; + resp.payload = out; + resp.payload_cap = sizeof(out); - auto err = rcp_send(ctrl, &cmd, &resp, 100); + auto err = rcp_send(ctrl, &req, &resp, 100); REQUIRE(err == RCP_OK); - REQUIRE(resp.status == 0); // OK + REQUIRE(resp.payload_len == 2); + REQUIRE(out[0] == 0xAB); + REQUIRE(out[1] == 0xCD); + REQUIRE(resp.info.rsp == 1); + + rcp_registry_close(reg); +} + +TEST_CASE("capi: registry_add rejects a duplicate (stream_key, byte_bus_id)", "[capi]") { + RCP_ALIGNED_BUF(uint8_t, reg_buf3, 512); + RCP_ALIGNED_BUF(uint8_t, ctrl_a, 64); + RCP_ALIGNED_BUF(uint8_t, ctrl_b, 64); + + rcp_registry_h reg = nullptr; + rcp_ctrl_h a = nullptr, b = nullptr; + REQUIRE(rcp_registry_init(reg_buf3, sizeof(reg_buf3), ®) == RCP_OK); + REQUIRE(rcp_ctrl_init(7, 2, echo_request_fn, nullptr, ctrl_a, sizeof(ctrl_a), &a) == RCP_OK); + REQUIRE(rcp_ctrl_init(7, 2, echo_request_fn, nullptr, ctrl_b, sizeof(ctrl_b), &b) == RCP_OK); + + REQUIRE(rcp_registry_add(reg, a) == RCP_OK); + REQUIRE(rcp_registry_add(reg, b) == RCP_ERR_BUSY); + + rcp_registry_close(reg); +} + +TEST_CASE("capi: registry_lookup returns RCP_ERR_NOT_FOUND for an unregistered endpoint", "[capi]") { + RCP_ALIGNED_BUF(uint8_t, reg_buf4, 512); + rcp_registry_h reg = nullptr; + REQUIRE(rcp_registry_init(reg_buf4, sizeof(reg_buf4), ®) == RCP_OK); + + rcp_ctrl_h out = nullptr; + REQUIRE(rcp_registry_lookup(reg, 1, 1, &out) == RCP_ERR_NOT_FOUND); rcp_registry_close(reg); } -TEST_CASE("capi: rcp_send with null ctrl returns invalid", "[capi]") { - rcp_command_t cmd{}; +TEST_CASE("capi: rcp_send with null ctrl/req/resp returns invalid", "[capi]") { + RCP_ALIGNED_BUF(uint8_t, buf, 64); + rcp_ctrl_h ctrl = nullptr; + REQUIRE(rcp_ctrl_init(0, 1, echo_request_fn, nullptr, buf, sizeof(buf), &ctrl) == RCP_OK); + + rcp_request_t req{}; + rcp_response_t resp{}; + REQUIRE(rcp_send(nullptr, &req, &resp, 100) == RCP_ERR_INVALID); + REQUIRE(rcp_send(ctrl, nullptr, &resp, 100) == RCP_ERR_INVALID); + REQUIRE(rcp_send(ctrl, &req, nullptr, 100) == RCP_ERR_INVALID); +} + +TEST_CASE("capi: rcp_send propagates the callback's own error code", "[capi]") { + RCP_ALIGNED_BUF(uint8_t, buf, 64); + rcp_ctrl_h ctrl = nullptr; + REQUIRE(rcp_ctrl_init(0, 1, not_found_request_fn, nullptr, buf, sizeof(buf), &ctrl) == RCP_OK); + + rcp_request_t req{}; + rcp_response_t resp{}; + REQUIRE(rcp_send(ctrl, &req, &resp, 0) == RCP_ERR_NOT_FOUND); +} + +TEST_CASE("capi: rcp_send rejects a callback that overruns payload_cap", "[capi][REQ-CAPI-009]") { + RCP_ALIGNED_BUF(uint8_t, buf, 64); + rcp_ctrl_h ctrl = nullptr; + REQUIRE(rcp_ctrl_init(0, 1, overrun_request_fn, nullptr, buf, sizeof(buf), &ctrl) == RCP_OK); + + uint8_t out[4]; + rcp_request_t req{}; rcp_response_t resp{}; - REQUIRE(rcp_send(nullptr, &cmd, &resp, 100) == RCP_ERR_INVALID); + resp.payload = out; + resp.payload_cap = sizeof(out); + REQUIRE(rcp_send(ctrl, &req, &resp, 0) == RCP_ERR_INVALID); } TEST_CASE("capi: sizeof queries return nonzero", "[capi]") { REQUIRE(rcp_registry_sizeof() > 0); REQUIRE(rcp_ctrl_sizeof() > 0); } + +// ── Real-backend interop: rcp::mock::Server (v2.12.0) via a caller-written +// rcp_request_fn_t adapter ────────────────────────────────────────────────── + +TEST_CASE("capi: rcp_send against a real rcp::mock::Server GPIO endpoint", "[capi][mock]") { + rcp::mock::Server srv; + REQUIRE_FALSE(srv.advance_to_rcp_configured()); + + RCP_ALIGNED_BUF(uint8_t, buf, 64); + rcp_ctrl_h ctrl = nullptr; + REQUIRE(rcp_ctrl_init(0, rcp::mock::kGpioByteBusId, mock_server_request_fn, &srv, + buf, sizeof(buf), &ctrl) == RCP_OK); + + // Write pins 0x00000001 (big-endian 4-byte GPIO payload), then read back. + uint8_t write_payload[4] = {0x00, 0x00, 0x00, 0x01}; + uint8_t out[8] = {}; + + rcp_request_t req{}; + req.info.byte_bus_id = rcp::mock::kGpioByteBusId; + req.info.op = 1; // write + req.payload = write_payload; + req.payload_len = sizeof(write_payload); + + rcp_response_t resp{}; + resp.payload = out; + resp.payload_cap = sizeof(out); + REQUIRE(rcp_send(ctrl, &req, &resp, 0) == RCP_OK); + REQUIRE(resp.payload_len == 4); + REQUIRE(resp.info.err == 0); + + rcp_request_t read_req{}; + read_req.info.byte_bus_id = rcp::mock::kGpioByteBusId; + read_req.info.op = 0; // read + + rcp_response_t read_resp{}; + read_resp.payload = out; + read_resp.payload_cap = sizeof(out); + REQUIRE(rcp_send(ctrl, &read_req, &read_resp, 0) == RCP_OK); + REQUIRE(read_resp.payload_len == 4); + REQUIRE(out[3] == 0x01); +} diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp index 0749794..6ceb16d 100644 --- a/tests/test_cli.cpp +++ b/tests/test_cli.cpp @@ -9,6 +9,14 @@ // Drives rcp::cli::run() directly (no subprocess) and checks the emitted JSON // against the required fields of RELAY spec/schemas/cli-*.json, plus the §11.3 // exit codes. +// +// ROADMAP.md milestone 60 (v2.16.0): the `send` coverage below is rewritten +// against the new `--server --endpoint --op +// read|write` protocol-flags form and the mock::Server-backed streaming +// sink — see rcp/cli.hpp's own header comment. `version`/`capabilities`/ +// `status` coverage is unchanged in shape, per this milestone's own scope +// note, with `capabilities`'s transports/features assertions refreshed to +// the v2.16.0 field values. #include #include @@ -120,6 +128,23 @@ TEST_CASE("cli: capabilities has all required fields and kind", "[cli][conforman REQUIRE(s.find("\"status\"") != std::string::npos); } +TEST_CASE("cli: capabilities transports/features reflect the v2.16.0 rebuild", + "[cli][conformance]") { + int code = 0; + auto s = capture({"capabilities"}, code); + REQUIRE(code == rcp::cli::kOk); + // Transports: the native UDP/IP (v2.13.0), shmem (v2.14.0), and the + // in-process mock::Server (v2.12.0) demo backend `send` dispatches to. + REQUIRE(s.find("\"udp\"") != std::string::npos); + REQUIRE(s.find("\"shmem\"") != std::string::npos); + REQUIRE(s.find("\"mock\"") != std::string::npos); + // Features: a sample of the endpoint set from v2.3.0/v2.4.0/v2.7.0. + REQUIRE(s.find("\"gpio\"") != std::string::npos); + REQUIRE(s.find("\"spi\"") != std::string::npos); + REQUIRE(s.find("\"can\"") != std::string::npos); + REQUIRE(s.find("\"loaning\"") != std::string::npos); +} + // ── §12.3 status ────────────────────────────────────────────────────────────── TEST_CASE("cli: status --format json has all required fields", "[cli][conformance]") { @@ -145,11 +170,14 @@ TEST_CASE("cli: send is declared in capabilities", "[cli][conformance][REQ-CLI-0 } TEST_CASE("cli: send --format json publishes each NDJSON message", "[cli][conformance][REQ-CLI-005]") { - // Three well-formed relay.Message lines addressed to standard zones. + // Three well-formed relay.Message lines addressed to the mock GPIO/SPI + // endpoints (byte_bus_id 1 and 2) via the v2.16.0 "<16 hex>:" id. + // SPI (endpoint 2) accepts any payload length, unlike GPIO's fixed + // 4-byte bitmask, so short payloads are addressed there. const std::string nd = - "{\"protocol\":5,\"id\":\"FrontLeft\",\"payload\":\"AQ==\",\"meta\":{\"rcp.command_type\":\"1\"}}\n" - "{\"protocol\":5,\"id\":\"RearRight\",\"seq\":7}\n" - "{\"protocol\":5,\"id\":\"Central\",\"meta\":{\"rcp.priority\":\"2\"}}\n"; + "{\"protocol\":5,\"id\":\"0000000000000000:2\",\"payload\":\"AQ==\",\"meta\":{\"rcp.op\":\"write\"}}\n" + "{\"protocol\":5,\"id\":\"0000000000000000:2\",\"seq\":7}\n" + "{\"protocol\":5,\"id\":\"0000000000000000:1\",\"meta\":{\"rcp.op\":\"read\"}}\n"; int code = 0; auto out = capture_in({"send", "--format", "json"}, nd, code); REQUIRE(code == rcp::cli::kOk); @@ -160,9 +188,10 @@ TEST_CASE("cli: send skips malformed and undeliverable lines without aborting", "[cli][conformance][REQ-CLI-005]") { const std::string nd = "not json\n" - "{\"id\":\"FrontLeft\"}\n" // deliverable - "{\"id\":\"Nonexistent\"}\n" // unknown zone -> skipped - "\n"; // blank -> skipped + "{\"id\":\"0000000000000000:1\"}\n" // deliverable (GPIO) + "{\"id\":\"0000000000000000:99\"}\n" // unregistered endpoint -> skipped + "{\"id\":\"not-a-valid-id\"}\n" // unparseable id -> skipped + "\n"; // blank -> skipped int code = 0; std::string errout; auto out = capture_in({"send", "--format", "json"}, nd, code, &errout); @@ -178,60 +207,85 @@ TEST_CASE("cli: send without --format json returns invalid-args (2)", REQUIRE(code == rcp::cli::kInvalidArgs); } -TEST_CASE("cli: send base64 payload decodes into the published command", +TEST_CASE("cli: send base64 payload decodes into the published request", "[cli][conformance][REQ-CLI-005]") { - // "AQID" -> bytes {1,2,3}; empty stream variant just checks clean EOF handling. + // "AQID" -> bytes {1,2,3}; SPI (endpoint 2) accepts any payload length. int code = 0; auto out = capture_in({"send", "--format", "json"}, - "{\"id\":\"FrontLeft\",\"payload\":\"AQID\"}\n", code); + "{\"id\":\"0000000000000000:2\",\"payload\":\"AQID\"," + "\"meta\":{\"rcp.op\":\"write\"}}\n", + code); REQUIRE(code == rcp::cli::kOk); REQUIRE(out.find("published 1 message(s)") != std::string::npos); } -// ── §11.2 protocol-flags send: --zone/--type/--payload (RCP row) ────────────── +// ── §11.2 protocol-flags send: --server/--endpoint/--op (RCP row) ───────────── -TEST_CASE("cli: send --zone --type dispatches a single command (text)", +TEST_CASE("cli: send --server --endpoint --op read dispatches a single request (text)", "[cli][conformance][REQ-CLI-005]") { int code = 0; - auto s = capture({"send", "--zone", "FrontLeft", "--type", "set"}, code); + auto s = capture({"send", "--server", "0", "--endpoint", "1", "--op", "read"}, code); REQUIRE(code == rcp::cli::kOk); - REQUIRE(s.find("FrontLeft") != std::string::npos); + REQUIRE(s.find("0000000000000000:1") != std::string::npos); } -TEST_CASE("cli: send --zone --type --payload dispatches with a decoded hex payload (json)", +TEST_CASE("cli: send --server --endpoint --op write --payload dispatches (json)", "[cli][conformance][REQ-CLI-005]") { int code = 0; - auto s = capture({"send", "--zone", "RearRight", "--type", "get", + auto s = capture({"send", "--server", "0x2A", "--endpoint", "2", "--op", "write", "--payload", "0102", "--format", "json"}, code); REQUIRE(code == rcp::cli::kOk); REQUIRE(s.find("\"sent\":true") != std::string::npos); - REQUIRE(s.find("\"zone\":\"RearRight\"") != std::string::npos); + REQUIRE(s.find("\"id\":\"000000000000002a:2\"") != std::string::npos); } -TEST_CASE("cli: send missing --type returns invalid-args (2)", +TEST_CASE("cli: send missing --op returns invalid-args (2)", "[cli][conformance][REQ-CLI-005]") { int code = 0; - capture({"send", "--zone", "FrontLeft"}, code); + capture({"send", "--server", "0", "--endpoint", "1"}, code); REQUIRE(code == rcp::cli::kInvalidArgs); } -TEST_CASE("cli: send with an unrecognised --type returns invalid-args (2)", +TEST_CASE("cli: send with an unrecognised --op returns invalid-args (2)", "[cli][conformance][REQ-CLI-005]") { int code = 0; - capture({"send", "--zone", "FrontLeft", "--type", "bogus"}, code); + capture({"send", "--server", "0", "--endpoint", "1", "--op", "bogus"}, code); REQUIRE(code == rcp::cli::kInvalidArgs); } TEST_CASE("cli: send with malformed --payload hex returns invalid-args (2)", "[cli][conformance][REQ-CLI-005]") { int code = 0; - capture({"send", "--zone", "FrontLeft", "--type", "set", "--payload", "zz"}, code); + capture({"send", "--server", "0", "--endpoint", "1", "--op", "write", + "--payload", "zz"}, code); + REQUIRE(code == rcp::cli::kInvalidArgs); +} + +TEST_CASE("cli: send with an out-of-range --evt-op returns invalid-args (2)", + "[cli][conformance][REQ-CLI-005]") { + int code = 0; + capture({"send", "--server", "0", "--endpoint", "1", "--op", "read", + "--evt-op", "8"}, code); REQUIRE(code == rcp::cli::kInvalidArgs); } -TEST_CASE("cli: send to an unknown zone returns a protocol error (1)", +TEST_CASE("cli: send to an unregistered endpoint returns a protocol error (1)", "[cli][conformance][REQ-CLI-005]") { int code = 0; - capture({"send", "--zone", "Nonexistent", "--type", "set"}, code); + capture({"send", "--server", "0", "--endpoint", "99", "--op", "read"}, code); REQUIRE(code == rcp::cli::kError); } + +TEST_CASE("cli: send with an invalid --server value returns invalid-args (2)", + "[cli][conformance][REQ-CLI-005]") { + int code = 0; + capture({"send", "--server", "not-a-number", "--endpoint", "1", "--op", "read"}, code); + REQUIRE(code == rcp::cli::kInvalidArgs); +} + +TEST_CASE("cli: send with an invalid --endpoint value returns invalid-args (2)", + "[cli][conformance][REQ-CLI-005]") { + int code = 0; + capture({"send", "--server", "0", "--endpoint", "300", "--op", "read"}, code); + REQUIRE(code == rcp::cli::kInvalidArgs); +} diff --git a/tests/test_relay.cpp b/tests/test_relay.cpp index 1fc180c..a75fb0a 100644 --- a/tests/test_relay.cpp +++ b/tests/test_relay.cpp @@ -8,10 +8,17 @@ // // Verifies that cpp-RCP satisfies the mandatory RELAY-conformance requirements // as enumerated in Appendix A of the RELAY spec. +// +// ROADMAP.md milestone 60 (v2.16.0): the §10.3/§15.7.5 sections below +// (Adapt(), endpoint-id round trips) are entirely rewritten against +// rcp/adapt.hpp's v2.16.0 RequestFn-based redesign — see that header's own +// comment. The general §3/§5/§14/§18.2/§19.4 relay:: conformance sections +// above them are unaffected by this milestone and unchanged. #include +#include #include -#include +#include #include #include @@ -140,135 +147,172 @@ TEST_CASE("relay: Channel recv returns nullopt after close with empty queue", "[ REQUIRE_FALSE(ch.recv().has_value()); } -// ── §10.3: Adapt() wraps Controller as relay::Caller ───────────────────────── +// ── §10.3: Adapt() wraps a RequestFn as relay::Caller ──────────────────────── +// mock_request_fn wires a fresh rcp::mock::Server (v2.12.0) as the RequestFn +// every test case below adapts — the same "client-side send-equivalent +// call" shape rcp/record.hpp's and rcp/observe.hpp's own RequestFn already +// standardize on (v2.14.0). + +namespace { +std::shared_ptr make_configured_mock_server() { + auto srv = std::make_shared(); + srv->advance_to_rcp_configured(); + return srv; +} + +rcp::RequestFn mock_request_fn(std::shared_ptr srv) { + return [srv](const rcp::Context&, const rcp::acf::AcfMessageInfo& req, + const std::vector& payload, + rcp::acf::AcfMessageInfo& out, std::vector& out_payload) { + return srv->dispatch(0, req, payload, out, out_payload); + }; +} +} // namespace TEST_CASE("relay: Adapt() returns non-null relay::Caller", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); - auto caller = rcp::Adapt(ctrl); + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); REQUIRE(caller != nullptr); } TEST_CASE("relay: Adapt() protocol() returns RCP", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); - auto caller = rcp::Adapt(ctrl); + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); REQUIRE(caller->protocol() == relay::Protocol::RCP); } -TEST_CASE("relay: Adapt() call() sends command and returns response", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); - auto caller = rcp::Adapt(ctrl); +TEST_CASE("relay: Adapt() call() sends a request and returns the response", "[relay][adapt]") { + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); relay::Message req; - req.protocol = relay::Protocol::RCP; - req.id = "FrontLeft"; - req.meta["rcp.cmd_type"] = "get"; + req.protocol = relay::Protocol::RCP; + req.id = rcp::endpoint_id_to_relay_id(0, rcp::mock::kGpioByteBusId); + req.meta["rcp.op"] = "read"; auto ctx = relay::Context::with_timeout(1s); auto [resp, ec] = caller->call(ctx, req); REQUIRE_FALSE(ec); - REQUIRE(resp.id == "FrontLeft"); + REQUIRE(resp.id == req.id); REQUIRE(resp.protocol == relay::Protocol::RCP); } +TEST_CASE("relay: Adapt() call() reports invalid_argument for an unparseable id", + "[relay][adapt]") { + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); + + relay::Message req; + req.id = "not-a-valid-id"; + + auto ctx = relay::Context::with_timeout(1s); + auto [resp, ec] = caller->call(ctx, req); + REQUIRE(ec == std::errc::invalid_argument); + REQUIRE(resp.id.empty()); +} + TEST_CASE("relay: Adapt() send() succeeds", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); - auto caller = rcp::Adapt(ctrl); + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); relay::Message msg; - msg.id = "FrontLeft"; - msg.meta["rcp.cmd_type"] = "set"; + msg.id = rcp::endpoint_id_to_relay_id(0, rcp::mock::kSpiByteBusId); + msg.meta["rcp.op"] = "write"; auto ctx = relay::Context::with_timeout(1s); REQUIRE_FALSE(caller->send(ctx, msg)); } -TEST_CASE("relay: Adapt() subscribe() returns valid channel", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); - auto caller = rcp::Adapt(ctrl); +// subscribe() has no analog in the target specification's request/response +// shape — see rcp/adapt.hpp's own header comment and rcp/mqttbr.hpp's +// (v2.15.0) equivalent note for the seven ADAPTed application bridges. +TEST_CASE("relay: Adapt() subscribe() reports function_not_supported", "[relay][adapt]") { + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); auto [ch, ec] = caller->subscribe(); - REQUIRE_FALSE(ec); - REQUIRE(ch != nullptr); - - // Publish a status and verify it arrives via the relay channel. - ctrl->publish({0x01, 0x02}); - - relay::Message msg; - bool got = false; - auto deadline = std::chrono::steady_clock::now() + 500ms; - while (std::chrono::steady_clock::now() < deadline) { - auto m = ch->try_recv(); - if (m) { - msg = *m; - got = true; - break; - } - std::this_thread::sleep_for(1ms); - } - - REQUIRE(got); - REQUIRE(msg.protocol == relay::Protocol::RCP); - REQUIRE(msg.id == "FrontLeft"); - REQUIRE(msg.meta.count("rcp.healthy") == 1); + REQUIRE(ch == nullptr); + REQUIRE(ec == std::errc::function_not_supported); } TEST_CASE("relay: Adapt() close() idempotent", "[relay][adapt]") { - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); - auto caller = rcp::Adapt(ctrl); + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); REQUIRE_FALSE(caller->close()); REQUIRE_FALSE(caller->close()); // second call must be no-op } -// ── §15.7.5: zone_to_relay_id / zone_from_relay_id round-trips ─────────────── - -TEST_CASE("relay: zone round-trips through relay ID", "[relay][conform]") { - using rcp::Zone; - for (auto z : {Zone::FrontLeft, Zone::FrontRight, - Zone::RearLeft, Zone::RearRight, Zone::Central}) { - REQUIRE(rcp::zone_from_relay_id(rcp::zone_to_relay_id(z)) == z); +// ── §15.7.5: endpoint_id_to_relay_id / relay_id_to_endpoint_id round-trips ─── + +TEST_CASE("relay: server+endpoint id round-trips through relay ID", "[relay][conform]") { + for (auto pair : {std::pair{uint64_t{0}, rcp::avtp::ByteBusId{1}}, + std::pair{uint64_t{0x1122334455667788ULL}, rcp::avtp::ByteBusId{2}}, + std::pair{uint64_t{42}, rcp::avtp::ByteBusId{255}}}) { + auto id = rcp::endpoint_id_to_relay_id(pair.first, pair.second); + uint64_t stream_key = 0; + rcp::avtp::ByteBusId byte_bus_id = 0; + REQUIRE(rcp::relay_id_to_endpoint_id(id, stream_key, byte_bus_id)); + REQUIRE(stream_key == pair.first); + REQUIRE(byte_bus_id == pair.second); } } -// ── v0.3 golden vector: status_to_message matches RELAY spec/vectors ───────── -// -// Pins rcp::status_to_message() to the canonical reference in -// RELAY spec/vectors/rcp-status.json (v0.3). The vector's `value` is the -// rcp.Status input; its `message` is the expected relay.Message output. -// Mandatory fields (protocol, id, payload, seq, meta["rcp.healthy"]) MUST match -// losslessly; timestamp is "ignored on receive" per §15.7 and is not pinned. - -TEST_CASE("relay: status_to_message matches rcp-status golden vector (v0.3)", - "[relay][conformance][vectors]") { - // From spec/vectors/rcp-status.json `value`: - // zone=1 (FrontLeft), seq=3, healthy=true, payload="AQ==" (== byte 0x01) - rcp::Status s; - s.zone = rcp::Zone::FrontLeft; - s.seq = 3; - s.healthy = true; - s.payload = {0x01}; +TEST_CASE("relay: relay_id_to_endpoint_id rejects the pre-v2.16.0 zone-name form", + "[relay][conform]") { + uint64_t stream_key = 0; + rcp::avtp::ByteBusId byte_bus_id = 0; + REQUIRE_FALSE(rcp::relay_id_to_endpoint_id("FrontLeft", stream_key, byte_bus_id)); +} - auto msg = rcp::status_to_message(s); +// ── §15.7.5: response_to_message / message_to_request mapping ──────────────── +// +// This implementation's own mapping from an ACF response (rcp/acf.hpp) to a +// relay::Message — not an externally pinned golden vector, since the +// pre-v2.16.0 Status/subscribe push model this milestone's own scope note +// retires has no analog to pin one against. Full bit-for-bit conformance of +// this encoding against any other implementation is not claimed, same as +// the equivalent disclaimers in rcp/avtp.hpp, rcp/acf.hpp, and rcp/udp.hpp. + +TEST_CASE("relay: response_to_message carries the endpoint id, payload, and response_kind", + "[relay][conformance]") { + rcp::acf::AcfMessageInfo resp; + resp.byte_bus_id = 7; + resp.rsp = true; + resp.op = false; // ReadResponse + + auto msg = rcp::response_to_message(0x2A, resp, {0x01, 0x02}); + + REQUIRE(static_cast(msg.protocol) == 5); // relay.RCP + REQUIRE(msg.id == "000000000000002a:7"); + REQUIRE(msg.payload == std::vector{0x01, 0x02}); + REQUIRE(msg.meta.at("rcp.err") == "false"); + REQUIRE(msg.meta.at("rcp.response_kind") == + std::to_string(static_cast(rcp::acf::ResponseKind::ReadResponse))); +} - // Expected `message` fields from the golden vector: - REQUIRE(static_cast(msg.protocol) == 5); // relay.RCP - REQUIRE(msg.id == "FrontLeft"); - REQUIRE(msg.seq == 3); - REQUIRE(msg.payload == std::vector{0x01}); // base64 "AQ==" - REQUIRE(msg.meta.at("rcp.healthy") == "true"); +TEST_CASE("relay: message_to_request decodes op/evt_op from meta", "[relay][conformance]") { + relay::Message msg; + msg.id = rcp::endpoint_id_to_relay_id(9, 3); + msg.meta["rcp.op"] = "write"; + msg.meta["rcp.evt_op"] = "5"; + msg.payload = {0xAA}; + + uint64_t stream_key = 0; + rcp::acf::AcfMessageInfo req; + std::vector payload; + REQUIRE(rcp::message_to_request(msg, stream_key, req, payload)); + REQUIRE(stream_key == 9); + REQUIRE(req.byte_bus_id == 3); + REQUIRE(req.op == true); + REQUIRE(req.evt_op == 5); + REQUIRE(payload == std::vector{0xAA}); } -// ── §14.1 (v0.3): SubscriberOptions carries topic_name; RCP ignores it ─────── +// ── §14.1 (v0.3): SubscriberOptions carries topic_name ─────────────────────── TEST_CASE("relay: SubscriberOptions has topic_name field, default empty", "[relay][conformance]") { relay::SubscriberOptions opts; REQUIRE(opts.topic_name.empty()); - // RCP adapter must ignore topic_name (only DDS routes on it, §14.1). - auto ctrl = std::make_shared(rcp::Zone::FrontLeft); - auto caller = rcp::Adapt(ctrl); + // subscribe() reports function_not_supported regardless of topic_name — + // see this file's own §10.3 section above. + auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); opts.topic_name = "ignored-by-rcp"; auto [ch, ec] = caller->subscribe(opts); - REQUIRE_FALSE(ec); - REQUIRE(ch != nullptr); + REQUIRE(ch == nullptr); + REQUIRE(ec == std::errc::function_not_supported); }