diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 4b21ee8..727034c 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -754,96 +754,96 @@ }, { "id": "REQ-UDP-001", - "title": "Header magic bytes 'RC'", - "text": "All wire frames shall begin with the two-byte sequence 0x52 ('R'), 0x43 ('C').", + "title": "Frame round-trips an NTSCF-framed ACF_ABB request", + "text": "rcp::udp::encode_frame/decode_frame shall round-trip a Frame carrying an NTSCF header (rcp::avtp::NtscfHeader) wrapping an ACF_ABB message (rcp::acf::AcfMessageInfo) unchanged: stream_id, sequence_num, the request's byte_bus_id/transaction_num/op, and its payload shall all be recoverable from the encoded bytes.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-002", - "title": "Protocol version field", - "text": "All wire frames shall carry the protocol version (currently 0x01) in byte 2.", + "title": "Frame round-trips a TSCF-framed request with avtp_timestamp", + "text": "rcp::udp::encode_frame/decode_frame shall round-trip a Frame carrying a TSCF header (rcp::avtp::TscfHeader), including its timestamp_valid flag and 32-bit avtp_timestamp, unchanged.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-003", - "title": "Message type field", - "text": "Byte 3 of every wire frame shall encode the message type: Command, Response, Status, Subscribe, or Unsubscribe.", + "title": "Frame round-trips an ACF_GBB message_timestamp", + "text": "rcp::udp::encode_frame/decode_frame shall round-trip a Frame whose AcfMessageInfo::acf_msg_type is kAcfMsgTypeGbb, including its mtv flag, 64-bit message_timestamp, and payload, unchanged.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-004", - "title": "Zone field", - "text": "Bytes 4 of every wire frame shall encode the target Zone as a uint8.", + "title": "decode_frame rejects a buffer shorter than the AVTPDU header", + "text": "rcp::udp::decode_frame shall return a non-zero std::error_code when given fewer bytes than the AVTPDU header (NTSCF or TSCF, selected by the subtype byte) requires.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-005", - "title": "CommandType field for commands", - "text": "Bytes 5\u20136 of Command frames shall encode the CommandType as a big-endian uint16.", + "title": "decode_frame rejects a buffer shorter than the ACF common header", + "text": "rcp::udp::decode_frame shall return a non-zero std::error_code when the AVTPDU header decodes successfully but fewer bytes than the 10-byte ACF common header (rcp::acf::kAcfCommonHeaderLen) remain.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-006", - "title": "Priority/Status field", - "text": "Byte 7 shall encode Priority for Command frames, ResponseStatus for Response frames, and Healthy flag for Status frames.", + "title": "decode_frame rejects an unrecognized ACF message type", + "text": "rcp::udp::decode_frame shall return a non-zero std::error_code when the byte immediately following a valid AVTPDU header is neither rcp::acf::kAcfMsgTypeAbb nor rcp::acf::kAcfMsgTypeGbb.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-007", - "title": "Command/response ID field", - "text": "Bytes 8\u201311 shall encode the Command or Response ID as a big-endian uint32.", + "title": "Server dispatches a decoded request through its handler and answers the sender", + "text": "rcp::udp::Server shall decode each inbound UDP datagram as a Frame, invoke its registered Handler with the decoded AcfMessageInfo and payload, and send the handler's response back to the originating sender address, framed under the same NTSCF/TSCF header kind the request arrived under.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-008", - "title": "Payload length field", - "text": "Bytes 12\u201315 shall encode the payload byte count as a big-endian uint32.", + "title": "Server answers Acknowledge by default when no handler is registered", + "text": "rcp::udp::Server shall respond to a decoded request with rcp::acf::ResponseKind::Acknowledge when no Handler has been set via set_handler.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-009", - "title": "Payload follows header", - "text": "The variable-length payload shall immediately follow the 16-byte header.", + "title": "Server assigns stable per-sender client ids", + "text": "rcp::udp::Server shall pass the same opaque client id to its Handler for every request originating from the same sender address, and a distinct id for a request from a different sender address.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-010", - "title": "Big-endian multi-byte fields", - "text": "All multi-byte fields in wire frames shall be encoded in big-endian (network) byte order.", + "title": "Client::request returns ErrTimeout when no response arrives", + "text": "rcp::udp::Client::request shall return rcp::ErrTimeout when the supplied rcp::Context's deadline elapses before a matching response frame is received.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-011", - "title": "Frame validation rejects bad magic", - "text": "The decoder shall return an error if frame bytes 0\u20131 are not 0x52, 0x43.", + "title": "Client::request correlates concurrent requests by byte_bus_id/transaction_num", + "text": "rcp::udp::Client::request shall match each inbound response frame to the pending request whose AcfMessageInfo::byte_bus_id and AcfMessageInfo::transaction_num it echoes, so that two outstanding requests with distinct (byte_bus_id, transaction_num) pairs each receive their own response.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-UDP-012", - "title": "Frame validation rejects bad version", - "text": "The decoder shall return an error if the version byte does not equal the supported protocol version.", + "title": "Server and Client close() are idempotent and requests after close fail", + "text": "rcp::udp::Server::close and rcp::udp::Client::close shall be safe to call more than once without blocking indefinitely or crashing, and rcp::udp::Client::request shall return rcp::ErrClosed once the Client has been closed.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a3e5d1..6be7b75 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.12.0 + VERSION 2.13.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 18e9782..3ac015e 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,12 @@ reference. | Header | Description | |---|---| -| `` | Pure-C++ UDP transport for the RCP protocol (still framed via `` until v2.13.0) | +| `` | Native IEEE 1722-over-UDP/IP transport (Annex J): `Server`/`Client` carry AVTPDU (NTSCF/TSCF) + ACF_ABB/ACF_GBB frames over UDP sockets (ROADMAP.md v2.13.0) | | `` | Mutual TLS transport for zone controller communication | | `` | Zero-copy intra-host command delivery via shared in-process memory | | `` | IEEE 802.1Qbv-aware UDP transport adapter for hard real-time Ethernet | | `` | TC18 wire codec, framing half — IEEE 1722 AVTPDU (NTSCF/TSCF) header framing (ROADMAP.md v2.0.0) | | `` | TC18 wire codec, message half — ACF_ABB/ACF_GBB message format (ROADMAP.md v2.0.0) | -| `` | Pre-replacement 16-byte frame codec, kept only for ``'s internal use until it is rebuilt at v2.13.0 | | `` | Timing-realistic RC Server simulator for SiL/HIL testing — wraps `` with latency/jitter and Fault/Recover controls, watchdog wired via `` (ROADMAP.md v2.12.0) | ## Build diff --git a/ROADMAP.md b/ROADMAP.md index 6f625da..e031a56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1017,6 +1017,45 @@ disclaimers in `rcp/regmap.hpp`, `rcp/lifecycle.hpp`, `rcp/gpio.hpp`, and it *is* the old wire format this whole roadmap replaces - Depends on: Wire format core (v2.0.0) +**Done (v2.13.0):** `rcp/udp.hpp` is replaced in full, per the Satellite +Package Disposition table's entry for this file — the bespoke `R`/`C`-magic +16-byte Zone/Command/Response/Status frame this file used to carry +(`rcp/legacy_wire.hpp`) is discarded outright, not adapted, matching the +roadmap's own call for this milestone. `udp::Frame`/`encode_frame`/ +`decode_frame` compose `rcp/avtp.hpp`'s NTSCF/TSCF header codec with +`rcp/acf.hpp`'s ACF_ABB/ACF_GBB message codec into one AVTPDU, carried as +the UDP datagram payload unmodified — this implementation's own reading of +IEEE 1722 Annex J's UDP/IP encapsulation, consistent with `rcp/avtp.hpp`'s +own header comment that its framing is transport-agnostic by design; no +additional encapsulation header is layered on top. `udp::Server` binds a UDP +socket, decodes each inbound datagram as a `Frame`, dispatches the carried +ACF request to a caller-supplied `Handler` shaped to match +`rcp::mock::Server::dispatch`'s signature (v2.12.0) so that simulator can be +wired up as this transport's handler directly, and answers the sender under +the same NTSCF/TSCF header kind the request arrived under; distinct sender +addresses are assigned stable, distinct opaque client ids, first-seen order. +`udp::Client` connects to one server address and correlates each response by +the `(byte_bus_id, transaction_num)` echo rule `rcp/acf.hpp`'s +`make_response` documents, rather than a locally invented request id — the +new addressing model has no analog of the old `Command::id`. Neither class +builds on `rcp.hpp`'s `Zone`/`Command`/`Controller`/`Registry` model, per +that file's own header comment that nothing new should; `udp::Registry`'s +old Zone-keyed controller-collection role has no replacement here since +addressing moved to stream/byte_bus_id. Grepping the tree for consumers of +the old `udp::ZoneServer`/`udp::Controller`/`udp::Registry` API before this +change found none beyond this file's own (now-deleted) test and +`rcp/tsn.hpp`'s doc comment — `rcp/tsn.hpp` itself only ever depended on the +generic `rcp::Controller` interface plus a raw socket fd, never on `udp::` +types directly — so no legacy-shim split file was needed here, unlike +`rcp/mock.hpp`'s at v2.12.0; `rcp/legacy_wire.hpp` is deleted outright in the +same change. New coverage lives in `tests/test_udp.cpp` (`REQ-UDP-001..012`, +entirely rewritten under the same file/prefix identity as the discarded +pre-replacement coverage, the same convention `tests/test_sim.cpp`'s +`REQ-SIM-001..007` rewrite followed at v2.12.0), traced in `.fusa-reqs.json`. +Full bit-for-bit conformance against other TC18/Annex-J implementations is +not claimed, same as the equivalent disclaimers in `rcp/avtp.hpp`, +`rcp/acf.hpp`, and `rcp/discovery.hpp`. + ### 58. Auxiliary Transport & Cross-Cutting Rebind (v2.14.0) - `mdns.hpp`: narrow its scope to the UDP/IP transport variant specifically diff --git a/include/rcp/avtp.hpp b/include/rcp/avtp.hpp index 9cfe810..ba3d2cf 100644 --- a/include/rcp/avtp.hpp +++ b/include/rcp/avtp.hpp @@ -11,12 +11,14 @@ // // ROADMAP.md milestone 44, "Wire Format Core (v2.0.0)": this header (plus // rcp/acf.hpp) replaces the old bespoke 16-byte frame this file used to -// define (now preserved as-is, unrenamed in behavior, under -// rcp/legacy_wire.hpp for rcp/udp.hpp's benefit until the transport itself -// is rebuilt at v2.13.0). Originally landed as a single rcp/wire.hpp; split -// into rcp/avtp.hpp (this file, AVTPDU header framing) and rcp/acf.hpp -// (ACF_ABB/ACF_GBB message format) per RELAY spec §13.7.2's standard -// module-name registry, which names these two concerns separately. +// define (preserved as-is, unrenamed in behavior, under rcp/legacy_wire.hpp +// for rcp/udp.hpp's benefit from v2.0.0 through v2.12.0, until rcp/udp.hpp +// was itself rebuilt directly on this header and rcp/acf.hpp — and +// rcp/legacy_wire.hpp deleted outright — at milestone 57, v2.13.0). +// Originally landed as a single rcp/wire.hpp; split into rcp/avtp.hpp (this +// file, AVTPDU header framing) and rcp/acf.hpp (ACF_ABB/ACF_GBB message +// format) per RELAY spec §13.7.2's standard module-name registry, which +// names these two concerns separately. // // Scope note: this module is a pure wire codec. It has no dependency on // rcp.hpp's Zone/Command/Controller/Registry model, and no dependency on any diff --git a/include/rcp/legacy_wire.hpp b/include/rcp/legacy_wire.hpp deleted file mode 100644 index 3257424..0000000 --- a/include/rcp/legacy_wire.hpp +++ /dev/null @@ -1,232 +0,0 @@ -// fusa:req REQ-UDP-001 -// fusa:req REQ-UDP-002 -// fusa:req REQ-UDP-003 -// fusa:req REQ-UDP-004 -// fusa:req REQ-UDP-005 -// fusa:req REQ-UDP-006 -// fusa:req REQ-UDP-007 -// fusa:req REQ-UDP-008 -// fusa:req REQ-UDP-009 -// fusa:req REQ-UDP-010 -// fusa:req REQ-UDP-011 -// fusa:req REQ-UDP-012 - -// Binary frame codec for the pre-replacement Zone/Command/Response/Status -// model, used today only as the internal frame format of rcp/udp.hpp. -// -// This is the old bespoke 16-byte header that this module (then named -// rcp/wire.hpp) carried before the TC18 protocol-replacement roadmap -// (ROADMAP.md, "44. Wire Format Core (v2.0.0)"). The new format has been -// built as the IEEE 1722 AVTPDU/ACF codec, in rcp/avtp.hpp and rcp/acf.hpp -// (split per RELAY spec §13.7.2's standard module-name registry); this file -// exists solely so rcp/udp.hpp keeps building -// and working against the still-current Zone/Command/Response/Status types -// until the Phase 13/14 model replacement (v2.1.0 onward) removes them and -// rcp/udp.hpp is rebuilt against the new wire format (v2.13.0, per the -// disposition table). Do not build new functionality on top of this file — -// it is a deliberately temporary bridge, not a second wire format going -// forward. -// -// Frame layout (all multi-byte fields big-endian): -// [0] Magic 'R' -// [1] Magic 'C' -// [2] Protocol version (0x01) -// [3] Message type (TypeCommand / TypeResponse / TypeStatus / …) -// [4] Zone (uint8) -// [5:7] CommandType or 0x0000 (uint16) -// [7] Priority, ResponseStatus, or Healthy flag (uint8) -// [8:12] Command/response ID or Status seq (uint32) -// [12:16] Payload length (uint32) -// [16:] Payload (variable) -#pragma once - -#include "rcp.hpp" - -#include -#include - -namespace rcp { -namespace legacy_wire { - -constexpr uint8_t MagicByte0 = 0x52; // 'R' -constexpr uint8_t MagicByte1 = 0x43; // 'C' -constexpr uint8_t ProtoVer = 0x01; - -constexpr uint8_t TypeCommand = 0x01; -constexpr uint8_t TypeResponse = 0x02; -constexpr uint8_t TypeStatus = 0x03; -constexpr uint8_t TypeSubscribe = 0x04; -constexpr uint8_t TypeUnsubscribe = 0x05; - -constexpr size_t HeaderLen = 16; -constexpr size_t MaxPayload = 65507 - HeaderLen; // fits in one UDP datagram - -// ── Codec errors ────────────────────────────────────────────────────────────── - -enum class WireErrc : int { - short_frame = 1, - bad_magic = 2, - bad_version = 3, -}; - -inline const std::error_category& wire_category() noexcept { - struct Cat : std::error_category { - const char* name() const noexcept override { return "rcp.legacy_wire"; } - std::string message(int ev) const override { - switch (static_cast(ev)) { - case WireErrc::short_frame: return "rcp/legacy_wire: frame too short"; - case WireErrc::bad_magic: return "rcp/legacy_wire: bad magic bytes"; - case WireErrc::bad_version: return "rcp/legacy_wire: unsupported protocol version"; - default: return "rcp/legacy_wire: unknown error"; - } - } - }; - static Cat instance; - return instance; -} - -inline std::error_code make_error_code(WireErrc e) noexcept { - return {static_cast(e), wire_category()}; -} - -// ── Internal helpers ────────────────────────────────────────────────────────── - -namespace detail { - -inline void put_u16(uint8_t* p, uint16_t v) noexcept { - p[0] = static_cast(v >> 8); - p[1] = static_cast(v & 0xFF); -} -inline void put_u32(uint8_t* p, uint32_t v) noexcept { - p[0] = static_cast(v >> 24); - p[1] = static_cast((v >> 16) & 0xFF); - p[2] = static_cast((v >> 8) & 0xFF); - p[3] = static_cast(v & 0xFF); -} -inline uint16_t get_u16(const uint8_t* p) noexcept { - return static_cast((uint16_t(p[0]) << 8) | p[1]); -} -inline uint32_t get_u32(const uint8_t* p) noexcept { - return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | - (uint32_t(p[2]) << 8) | uint32_t(p[3]); -} - -} // namespace detail - -// ── Header validation ───────────────────────────────────────────────────────── - -inline std::error_code validate_header(const uint8_t* b, size_t len) noexcept { - if (len < HeaderLen) - return make_error_code(WireErrc::short_frame); - if (b[0] != MagicByte0 || b[1] != MagicByte1) - return make_error_code(WireErrc::bad_magic); - if (b[2] != ProtoVer) - return make_error_code(WireErrc::bad_version); - return {}; -} - -// ── Command encode / decode ─────────────────────────────────────────────────── - -inline std::vector encode_command(const Command& cmd) { - const size_t n = HeaderLen + cmd.payload.size(); - std::vector buf(n); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = TypeCommand; - buf[4] = static_cast(cmd.zone); - detail::put_u16(&buf[5], static_cast(cmd.type)); - buf[7] = static_cast(cmd.priority); - detail::put_u32(&buf[8], cmd.id); - detail::put_u32(&buf[12], static_cast(cmd.payload.size())); - std::copy(cmd.payload.begin(), cmd.payload.end(), buf.begin() + HeaderLen); - return buf; -} - -inline std::error_code decode_command(const uint8_t* b, size_t len, Command& out) { - if (auto ec = validate_header(b, len)) return ec; - uint32_t body_len = detail::get_u32(&b[12]); - if (len < HeaderLen + body_len) - return make_error_code(WireErrc::short_frame); - out.zone = static_cast(b[4]); - out.type = static_cast(detail::get_u16(&b[5])); - out.priority = static_cast(b[7]); - out.id = detail::get_u32(&b[8]); - out.payload.assign(b + HeaderLen, b + HeaderLen + body_len); - return {}; -} - -// ── Response encode / decode ────────────────────────────────────────────────── - -inline std::vector encode_response(const Response& resp) { - const size_t n = HeaderLen + resp.payload.size(); - std::vector buf(n); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = TypeResponse; - buf[4] = static_cast(resp.zone); - detail::put_u16(&buf[5], 0); - buf[7] = static_cast(resp.status); - detail::put_u32(&buf[8], resp.command_id); - detail::put_u32(&buf[12], static_cast(resp.payload.size())); - std::copy(resp.payload.begin(), resp.payload.end(), buf.begin() + HeaderLen); - return buf; -} - -inline std::error_code decode_response(const uint8_t* b, size_t len, Response& out) { - if (auto ec = validate_header(b, len)) return ec; - uint32_t body_len = detail::get_u32(&b[12]); - if (len < HeaderLen + body_len) - return make_error_code(WireErrc::short_frame); - out.zone = static_cast(b[4]); - out.status = static_cast(b[7]); - out.command_id = detail::get_u32(&b[8]); - out.payload.assign(b + HeaderLen, b + HeaderLen + body_len); - return {}; -} - -// ── Status encode / decode ──────────────────────────────────────────────────── - -inline std::vector encode_status(const Status& st) { - const size_t n = HeaderLen + st.payload.size(); - std::vector buf(n); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = TypeStatus; - buf[4] = static_cast(st.zone); - detail::put_u16(&buf[5], 0); - buf[7] = st.healthy ? 1u : 0u; - detail::put_u32(&buf[8], st.seq); - detail::put_u32(&buf[12], static_cast(st.payload.size())); - std::copy(st.payload.begin(), st.payload.end(), buf.begin() + HeaderLen); - return buf; -} - -inline std::error_code decode_status(const uint8_t* b, size_t len, Status& out) { - if (auto ec = validate_header(b, len)) return ec; - uint32_t body_len = detail::get_u32(&b[12]); - if (len < HeaderLen + body_len) - return make_error_code(WireErrc::short_frame); - out.zone = static_cast(b[4]); - out.healthy = (b[7] == 1); - out.seq = detail::get_u32(&b[8]); - out.payload.assign(b + HeaderLen, b + HeaderLen + body_len); - return {}; -} - -// ── Control frame (subscribe / unsubscribe) ─────────────────────────────────── - -inline std::vector encode_control(uint8_t msg_type, Zone zone) { - std::vector buf(HeaderLen, 0); - buf[0] = MagicByte0; - buf[1] = MagicByte1; - buf[2] = ProtoVer; - buf[3] = msg_type; - buf[4] = static_cast(zone); - return buf; -} - -} // namespace legacy_wire -} // namespace rcp diff --git a/include/rcp/tsn.hpp b/include/rcp/tsn.hpp index dd24b68..70bd347 100644 --- a/include/rcp/tsn.hpp +++ b/include/rcp/tsn.hpp @@ -14,7 +14,13 @@ // Full 802.1Qbv gate scheduling requires a TSN-capable NIC and kernel ≥ 4.15. // On standard hardware this provides best-effort priority mapping only. // -// Wraps udp::Controller; add #include before this header. +// Wraps any rcp::Controller (e.g. the pre-replacement rcp/legacy_mock.hpp's +// Controller); it depends only on the socket fd passed in, not on any +// concrete transport header. See ROADMAP.md's Satellite Package Disposition +// table's entry for this file — a v2.14.0 rebind to the post-replacement +// request model, once rcp/udp.hpp itself no longer implements this +// pre-replacement Controller interface (v2.13.0) — for why this class still +// targets the old model today. #pragma once #include "rcp.hpp" diff --git a/include/rcp/udp.hpp b/include/rcp/udp.hpp index 0c965ee..7a25584 100644 --- a/include/rcp/udp.hpp +++ b/include/rcp/udp.hpp @@ -11,30 +11,65 @@ // fusa:req REQ-UDP-011 // fusa:req REQ-UDP-012 -// Pure-C++ UDP transport for the RCP protocol. +// Native IEEE 1722-over-UDP/IP transport for the OPEN Alliance TC18 Remote +// Control Protocol Specification v0.5.1_RC — carries real AVTPDU (NTSCF/ +// TSCF, rcp/avtp.hpp) frames wrapping ACF_ABB/ACF_GBB messages (rcp/acf.hpp) +// as raw UDP datagram payloads. // // On POSIX (Linux, macOS): full implementation using BSD sockets. // On Windows: stub that returns std::errc::function_not_supported. // -// Frame format is defined in rcp/legacy_wire.hpp — the pre-replacement -// 16-byte Zone/Command/Response/Status frame, not the TC18 AVTPDU/ACF codec -// now in rcp/avtp.hpp and rcp/acf.hpp. This transport is rebuilt to carry real AVTPDU -// frames at v2.13.0 (ROADMAP.md); until then it deliberately keeps using -// the old frame format so it keeps building and working against the -// still-current Zone/Command model. +// ROADMAP.md milestone 57, "Native Transport Rebuild — UDP/IP (Annex J), +// v2.13.0": this header REPLACES this file's pre-replacement content in +// full, per the Satellite Package Disposition table's entry for `udp.hpp` +// — the bespoke `R`/`C`-magic 16-byte Zone/Command/Response/Status frame +// this file used to carry (rcp/legacy_wire.hpp) is discarded outright, not +// adapted, since it *is* the old wire format this whole roadmap replaces +// (the roadmap's own words for this milestone). Unlike rcp/mock.hpp's split +// at v2.12.0, no legacy shim file was created here: grepping the tree found +// no consumer of the old udp::ZoneServer/udp::Controller/udp::Registry API +// beyond this file's own (now-deleted) test and rcp/tsn.hpp's doc comment +// — and rcp/tsn.hpp itself only ever depended on the generic rcp::Controller +// interface plus a raw socket fd, never on udp:: types directly, so it needed +// no change here. rcp/legacy_wire.hpp is deleted in the same change as this +// file for the same reason. +// +// This module does not build on rcp.hpp's Zone/Command/Controller/Registry +// model — per rcp.hpp's own header comment, nothing new should. Instead it +// addresses the way rcp/avtp.hpp and rcp/acf.hpp already do: a StreamId per +// endpoint plus a byte_bus_id/transaction_num pair per request, with request/ +// response correlation performed via the echo rule rcp/acf.hpp's +// make_response() documents rather than a locally invented 32-bit request +// id. Server's request handler is deliberately shaped to match +// rcp::mock::Server::dispatch's signature (v2.12.0) so an in-process +// simulator can be wired up as this transport's handler directly, without +// this header needing to depend on rcp/mock.hpp itself. +// +// IEEE 1722's own Annex J describes carrying AVTPDUs over UDP/IP instead of +// raw Ethernet, for links where native AVTP framing (destination MAC + +// EtherType 0x22F0) is not available. This implementation's own reading of +// that behavior — consistent with rcp/avtp.hpp's own header comment that its +// NTSCF/TSCF framing is transport-agnostic by design — is that the AVTPDU +// bytes rcp/avtp.hpp and rcp/acf.hpp already produce are carried unmodified +// as the UDP payload; no additional encapsulation header is layered on top +// here, and IP/UDP addressing substitutes for the Ethernet destination +// address a native AVTP link would otherwise use. Full bit-for-bit +// conformance against other TC18/Annex-J implementations is not claimed, +// same as the equivalent disclaimers in rcp/avtp.hpp, rcp/acf.hpp, and +// rcp/discovery.hpp. #pragma once -#include "rcp.hpp" -#include "legacy_wire.hpp" +#include "acf.hpp" +#include "avtp.hpp" +#include "rcp.hpp" // for rcp::Context only — see this header's own scope note above -#include #include #include +#include #include #include #include #include -#include #include #include #include @@ -51,18 +86,108 @@ namespace rcp { namespace udp { +// A UDP datagram is capped at 65507 payload bytes (65535 minus the IPv4 and +// UDP headers) regardless of AVTPDU/ACF content — the same ceiling +// rcp/legacy_wire.hpp's MaxPayload used to size against. +constexpr size_t kMaxDatagram = 65507; + #if defined(RCP_UDP_POSIX) -// ── ZoneServer ──────────────────────────────────────────────────────────────── +// ── Frame ───────────────────────────────────────────────────────────────────── +// Frame is one encapsulated AVTPDU: an NTSCF or TSCF header (rcp/avtp.hpp) +// wrapping one ACF_ABB or ACF_GBB message (rcp/acf.hpp, selected by +// info.acf_msg_type). encode_frame/decode_frame compose those two headers' +// existing codecs rather than re-deriving any bit layout, and are pure +// functions — no socket I/O — so they can be exercised directly in tests +// without a real UDP round trip. +struct Frame { + bool use_tscf = false; // false = NTSCF, true = TSCF + avtp::StreamId stream_id{}; + uint16_t sequence_num = 0; + bool timestamp_valid = false; // TSCF "tv" bit; ignored under NTSCF + uint32_t avtp_timestamp = 0; // TSCF-only; ignored under NTSCF + + acf::AcfMessageInfo info{}; + uint64_t message_timestamp = 0; // honored only when info.acf_msg_type == kAcfMsgTypeGbb + std::vector payload; +}; -// ZoneServer listens on a UDP port, handles Command frames, and publishes -// Status to all registered subscribers. -class ZoneServer { -public: - using Handler = std::function; +inline std::vector encode_frame(const Frame& f) { + std::vector acf_msg = (f.info.acf_msg_type == acf::kAcfMsgTypeGbb) + ? acf::encode_acf_gbb(f.info, f.message_timestamp, f.payload) + : acf::encode_acf_abb(f.info, f.payload); + + std::vector out; + if (f.use_tscf) { + avtp::TscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_msg.size()); + hdr.timestamp_valid = f.timestamp_valid; + hdr.avtp_timestamp = f.avtp_timestamp; + out = avtp::encode_tscf_header(hdr); + } else { + avtp::NtscfHeader hdr; + hdr.stream_id = f.stream_id; + hdr.sequence_num = f.sequence_num; + hdr.control_data_length = static_cast(acf_msg.size()); + out = avtp::encode_ntscf_header(hdr); + } + out.insert(out.end(), acf_msg.begin(), acf_msg.end()); + return out; +} + +inline std::error_code decode_frame(const uint8_t* b, size_t len, Frame& out) { + if (len < 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + + size_t acf_off; + out.use_tscf = (b[0] == avtp::kSubtypeTscf); + if (out.use_tscf) { + avtp::TscfHeader hdr; + if (auto ec = avtp::decode_tscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = hdr.timestamp_valid; + out.avtp_timestamp = hdr.avtp_timestamp; + acf_off = avtp::kTscfHeaderLen; + } else { + avtp::NtscfHeader hdr; + if (auto ec = avtp::decode_ntscf_header(b, len, hdr)) return ec; + out.stream_id = hdr.stream_id; + out.sequence_num = hdr.sequence_num; + out.timestamp_valid = false; + out.avtp_timestamp = 0; + acf_off = avtp::kNtscfHeaderLen; + } + + if (len < acf_off + 1) return avtp::make_error_code(avtp::AvtpErrc::short_buffer); + if (b[acf_off] == acf::kAcfMsgTypeGbb) { + return acf::decode_acf_gbb(b + acf_off, len - acf_off, out.info, + out.message_timestamp, out.payload); + } + out.message_timestamp = 0; + return acf::decode_acf_abb(b + acf_off, len - acf_off, out.info, out.payload); +} - ZoneServer(Zone zone, const char* addr, uint16_t port) - : zone_(zone), fd_(-1) { +// ── Server ──────────────────────────────────────────────────────────────────── +// Server binds a UDP socket, decodes each inbound datagram as a Frame, and +// dispatches the carried ACF request to a caller-supplied Handler — shaped +// to match rcp::mock::Server::dispatch's signature — then encodes and sends +// the handler's response back to the sender under the same header kind +// (NTSCF/TSCF) the request arrived under. Malformed datagrams (short buffer, +// bad subtype, unrecognized ACF message type) are dropped silently, the same +// "drop rather than partially process" choice rcp/discovery.hpp documents +// for its own decode path. +class Server { +public: + using Handler = std::function& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload)>; + + Server(avtp::StreamId stream_id, const char* addr, uint16_t port) + : stream_id_(stream_id), fd_(-1) { fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); if (fd_ < 0) return; sockaddr_in sa{}; @@ -77,11 +202,10 @@ class ZoneServer { fd_ = -1; return; } - healthy_.store(true); serve_thread_ = std::thread([this]{ serve(); }); } - ~ZoneServer() { close(); } + ~Server() { close(); } // addr_string returns "host:port" for this server's bound address. std::string addr_string() const { @@ -107,19 +231,6 @@ class ZoneServer { handler_ = std::move(h); } - void set_healthy(bool v) { healthy_.store(v, std::memory_order_release); } - - void publish(const std::vector& payload) { - uint32_t seq = ++seq_; - Status st{zone_, seq, healthy_.load(), payload}; - auto frame = legacy_wire::encode_status(st); - std::lock_guard lk(mu_); - for (auto& kv : subscribers_) { - ::sendto(fd_, frame.data(), frame.size(), 0, - reinterpret_cast(&kv.second), sizeof(kv.second)); - } - } - void close() { if (!closed_.exchange(true)) { if (fd_ >= 0) { @@ -134,67 +245,92 @@ class ZoneServer { bool ok() const noexcept { return fd_ >= 0; } private: - Zone zone_; - int fd_; + avtp::StreamId stream_id_; + int fd_; std::atomic closed_{false}; - std::atomic healthy_{false}; - std::atomic seq_{0}; + std::atomic seq_{0}; std::mutex mu_; Handler handler_; - std::map subscribers_; std::thread serve_thread_; + // client_ids_ assigns each distinct sender address a stable, opaque + // size_t identity, first-seen order — the same role rcp/regmap.hpp's + // Ep0 root-client index plays for its own callers, just derived from + // the UDP sender address instead of an in-process connection index. + std::map client_ids_; + size_t next_client_id_ = 0; + static std::string addr_key(const sockaddr_in& sa) { char buf[INET_ADDRSTRLEN]; ::inet_ntop(AF_INET, &sa.sin_addr, buf, sizeof(buf)); return std::string(buf) + ":" + std::to_string(ntohs(sa.sin_port)); } + size_t client_id_for(const sockaddr_in& from) { + auto key = addr_key(from); + auto it = client_ids_.find(key); + if (it != client_ids_.end()) return it->second; + size_t id = next_client_id_++; + client_ids_.emplace(key, id); + return id; + } + void serve() { - std::vector buf(legacy_wire::HeaderLen + legacy_wire::MaxPayload); - sockaddr_in client{}; - socklen_t clen = sizeof(client); + std::vector buf(kMaxDatagram); + sockaddr_in from{}; + socklen_t flen = sizeof(from); while (!closed_.load()) { ssize_t n = ::recvfrom(fd_, buf.data(), buf.size(), 0, - reinterpret_cast(&client), &clen); + reinterpret_cast(&from), &flen); if (n <= 0) break; - if (static_cast(n) < legacy_wire::HeaderLen) continue; - switch (buf[3]) { - case legacy_wire::TypeCommand: { - Command cmd; - if (legacy_wire::decode_command(buf.data(), static_cast(n), cmd)) break; - Response resp; - { - std::lock_guard lk(mu_); - if (handler_) resp = handler_(cmd); - else resp = Response{cmd.id, zone_, ResponseStatus::OK, {}}; + + Frame req; + if (decode_frame(buf.data(), static_cast(n), req)) continue; + + acf::AcfMessageInfo out_info; + std::vector out_payload; + size_t client; + { + std::lock_guard lk(mu_); + client = client_id_for(from); + if (handler_) { + auto ec = handler_(client, req.info, req.payload, out_info, out_payload); + (void)ec; // Handler always populates out_info even on failure, + // same contract rcp::mock::Server::dispatch documents. + } else { + out_info = acf::make_response(req.info, acf::ResponseKind::Acknowledge); } - auto frame = legacy_wire::encode_response(resp); - ::sendto(fd_, frame.data(), frame.size(), 0, - reinterpret_cast(&client), clen); - break; - } - case legacy_wire::TypeSubscribe: - subscribers_[addr_key(client)] = client; - break; - case legacy_wire::TypeUnsubscribe: - subscribers_.erase(addr_key(client)); - break; - default: - break; } + + Frame resp; + resp.use_tscf = req.use_tscf; + resp.stream_id = stream_id_; + resp.sequence_num = static_cast(++seq_); + resp.timestamp_valid = req.timestamp_valid; + resp.avtp_timestamp = req.avtp_timestamp; + resp.info = out_info; + resp.payload = std::move(out_payload); + + auto out_frame = encode_frame(resp); + ::sendto(fd_, out_frame.data(), out_frame.size(), 0, + reinterpret_cast(&from), flen); } } }; -// ── Controller ──────────────────────────────────────────────────────────────── - -// Controller connects to a ZoneServer via UDP and implements rcp::Controller. -class Controller final : public rcp::Controller { +// ── Client ──────────────────────────────────────────────────────────────────── +// Client connects to one Server address and sends AVTPDU-framed ACF requests, +// correlating each response by the (byte_bus_id, transaction_num) pair +// rcp/acf.hpp's make_response echoes back unchanged — there is no locally +// invented request id the way rcp.hpp's old Command::id was, since the new +// addressing model has no analog of it. Callers build `req` themselves (e.g. +// via acf::make_standard_request or rcp/discovery.hpp's +// make_discovery_request) so this transport stays a pure carrier, not a +// second place request semantics are decided. +class Client { public: - // Connect to serverHost:serverPort for zone. - Controller(Zone zone, const char* server_host, uint16_t server_port) - : zone_(zone), fd_(-1) { + Client(avtp::StreamId stream_id, const char* server_host, uint16_t server_port) + : stream_id_(stream_id), fd_(-1) { fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); if (fd_ < 0) return; @@ -211,41 +347,52 @@ class Controller final : public rcp::Controller { read_thread_ = std::thread([this]{ read_loop(); }); } - ~Controller() override { auto ec = close(); (void)ec; } - - Zone zone() const noexcept override { return zone_; } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { + ~Client() { auto ec = close(); (void)ec; } + + // request sends one ACF request — ABB or GBB per req.info's own + // acf_msg_type, wrapped in the AVTPDU header rcp/avtp.hpp defines for + // whichever kind `use_tscf` selects — and blocks until the matching + // response arrives or `ctx` is done. message_timestamp is only sent + // (and only meaningful) when req.acf_msg_type is kAcfMsgTypeGbb. + std::error_code request(const rcp::Context& ctx, + const acf::AcfMessageInfo& req, + const std::vector& req_payload, + acf::AcfMessageInfo& out_resp, + std::vector& out_resp_payload, + bool use_tscf = false, + uint32_t avtp_timestamp = 0, + uint64_t message_timestamp = 0) { if (closed_.load()) return ErrClosed; if (ctx.done()) return ErrTimeout; - if (cmd.zone != zone_) return ErrZoneMismatch; - uint32_t id = ++next_id_; - Command safe = cmd; - if (!cmd.payload.empty()) safe.payload = cmd.payload; - safe.id = id; - - auto frame = legacy_wire::encode_command(safe); - auto result = std::make_shared>(); + Frame out; + out.use_tscf = use_tscf; + out.stream_id = stream_id_; + out.sequence_num = static_cast(++seq_); + out.timestamp_valid = use_tscf; + out.avtp_timestamp = avtp_timestamp; + out.info = req; + out.message_timestamp = message_timestamp; + out.payload = req_payload; + + const uint16_t key = pending_key(req.byte_bus_id, req.transaction_num); + auto result = std::make_shared>(); auto future = result->get_future(); { std::lock_guard lk(mu_); - pending_[id] = result; + pending_[key] = result; } - auto cleanup = [&]{ std::lock_guard lk(mu_); - pending_.erase(id); + pending_.erase(key); }; - if (::send(fd_, frame.data(), frame.size(), 0) < 0) { + auto frame_bytes = encode_frame(out); + if (::send(fd_, frame_bytes.data(), frame_bytes.size(), 0) < 0) { cleanup(); return ErrClosed; } - // Wait for response or timeout. std::future_status st; if (ctx.deadline()) { st = future.wait_until(*ctx.deadline()); @@ -256,50 +403,14 @@ class Controller final : public rcp::Controller { cleanup(); if (st == std::future_status::timeout) return ErrTimeout; if (!future.valid()) return ErrClosed; - out = future.get(); - return {}; - } - - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - if (closed_.load()) return ErrClosed; - - auto ch = std::make_shared(16); - { - std::lock_guard lk(mu_); - subs_.push_back(ch); - } - - // Send subscribe control frame. - auto frame = legacy_wire::encode_control(legacy_wire::TypeSubscribe, zone_); - ::send(fd_, frame.data(), frame.size(), 0); - - std::thread([this, weak_ch = std::weak_ptr(ch), ctx]() mutable { - while (!ctx.done() && !closed_.load()) { - auto c = weak_ch.lock(); - if (!c || c->is_closed()) break; - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - // Send unsubscribe. - if (!closed_.load()) { - auto frame2 = legacy_wire::encode_control(legacy_wire::TypeUnsubscribe, zone_); - ::send(fd_, frame2.data(), frame2.size(), 0); - } - auto c = weak_ch.lock(); - if (!c) return; - { - std::lock_guard lk(mu_); - auto it = std::find(subs_.begin(), subs_.end(), c); - if (it != subs_.end()) subs_.erase(it); - } - c->close(); - }).detach(); - out = std::move(ch); + Frame resp = future.get(); + out_resp = resp.info; + out_resp_payload = std::move(resp.payload); return {}; } - std::error_code close() override { + std::error_code close() { if (!closed_.exchange(true)) { if (fd_ >= 0) { ::shutdown(fd_, SHUT_RDWR); @@ -307,169 +418,89 @@ class Controller final : public rcp::Controller { } } if (read_thread_.joinable()) read_thread_.join(); - std::lock_guard lk(mu_); - for (auto& s : subs_) s->close(); - subs_.clear(); return {}; } bool ok() const noexcept { return fd_ >= 0; } private: - Zone zone_; - int fd_; + static uint16_t pending_key(avtp::ByteBusId bus_id, uint8_t transaction_num) noexcept { + return static_cast((static_cast(bus_id) << 8) | transaction_num); + } + + avtp::StreamId stream_id_; + int fd_; std::atomic closed_{false}; - std::atomic next_id_{0}; + std::atomic seq_{0}; std::mutex mu_; - std::map>> pending_; - std::vector> subs_; + std::map>> pending_; std::thread read_thread_; void read_loop() { - std::vector buf(legacy_wire::HeaderLen + legacy_wire::MaxPayload); + std::vector buf(kMaxDatagram); while (!closed_.load()) { ssize_t n = ::recv(fd_, buf.data(), buf.size(), 0); if (n <= 0) break; - if (static_cast(n) < legacy_wire::HeaderLen) continue; - switch (buf[3]) { - case legacy_wire::TypeResponse: { - Response resp; - if (legacy_wire::decode_response(buf.data(), static_cast(n), resp)) break; - std::lock_guard lk(mu_); - auto it = pending_.find(resp.command_id); - if (it != pending_.end()) { - it->second->set_value(std::move(resp)); - } - break; - } - case legacy_wire::TypeStatus: { - Status st; - if (legacy_wire::decode_status(buf.data(), static_cast(n), st)) break; - std::lock_guard lk(mu_); - for (auto& s : subs_) s->push(st); - break; - } - default: - break; + + Frame resp; + if (decode_frame(buf.data(), static_cast(n), resp)) continue; + + const uint16_t key = pending_key(resp.info.byte_bus_id, resp.info.transaction_num); + std::lock_guard lk(mu_); + auto it = pending_.find(key); + if (it != pending_.end()) { + it->second->set_value(std::move(resp)); + pending_.erase(it); } } } }; -// ── Registry ────────────────────────────────────────────────────────────────── - -class Registry final : public rcp::Registry { -public: - std::error_code dial(Zone z, const char* host, uint16_t port) { - auto ctrl = std::make_shared(z, host, port); - if (!ctrl->ok()) return make_error_code(Errc::not_found); - return register_ctrl(ctrl); - } - - std::error_code register_ctrl(std::shared_ptr ctrl) override { - std::unique_lock lk(mu_); - if (closed_) return ErrClosed; - if (ctrls_.count(ctrl->zone())) return ErrAlreadyExists; - ctrls_[ctrl->zone()] = ctrl; - return {}; - } - - std::error_code deregister(Zone z) override { - std::unique_lock lk(mu_); - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - auto ctrl = it->second; - ctrls_.erase(it); - lk.unlock(); - return ctrl->close(); - } - - std::error_code lookup(Zone z, std::shared_ptr& out) override { - std::shared_lock lk(mu_); - if (closed_) return ErrClosed; - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - out = it->second; - return {}; - } - - std::vector> controllers() override { - std::shared_lock lk(mu_); - std::vector> out; - out.reserve(ctrls_.size()); - for (auto& kv : ctrls_) out.push_back(kv.second); - return out; - } - - std::error_code close() override { - std::unique_lock lk(mu_); - if (closed_) return {}; - closed_ = true; - auto local = std::move(ctrls_); - lk.unlock(); - for (auto& kv : local) { auto ec = kv.second->close(); (void)ec; } - return {}; - } +#else // !RCP_UDP_POSIX (Windows stub) -private: - mutable std::shared_mutex mu_; - std::map> ctrls_; - bool closed_ = false; +struct Frame { + bool use_tscf = false; + avtp::StreamId stream_id{}; + uint16_t sequence_num = 0; + bool timestamp_valid = false; + uint32_t avtp_timestamp = 0; + acf::AcfMessageInfo info{}; + uint64_t message_timestamp = 0; + std::vector payload; }; -inline std::unique_ptr new_registry() { - return std::make_unique(); +inline std::vector encode_frame(const Frame&) { return {}; } +inline std::error_code decode_frame(const uint8_t*, size_t, Frame&) { + return std::make_error_code(std::errc::function_not_supported); } -#else // !RCP_UDP_POSIX (Windows stub) - -class ZoneServer { +class Server { public: - ZoneServer(Zone, const char*, uint16_t) {} + using Handler = std::function&, + acf::AcfMessageInfo&, std::vector&)>; + + Server(avtp::StreamId, const char*, uint16_t) {} std::string addr_string() const { return {}; } uint16_t port() const { return 0; } - void set_handler(std::function) {} - void set_healthy(bool) {} - void publish(const std::vector&) {} + void set_handler(Handler) {} void close() {} bool ok() const noexcept { return false; } }; -class Controller final : public rcp::Controller { +class Client { public: - Controller(Zone z, const char*, uint16_t) : zone_(z) {} - Zone zone() const noexcept override { return zone_; } - std::error_code send(const rcp::Context&, const Command&, Response&) override { + Client(avtp::StreamId, const char*, uint16_t) {} + std::error_code request(const rcp::Context&, const acf::AcfMessageInfo&, + const std::vector&, + acf::AcfMessageInfo&, std::vector&, + bool = false, uint32_t = 0, uint64_t = 0) { return std::make_error_code(std::errc::function_not_supported); } - std::error_code subscribe(const rcp::Context&, std::shared_ptr&) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::error_code close() override { return {}; } + std::error_code close() { return {}; } bool ok() const noexcept { return false; } -private: - Zone zone_; }; -class Registry final : public rcp::Registry { -public: - std::error_code register_ctrl(std::shared_ptr) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::error_code deregister(Zone) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::error_code lookup(Zone, std::shared_ptr&) override { - return std::make_error_code(std::errc::function_not_supported); - } - std::vector> controllers() override { return {}; } - std::error_code close() override { return {}; } -}; - -inline std::unique_ptr new_registry() { - return std::make_unique(); -} - #endif // RCP_UDP_POSIX } // namespace udp diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index 89d8f77..15ee9ee 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.12.0"; +constexpr std::string_view kVersion = "2.13.0"; } // namespace rcp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 17f1f2f..9003aac 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,10 +18,6 @@ add_executable(test_acf test_acf.cpp) target_link_libraries(test_acf PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_acf COMMAND test_acf) -add_executable(test_legacy_wire test_legacy_wire.cpp) -target_link_libraries(test_legacy_wire PRIVATE rcp::rcp Catch2::Catch2WithMain) -add_test(NAME rcp_legacy_wire COMMAND test_legacy_wire) - add_executable(test_lifecycle test_lifecycle.cpp) target_link_libraries(test_lifecycle PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_lifecycle COMMAND test_lifecycle) @@ -197,7 +193,7 @@ add_test(NAME rcp_cli COMMAND test_cli) foreach(mod canbr ddsbr doipbr grpcbridge linbr mqttbr restbridge someipbr udsbr - tsn mdns powerstate tls) + tsn mdns powerstate tls udp) add_executable(test_${mod} test_${mod}.cpp) target_link_libraries(test_${mod} PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_${mod} COMMAND test_${mod}) diff --git a/tests/test_legacy_wire.cpp b/tests/test_legacy_wire.cpp deleted file mode 100644 index 67530af..0000000 --- a/tests/test_legacy_wire.cpp +++ /dev/null @@ -1,158 +0,0 @@ -// fusa:test REQ-UDP-001 -// fusa:test REQ-UDP-002 -// fusa:test REQ-UDP-003 -// fusa:test REQ-UDP-004 -// fusa:test REQ-UDP-005 -// fusa:test REQ-UDP-006 -// fusa:test REQ-UDP-007 -// fusa:test REQ-UDP-008 -// fusa:test REQ-UDP-009 -// fusa:test REQ-UDP-010 -// fusa:test REQ-UDP-011 -// fusa:test REQ-UDP-012 - -// Tests for rcp/legacy_wire.hpp — the pre-replacement 16-byte frame codec -// still used internally by rcp/udp.hpp. See that header's comment for why -// this still exists alongside the new rcp/avtp.hpp + rcp/acf.hpp AVTPDU/ACF -// codec. - -#include -#include - -using namespace rcp; -using namespace rcp::legacy_wire; - -// ── Header constants ────────────────────────────────────────────────────────── - -TEST_CASE("Legacy wire header length and magic are correct", "[legacy_wire][REQ-UDP-001]") { - REQUIRE(HeaderLen == 16); - REQUIRE(MagicByte0 == 'R'); - REQUIRE(MagicByte1 == 'C'); -} - -TEST_CASE("Legacy wire version is 1", "[legacy_wire][REQ-UDP-002]") { - REQUIRE(ProtoVer == 1); -} - -TEST_CASE("Legacy MaxPayload is non-zero", "[legacy_wire][REQ-UDP-003]") { - REQUIRE(MaxPayload > 0); -} - -// ── Command round-trip ──────────────────────────────────────────────────────── - -TEST_CASE("encode_command / decode_command round-trip with no payload", "[legacy_wire][REQ-UDP-004]") { - Command cmd; - cmd.id = 0xDEADBEEF; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Set; - cmd.priority = Priority::High; - - auto frame = encode_command(cmd); - REQUIRE(frame.size() >= HeaderLen); - - Command out; - auto ec = decode_command(frame.data(), frame.size(), out); - REQUIRE_FALSE(ec); - REQUIRE(out.id == cmd.id); - REQUIRE(out.zone == cmd.zone); - REQUIRE(out.type == cmd.type); - REQUIRE(out.priority == cmd.priority); - REQUIRE(out.payload.empty()); -} - -TEST_CASE("encode_command / decode_command round-trip with payload", "[legacy_wire][REQ-UDP-005]") { - Command cmd; - cmd.id = 42; - cmd.zone = Zone::RearRight; - cmd.type = CommandType::Get; - cmd.payload = {0x01, 0x02, 0x03, 0x04}; - - auto frame = encode_command(cmd); - Command out; - REQUIRE_FALSE(decode_command(frame.data(), frame.size(), out)); - REQUIRE(out.payload == cmd.payload); -} - -TEST_CASE("decode_command rejects truncated frames", "[legacy_wire][REQ-UDP-006]") { - Command cmd; - cmd.id = 1; - cmd.zone = Zone::Central; - auto frame = encode_command(cmd); - - Command out; - REQUIRE(decode_command(frame.data(), HeaderLen - 1, out)); // truncated -} - -// ── Response round-trip ─────────────────────────────────────────────────────── - -TEST_CASE("encode_response / decode_response round-trip", "[legacy_wire][REQ-UDP-007]") { - Response resp; - resp.command_id = 0x1234; - resp.zone = Zone::FrontRight; - resp.status = ResponseStatus::OK; - resp.payload = {0xFF, 0xFE}; - - auto frame = encode_response(resp); - Response out; - REQUIRE_FALSE(decode_response(frame.data(), frame.size(), out)); - REQUIRE(out.command_id == resp.command_id); - REQUIRE(out.zone == resp.zone); - REQUIRE(out.status == resp.status); - REQUIRE(out.payload == resp.payload); -} - -// ── Status round-trip ───────────────────────────────────────────────────────── - -TEST_CASE("encode_status / decode_status round-trip", "[legacy_wire][REQ-UDP-008]") { - Status st; - st.zone = Zone::RearLeft; - st.seq = 99; - st.healthy = true; - st.payload = {0xAB}; - - auto frame = encode_status(st); - Status out; - REQUIRE_FALSE(decode_status(frame.data(), frame.size(), out)); - REQUIRE(out.zone == st.zone); - REQUIRE(out.seq == st.seq); - REQUIRE(out.healthy == st.healthy); - REQUIRE(out.payload == st.payload); -} - -// ── Frame magic / version ───────────────────────────────────────────────────── - -TEST_CASE("Frames begin with 'RC' magic and correct version", "[legacy_wire][REQ-UDP-009]") { - Command cmd; - cmd.zone = Zone::FrontLeft; - auto frame = encode_command(cmd); - REQUIRE(frame[0] == 'R'); - REQUIRE(frame[1] == 'C'); - REQUIRE(frame[2] == ProtoVer); -} - -TEST_CASE("decode_command rejects wrong magic", "[legacy_wire][REQ-UDP-010]") { - Command cmd; - cmd.zone = Zone::FrontLeft; - auto frame = encode_command(cmd); - frame[0] = 'X'; // corrupt magic - Command out; - REQUIRE(decode_command(frame.data(), frame.size(), out)); -} - -TEST_CASE("decode_command rejects wrong version", "[legacy_wire][REQ-UDP-011]") { - Command cmd; - cmd.zone = Zone::FrontLeft; - auto frame = encode_command(cmd); - frame[2] = ProtoVer + 1; // bump version - Command out; - REQUIRE(decode_command(frame.data(), frame.size(), out)); -} - -// ── Control frame ───────────────────────────────────────────────────────────── - -TEST_CASE("encode_control produces a valid header-only frame", "[legacy_wire][REQ-UDP-012]") { - auto frame = encode_control(TypeSubscribe, Zone::Central); - REQUIRE(frame.size() == HeaderLen); - REQUIRE(frame[0] == 'R'); - REQUIRE(frame[3] == TypeSubscribe); -} diff --git a/tests/test_udp.cpp b/tests/test_udp.cpp new file mode 100644 index 0000000..e639ba4 --- /dev/null +++ b/tests/test_udp.cpp @@ -0,0 +1,354 @@ +// fusa:test REQ-UDP-001 +// fusa:test REQ-UDP-002 +// fusa:test REQ-UDP-003 +// fusa:test REQ-UDP-004 +// fusa:test REQ-UDP-005 +// fusa:test REQ-UDP-006 +// fusa:test REQ-UDP-007 +// fusa:test REQ-UDP-008 +// fusa:test REQ-UDP-009 +// fusa:test REQ-UDP-010 +// fusa:test REQ-UDP-011 +// fusa:test REQ-UDP-012 + +// Tests for rcp/udp.hpp — the native IEEE 1722-over-UDP/IP transport +// (ROADMAP.md milestone 57, "Native Transport Rebuild — UDP/IP (Annex J)", +// v2.13.0). See that header's own comment for why no legacy-shim split file +// was needed for this rebuild, unlike rcp/mock.hpp's at v2.12.0. + +#include +#include + +#include + +using namespace rcp; +using namespace rcp::udp; + +namespace { + +#if defined(RCP_UDP_POSIX) +// A bound UDP socket that accepts datagrams but never replies. Used only by +// the timeout test below, so a Client waiting on it genuinely times out +// instead of racing an OS-level "port unreachable" ICMP notification against +// an already-closed port (platform-dependent and not what REQ-UDP-010 means +// to exercise). +class BlackHole { +public: + BlackHole() { + fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_port = 0; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + ::bind(fd_, reinterpret_cast(&sa), sizeof(sa)); + } + ~BlackHole() { if (fd_ >= 0) ::close(fd_); } + + uint16_t port() const { + sockaddr_in sa{}; + socklen_t len = sizeof(sa); + ::getsockname(fd_, reinterpret_cast(&sa), &len); + return ntohs(sa.sin_port); + } + +private: + int fd_ = -1; +}; +#endif + +avtp::StreamId make_stream_id(uint8_t mac_seed, uint16_t suffix) { + avtp::StreamId id; + for (auto& b : id.mac) b = mac_seed++; + id.suffix = suffix; + return id; +} + +acf::AcfMessageInfo standard_request(avtp::ByteBusId bus_id, uint8_t transaction_num, + bool write = false, uint16_t read_size = 0) { + return acf::make_standard_request(bus_id, transaction_num, write, read_size); +} + +} // namespace + +// Every TEST_CASE below exercises the real POSIX implementation (Frame +// codec, Server, Client) — on Windows, rcp/udp.hpp's own header comment +// documents that all of these are the function_not_supported stub, so +// there is nothing real left for REQ-UDP-001..012 to exercise there. +#if defined(RCP_UDP_POSIX) + +// ── Frame encode/decode (pure codec, no sockets) ───────────────────────────── + +TEST_CASE("Frame round-trips an NTSCF-framed ACF_ABB request", "[udp][REQ-UDP-001]") { + Frame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x02, 0x1234); + f.sequence_num = 7; + f.info = standard_request(/*bus_id=*/3, /*transaction_num=*/9, /*write=*/true); + f.payload = {0x01, 0x02, 0x03}; + + auto bytes = encode_frame(f); + REQUIRE(bytes[0] == avtp::kSubtypeNtscf); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE_FALSE(out.use_tscf); + REQUIRE(out.stream_id == f.stream_id); + REQUIRE(out.sequence_num == f.sequence_num); + REQUIRE(out.info.byte_bus_id == f.info.byte_bus_id); + REQUIRE(out.info.transaction_num == f.info.transaction_num); + REQUIRE(out.info.op == f.info.op); + REQUIRE(out.payload == f.payload); +} + +TEST_CASE("Frame round-trips a TSCF-framed request with avtp_timestamp", "[udp][REQ-UDP-002]") { + Frame f; + f.use_tscf = true; + f.stream_id = make_stream_id(0xAA, 0x0001); + f.sequence_num = 42; + f.timestamp_valid = true; + f.avtp_timestamp = 0xCAFEBABE; + f.info = standard_request(/*bus_id=*/1, /*transaction_num=*/5); + f.payload = {0xFF}; + + auto bytes = encode_frame(f); + REQUIRE(bytes[0] == avtp::kSubtypeTscf); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE(out.use_tscf); + REQUIRE(out.timestamp_valid); + REQUIRE(out.avtp_timestamp == 0xCAFEBABE); + REQUIRE(out.info.byte_bus_id == 1); +} + +TEST_CASE("Frame round-trips an ACF_GBB message_timestamp", "[udp][REQ-UDP-003]") { + Frame f; + f.use_tscf = false; + f.stream_id = make_stream_id(0x10, 0x2222); + f.sequence_num = 1; + f.info = standard_request(/*bus_id=*/2, /*transaction_num=*/1); + f.info.acf_msg_type = acf::kAcfMsgTypeGbb; + f.info.mtv = true; + f.message_timestamp = 0x1122334455667788ULL; + f.payload = {0xAB, 0xCD}; + + auto bytes = encode_frame(f); + + Frame out; + REQUIRE_FALSE(decode_frame(bytes.data(), bytes.size(), out)); + REQUIRE(out.info.acf_msg_type == acf::kAcfMsgTypeGbb); + REQUIRE(out.info.mtv); + REQUIRE(out.message_timestamp == 0x1122334455667788ULL); + REQUIRE(out.payload == f.payload); +} + +TEST_CASE("decode_frame rejects a buffer shorter than the AVTPDU header", "[udp][REQ-UDP-004]") { + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_frame(f); + + Frame out; + REQUIRE(decode_frame(bytes.data(), avtp::kNtscfHeaderLen - 1, out)); +} + +TEST_CASE("decode_frame rejects a buffer shorter than the ACF common header", + "[udp][REQ-UDP-005]") { + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_frame(f); + + Frame out; + REQUIRE(decode_frame(bytes.data(), avtp::kNtscfHeaderLen + 1, out)); +} + +TEST_CASE("decode_frame rejects an unrecognized ACF message type", "[udp][REQ-UDP-006]") { + Frame f; + f.stream_id = make_stream_id(0x01, 1); + f.info = standard_request(1, 1); + auto bytes = encode_frame(f); + bytes[avtp::kNtscfHeaderLen] = 0x00; // neither kAcfMsgTypeAbb nor kAcfMsgTypeGbb + + Frame out; + REQUIRE(decode_frame(bytes.data(), bytes.size(), out)); +} + +// ── Server (real UDP sockets, loopback) ────────────────────────────────────── + +TEST_CASE("Server dispatches a decoded request through its handler and answers the sender", + "[udp][REQ-UDP-007]") { + Server server(make_stream_id(0x02, 1), "127.0.0.1", 0); + REQUIRE(server.ok()); + + server.set_handler([](size_t, const acf::AcfMessageInfo& req, + const std::vector& req_payload, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) { + out_resp_payload = req_payload; + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); + return std::error_code{}; + }); + + Client client(make_stream_id(0x03, 1), "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + auto req = standard_request(/*bus_id=*/5, /*transaction_num=*/11); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + auto ec = client.request(ctx, req, {0x11, 0x22}, resp, resp_payload); + + REQUIRE_FALSE(ec); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::ReadResponse); + REQUIRE(resp.byte_bus_id == 5); + REQUIRE(resp.transaction_num == 11); + REQUIRE(resp_payload == std::vector{0x11, 0x22}); + + server.close(); + client.close(); +} + +TEST_CASE("Server answers Acknowledge by default when no handler is registered", + "[udp][REQ-UDP-008]") { + Server server(make_stream_id(0x02, 2), "127.0.0.1", 0); + REQUIRE(server.ok()); + + Client client(make_stream_id(0x03, 2), "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + auto req = standard_request(/*bus_id=*/1, /*transaction_num=*/1); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + REQUIRE_FALSE(client.request(ctx, req, {}, resp, resp_payload)); + REQUIRE(acf::response_kind_of(resp) == acf::ResponseKind::Acknowledge); + + server.close(); + client.close(); +} + +TEST_CASE("Server assigns stable per-sender client ids", "[udp][REQ-UDP-009]") { + Server server(make_stream_id(0x02, 3), "127.0.0.1", 0); + REQUIRE(server.ok()); + + std::vector seen; + server.set_handler([&](size_t client, const acf::AcfMessageInfo& req, + const std::vector&, + acf::AcfMessageInfo& out_resp, std::vector&) { + seen.push_back(client); + out_resp = acf::make_response(req, acf::ResponseKind::Acknowledge); + return std::error_code{}; + }); + + Client client_a(make_stream_id(0x03, 3), "127.0.0.1", server.port()); + Client client_b(make_stream_id(0x04, 3), "127.0.0.1", server.port()); + REQUIRE(client_a.ok()); + REQUIRE(client_b.ok()); + + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + + REQUIRE_FALSE(client_a.request(ctx, standard_request(1, 1), {}, resp, resp_payload)); + REQUIRE_FALSE(client_a.request(ctx, standard_request(1, 2), {}, resp, resp_payload)); + REQUIRE_FALSE(client_b.request(ctx, standard_request(1, 1), {}, resp, resp_payload)); + + REQUIRE(seen.size() == 3); + REQUIRE(seen[0] == seen[1]); // same sender address, same client id + REQUIRE(seen[0] != seen[2]); // different sender address, different id + + server.close(); + client_a.close(); + client_b.close(); +} + +// ── Client (real UDP sockets, loopback) ────────────────────────────────────── + +TEST_CASE("Client::request returns ErrTimeout when no response arrives", + "[udp][REQ-UDP-010]") { + BlackHole sink; // listens, but never replies + Client client(make_stream_id(0x03, 4), "127.0.0.1", sink.port()); + REQUIRE(client.ok()); + + auto req = standard_request(1, 1); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::with_timeout(std::chrono::milliseconds(50)); + auto ec = client.request(ctx, req, {}, resp, resp_payload); + REQUIRE(ec == ErrTimeout); + + client.close(); +} + +TEST_CASE("Client::request correlates concurrent requests by byte_bus_id/transaction_num", + "[udp][REQ-UDP-011]") { + Server server(make_stream_id(0x02, 5), "127.0.0.1", 0); + REQUIRE(server.ok()); + + server.set_handler([](size_t, const acf::AcfMessageInfo& req, + const std::vector&, + acf::AcfMessageInfo& out_resp, std::vector& out_resp_payload) { + out_resp = acf::make_response(req, acf::ResponseKind::ReadResponse); + out_resp_payload = {req.byte_bus_id, req.transaction_num}; + return std::error_code{}; + }); + + Client client(make_stream_id(0x03, 5), "127.0.0.1", server.port()); + REQUIRE(client.ok()); + + auto ctx = Context::with_timeout(std::chrono::seconds(2)); + + acf::AcfMessageInfo resp1, resp2; + std::vector payload1, payload2; + REQUIRE_FALSE(client.request(ctx, standard_request(1, 1), {}, resp1, payload1)); + REQUIRE_FALSE(client.request(ctx, standard_request(2, 1), {}, resp2, payload2)); + + REQUIRE(payload1 == std::vector{1, 1}); + REQUIRE(payload2 == std::vector{2, 1}); + + server.close(); + client.close(); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +TEST_CASE("Server and Client close() are idempotent and requests after close fail", + "[udp][REQ-UDP-012]") { + Server server(make_stream_id(0x02, 6), "127.0.0.1", 0); + Client client(make_stream_id(0x03, 6), "127.0.0.1", server.port()); + REQUIRE(server.ok()); + REQUIRE(client.ok()); + + server.close(); + server.close(); // second call must not hang or crash + + REQUIRE_FALSE(client.close()); + REQUIRE_FALSE(client.close()); // second call must not hang or crash + + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ctx = Context::background(); + REQUIRE(client.request(ctx, standard_request(1, 1), {}, resp, resp_payload) == ErrClosed); +} + +#else // !RCP_UDP_POSIX + +// On Windows rcp/udp.hpp has no real transport (see its own header comment) +// — this exercises the function_not_supported stub surface instead, so this +// binary still registers a real assertion rather than reporting "No tests +// ran" (which Catch2, and therefore ctest, treats as a failure). +TEST_CASE("Windows stub reports function_not_supported", "[udp]") { + Server server(make_stream_id(0x02, 1), "127.0.0.1", 0); + REQUIRE_FALSE(server.ok()); + + Client client(make_stream_id(0x03, 1), "127.0.0.1", 0); + REQUIRE_FALSE(client.ok()); + + auto ctx = Context::background(); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = client.request(ctx, standard_request(1, 1), {}, resp, resp_payload); + REQUIRE(ec == std::make_error_code(std::errc::function_not_supported)); +} + +#endif // RCP_UDP_POSIX