diff --git a/.fusa-reqs.json b/.fusa-reqs.json index d52ceb1..817b2b5 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2655,6 +2655,118 @@ "standard": "relay", "level": "QM", "asil": "QM" + }, + { + "id": "REQ-WIRE-001", + "title": "NTSCF header addressing and length fields", + "text": "rcp::wire::encode_ntscf_header/decode_ntscf_header shall round-trip stream_id, sequence_num, and control_data_length unchanged for a Non-Time-Synchronous Control Format AVTPDU header.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-002", + "title": "TSCF header carries avtp_timestamp", + "text": "rcp::wire::encode_tscf_header/decode_tscf_header shall round-trip stream_id, sequence_num, control_data_length, and the 32-bit avtp_timestamp field unchanged for a Time-Synchronous Control Format AVTPDU header.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-003", + "title": "AVTPDU subtype validation", + "text": "decode_ntscf_header shall reject a buffer whose subtype byte is not the NTSCF subtype, and decode_tscf_header shall reject a buffer whose subtype byte is not the TSCF subtype, both returning WireErrc::bad_subtype.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-004", + "title": "ACF_ABB carries no timestamp field", + "text": "encode_acf_abb/decode_acf_abb shall round-trip the shared ACF header and payload for an ACF_ABB (0x0E) message, and the decoded AcfMessageInfo::mtv shall always be false for ACF_ABB since it defines no timestamp slot.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-005", + "title": "ACF_GBB carries a 64-bit message_timestamp", + "text": "encode_acf_gbb/decode_acf_gbb shall round-trip the shared ACF header, a 64-bit message_timestamp value, and the payload unchanged for an ACF_GBB (0x0D) message.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-006", + "title": "Shared byte_message_info fields round-trip", + "text": "AcfMessageInfo encode/decode shall preserve acf_msg_type, acf_msg_length, pad, mtv, byte_bus_id, the evt ack flag and 3-bit sub-opcode, hs, cs, transaction_num, op, rsp, err, ms, and the dual-purpose read_size/segment_num field unchanged through an encode/decode round trip.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-007", + "title": "StreamId addressing round-trips", + "text": "rcp::wire::StreamId shall round-trip its 48-bit MAC half and 16-bit locally-assigned suffix through to_u64()/from_u64() unchanged, and two StreamId values with different MAC halves shall compare unequal.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-008", + "title": "byte_bus_id is echoed unchanged on responses", + "text": "rcp::wire::make_response shall always copy byte_bus_id and transaction_num from the request it is built from, unchanged, since byte_bus_id is unique only within the owning stream_id and a response/ack must echo it back exactly.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-009", + "title": "Standard request kind is unconditional ACF_ABB", + "text": "rcp::wire::make_standard_request shall build a request using acf_msg_type ACF_ABB with rsp clear, representing the mandatory best-effort, unconditional standard request kind.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-010", + "title": "Four response semantic types are distinguishable", + "text": "rcp::wire::response_kind_of shall recover Acknowledge, WriteResponse, ReadResponse, or ErrorResponse from a header built by make_response with the corresponding ResponseKind, and the four kinds shall be pairwise distinguishable from the shared header fields alone.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-011", + "title": "avtp_timestamp validity is explicit, not implied by zero", + "text": "A TSCF header with timestamp_valid false shall be distinguishable from one with a valid, zero-valued avtp_timestamp; decode_tscf_header shall preserve the timestamp_valid flag independently of the avtp_timestamp value.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-012", + "title": "Timestamp fallback rule: TSCF preferred, then ACF_GBB, then none", + "text": "rcp::wire::effective_timestamp shall return the TSCF avtp_timestamp when timestamp_valid is set, shall fall back to a valid ACF_GBB message_timestamp when no TSCF timestamp is available, and shall return std::nullopt rather than zero when neither source is valid.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-013", + "title": "Decoders reject short buffers", + "text": "decode_ntscf_header, decode_tscf_header, decode_acf_abb, and decode_acf_gbb shall return WireErrc::short_buffer when given fewer bytes than their fixed-size header requires, without reading past the buffer.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-WIRE-014", + "title": "ACF message type mismatch is rejected", + "text": "decode_acf_abb shall return WireErrc::bad_acf_msg_type when given a buffer whose acf_msg_type byte is ACF_GBB, and decode_acf_gbb shall return the same error when given a buffer whose acf_msg_type byte is ACF_ABB.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" } ] } diff --git a/CMakeLists.txt b/CMakeLists.txt index 469ad1e..5f52d5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 1.2.0 + VERSION 2.0.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 75adcbd..eac5bd3 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,12 @@ reference. | Header | Description | |---|---| -| `` | Pure-C++ UDP transport for the RCP protocol | +| `` | Pure-C++ UDP transport for the RCP protocol (still framed via `` until 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 | -| `` | Binary frame codec shared by the UDP and TLS transports | +| `` | TC18 wire codec — IEEE 1722 AVTPDU (NTSCF/TSCF) framing and ACF_ABB/ACF_GBB messages (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 zone controller simulator for SiL/HIL testing | ## Build diff --git a/ROADMAP.md b/ROADMAP.md index a88b6b8..c3fe5ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -150,6 +150,24 @@ and full endpoint coverage is not reached until v2.7.0. ### 44. Wire Format Core (v2.0.0) +**Done (v2.0.0):** `rcp/wire.hpp` is now the IEEE 1722 AVTPDU/ACF codec +described below — NTSCF/TSCF headers, ACF_ABB/ACF_GBB messages, the shared +`AcfMessageInfo` ("byte_message_info") fields, `StreamId`/`byte_bus_id` +addressing with an enforced echo-back rule, the mandatory standard request +kind, the four response semantic types, and `avtp_timestamp`/ +`message_timestamp` fallback handling via `effective_timestamp`. It has no +dependency on `rcp.hpp`'s Zone/Command/Controller/Registry model or on any +later-phase lifecycle/discovery/endpoint behavior, and makes no assumption +about the underlying transport. The old 16-byte frame codec this file used +to define is preserved unchanged, under `rcp/legacy_wire.hpp`, purely so +`rcp/udp.hpp` keeps building and working until it is rebuilt against real +AVTPDU framing at v2.13.0 — see the disposition table entry for `wire.hpp` +and the header comment in `legacy_wire.hpp`. New coverage lives in +`tests/test_wire.cpp` (REQ-WIRE-001..014); the prior 16-byte-frame tests +moved to `tests/test_legacy_wire.cpp` unchanged (REQ-UDP-001..012). Full +bit-for-bit wire conformance is not claimed at this milestone — that lands +incrementally through v2.6.0 per the Phase 13 introduction above. + - Replace the bespoke 16-byte `wire.hpp` header entirely with real IEEE 1722 framing: NTSCF (non-time-synchronous, server-only-outbound) and TSCF (time-synchronous, client-only) AVTPDU headers (extraction §2.2) diff --git a/include/rcp/legacy_wire.hpp b/include/rcp/legacy_wire.hpp new file mode 100644 index 0000000..e3bbfd7 --- /dev/null +++ b/include/rcp/legacy_wire.hpp @@ -0,0 +1,230 @@ +// 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 rcp/wire.hpp carried before +// the TC18 protocol-replacement roadmap (ROADMAP.md, "44. Wire Format Core +// (v2.0.0)"). rcp/wire.hpp itself has been rebuilt as the IEEE 1722 +// AVTPDU/ACF codec; 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/udp.hpp b/include/rcp/udp.hpp index 789159a..fcb2dbf 100644 --- a/include/rcp/udp.hpp +++ b/include/rcp/udp.hpp @@ -16,11 +16,16 @@ // 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/wire.hpp. +// 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/wire.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. #pragma once #include "rcp.hpp" -#include "wire.hpp" +#include "legacy_wire.hpp" #include #include @@ -107,7 +112,7 @@ class ZoneServer { void publish(const std::vector& payload) { uint32_t seq = ++seq_; Status st{zone_, seq, healthy_.load(), payload}; - auto frame = wire::encode_status(st); + auto frame = legacy_wire::encode_status(st); std::lock_guard lk(mu_); for (auto& kv : subscribers_) { ::sendto(fd_, frame.data(), frame.size(), 0, @@ -146,33 +151,33 @@ class ZoneServer { } void serve() { - std::vector buf(wire::HeaderLen + wire::MaxPayload); + std::vector buf(legacy_wire::HeaderLen + legacy_wire::MaxPayload); sockaddr_in client{}; socklen_t clen = sizeof(client); while (!closed_.load()) { ssize_t n = ::recvfrom(fd_, buf.data(), buf.size(), 0, reinterpret_cast(&client), &clen); if (n <= 0) break; - if (static_cast(n) < wire::HeaderLen) continue; + if (static_cast(n) < legacy_wire::HeaderLen) continue; switch (buf[3]) { - case wire::TypeCommand: { + case legacy_wire::TypeCommand: { Command cmd; - if (wire::decode_command(buf.data(), static_cast(n), cmd)) break; + 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, {}}; } - auto frame = wire::encode_response(resp); + auto frame = legacy_wire::encode_response(resp); ::sendto(fd_, frame.data(), frame.size(), 0, reinterpret_cast(&client), clen); break; } - case wire::TypeSubscribe: + case legacy_wire::TypeSubscribe: subscribers_[addr_key(client)] = client; break; - case wire::TypeUnsubscribe: + case legacy_wire::TypeUnsubscribe: subscribers_.erase(addr_key(client)); break; default: @@ -222,7 +227,7 @@ class Controller final : public rcp::Controller { if (!cmd.payload.empty()) safe.payload = cmd.payload; safe.id = id; - auto frame = wire::encode_command(safe); + auto frame = legacy_wire::encode_command(safe); auto result = std::make_shared>(); auto future = result->get_future(); { @@ -266,7 +271,7 @@ class Controller final : public rcp::Controller { } // Send subscribe control frame. - auto frame = wire::encode_control(wire::TypeSubscribe, zone_); + 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 { @@ -277,7 +282,7 @@ class Controller final : public rcp::Controller { } // Send unsubscribe. if (!closed_.load()) { - auto frame2 = wire::encode_control(wire::TypeUnsubscribe, zone_); + auto frame2 = legacy_wire::encode_control(legacy_wire::TypeUnsubscribe, zone_); ::send(fd_, frame2.data(), frame2.size(), 0); } auto c = weak_ch.lock(); @@ -321,15 +326,15 @@ class Controller final : public rcp::Controller { std::thread read_thread_; void read_loop() { - std::vector buf(wire::HeaderLen + wire::MaxPayload); + std::vector buf(legacy_wire::HeaderLen + legacy_wire::MaxPayload); while (!closed_.load()) { ssize_t n = ::recv(fd_, buf.data(), buf.size(), 0); if (n <= 0) break; - if (static_cast(n) < wire::HeaderLen) continue; + if (static_cast(n) < legacy_wire::HeaderLen) continue; switch (buf[3]) { - case wire::TypeResponse: { + case legacy_wire::TypeResponse: { Response resp; - if (wire::decode_response(buf.data(), static_cast(n), resp)) break; + 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()) { @@ -337,9 +342,9 @@ class Controller final : public rcp::Controller { } break; } - case wire::TypeStatus: { + case legacy_wire::TypeStatus: { Status st; - if (wire::decode_status(buf.data(), static_cast(n), st)) break; + 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; diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index c092db8..424bb41 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 = "1.2.0"; +constexpr std::string_view kVersion = "2.0.0"; } // namespace rcp diff --git a/include/rcp/wire.hpp b/include/rcp/wire.hpp index 5d8efb8..0bf4929 100644 --- a/include/rcp/wire.hpp +++ b/include/rcp/wire.hpp @@ -1,58 +1,86 @@ -// 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 shared by the UDP and TLS transports. +// fusa:req REQ-WIRE-001 +// fusa:req REQ-WIRE-002 +// fusa:req REQ-WIRE-003 +// fusa:req REQ-WIRE-004 +// fusa:req REQ-WIRE-005 +// fusa:req REQ-WIRE-006 +// fusa:req REQ-WIRE-007 +// fusa:req REQ-WIRE-008 +// fusa:req REQ-WIRE-009 +// fusa:req REQ-WIRE-010 +// fusa:req REQ-WIRE-011 +// fusa:req REQ-WIRE-012 +// fusa:req REQ-WIRE-013 +// fusa:req REQ-WIRE-014 + +// TC18 wire codec — IEEE 1722 AVTPDU framing (NTSCF/TSCF) plus the ACF_ABB / +// ACF_GBB message types the OPEN Alliance TC18 Remote Control Protocol +// Specification v0.5.1_RC layers on top of them. +// +// ROADMAP.md milestone 44, "Wire Format Core (v2.0.0)": this header 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). +// +// 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 +// RC Server lifecycle state, register-map, discovery, or endpoint-specific +// behavior — those are later Phase 13 milestones (v2.1.0 onward), sequenced +// on top of this header, not into it. Conditional request kinds beyond the +// mandatory "standard" kind (v2.5.0), the E2E CRC safe-point mechanism +// (v2.6.0), and fragmentation (deferred indefinitely, v2.8.0) are likewise +// out of scope here; the fields they need already exist below so later +// milestones can be layered on without reshaping this header. +// +// Transport-agnostic by design: nothing in this module assumes the AVTPDU +// travels over raw Ethernet. The same framing is meant to sit under a native +// IEEE 1722 Ethernet transport, IEEE 1722-over-UDP/IP (v2.13.0), or a +// CAN(FD/XL) transport — no socket, header, or byte-order assumption here is +// specific to any one of those. // -// 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) +// Field names and behavior below implement TC18's *behavior* as described in +// an internal structured extraction of the specification named above; no +// text from that document is reproduced here. The concrete bit-packing +// chosen in this file is this implementation's own encoding of that +// behavior for milestone 44 — full bit-for-bit wire conformance against +// other TC18 implementations is not claimed until v2.6.0 lands, per the +// Phase 13 introduction in ROADMAP.md. #pragma once -#include "rcp.hpp" - +#include +#include +#include #include +#include +#include #include +#include namespace rcp { namespace wire { -constexpr uint8_t MagicByte0 = 0x52; // 'R' -constexpr uint8_t MagicByte1 = 0x43; // 'C' -constexpr uint8_t ProtoVer = 0x01; +// ── AVTPDU subtype identifiers ──────────────────────────────────────────────── +// From the IEEE 1722 AVTP subtype space — a separate, publicly published +// standard from the confidential TC18 document cited above. NTSCF and TSCF +// are the two control formats TC18 builds its messaging on (extraction §2.2). + +constexpr uint8_t kSubtypeTscf = 0x05; // Time-Synchronous Control Format +constexpr uint8_t kSubtypeNtscf = 0x82; // Non-Time-Synchronous Control Format -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; +// ── ACF message types ───────────────────────────────────────────────────────── +// The two ACF (AVTP Control Format) message types RCP defines on top of the +// AVTPDU framing above (extraction §2.3). -constexpr size_t HeaderLen = 16; -constexpr size_t MaxPayload = 65507 - HeaderLen; // fits in one UDP datagram +constexpr uint8_t kAcfMsgTypeGbb = 0x0D; // ACF_GBB — carries a 64-bit message_timestamp slot +constexpr uint8_t kAcfMsgTypeAbb = 0x0E; // ACF_ABB — no timestamp field at all -// ── Codec errors ────────────────────────────────────────────────────────────── +// ── Errors ──────────────────────────────────────────────────────────────────── enum class WireErrc : int { - short_frame = 1, - bad_magic = 2, - bad_version = 3, + short_buffer = 1, // fewer bytes available than the header/field requires + bad_subtype = 2, // AVTPDU subtype byte is neither NTSCF nor TSCF + bad_acf_msg_type = 3, // acf_msg_type is neither ACF_ABB nor ACF_GBB + length_mismatch = 4, // control_data_length / acf_msg_length disagrees with buffer size }; inline const std::error_category& wire_category() noexcept { @@ -60,10 +88,11 @@ inline const std::error_category& wire_category() noexcept { const char* name() const noexcept override { return "rcp.wire"; } std::string message(int ev) const override { switch (static_cast(ev)) { - case WireErrc::short_frame: return "rcp/wire: frame too short"; - case WireErrc::bad_magic: return "rcp/wire: bad magic bytes"; - case WireErrc::bad_version: return "rcp/wire: unsupported protocol version"; - default: return "rcp/wire: unknown error"; + case WireErrc::short_buffer: return "rcp/wire: buffer too short"; + case WireErrc::bad_subtype: return "rcp/wire: unrecognized AVTPDU subtype"; + case WireErrc::bad_acf_msg_type: return "rcp/wire: unrecognized ACF message type"; + case WireErrc::length_mismatch: return "rcp/wire: length field does not match buffer size"; + default: return "rcp/wire: unknown error"; } } }; @@ -89,6 +118,10 @@ inline void put_u32(uint8_t* p, uint32_t v) noexcept { p[2] = static_cast((v >> 8) & 0xFF); p[3] = static_cast(v & 0xFF); } +inline void put_u64(uint8_t* p, uint64_t v) noexcept { + for (int i = 0; i < 8; ++i) + p[i] = static_cast((v >> (56 - 8 * i)) & 0xFF); +} inline uint16_t get_u16(const uint8_t* p) noexcept { return static_cast((uint16_t(p[0]) << 8) | p[1]); } @@ -96,122 +129,348 @@ 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]); } +inline uint64_t get_u64(const uint8_t* p) noexcept { + uint64_t v = 0; + for (int i = 0; i < 8; ++i) + v = (v << 8) | uint64_t(p[i]); + return v; +} + +} // namespace detail + +// ── StreamId ────────────────────────────────────────────────────────────────── +// stream_id addresses the sender of an AVTPDU: the sender's 48-bit MAC +// address plus a 16-bit suffix the sender assigns locally to distinguish its +// own concurrent streams (extraction §2.1). Two different senders' stream_id +// values are only guaranteed distinct because their MAC halves differ; the +// suffix alone is not a global identifier. + +struct StreamId { + std::array mac{}; + uint16_t suffix = 0; + + uint64_t to_u64() const noexcept { + uint64_t v = 0; + for (uint8_t b : mac) v = (v << 8) | uint64_t(b); + return (v << 16) | uint64_t(suffix); + } + + static StreamId from_u64(uint64_t v) noexcept { + StreamId id; + id.suffix = static_cast(v & 0xFFFF); + uint64_t mac_bits = v >> 16; + for (int i = 5; i >= 0; --i) { + id.mac[static_cast(i)] = static_cast(mac_bits & 0xFF); + mac_bits >>= 8; + } + return id; + } +}; + +inline bool operator==(const StreamId& a, const StreamId& b) noexcept { + return a.mac == b.mac && a.suffix == b.suffix; +} +inline bool operator!=(const StreamId& a, const StreamId& b) noexcept { return !(a == b); } + +// ── ByteBusId ───────────────────────────────────────────────────────────────── +// byte_bus_id addresses a target endpoint's request path *within* one +// stream_id. Rule (extraction §2.1): byte_bus_id is unique only within the +// owning stream, never globally — two different clients' streams may reuse +// the same byte_bus_id for unrelated endpoints — and a response or ack MUST +// echo the byte_bus_id of the request it answers, unchanged. This module +// enforces the echo half of that rule structurally: see make_response() +// below, which always copies byte_bus_id (and transaction_num) from the +// request it is built from rather than accepting them as free parameters. + +using ByteBusId = uint8_t; + +// ── NTSCF / TSCF AVTPDU headers ─────────────────────────────────────────────── +// +// NTSCF is non-time-synchronous and, per the target protocol, server-only +// outbound; TSCF is time-synchronous and client-only (extraction §2.2). This +// codec does not know which local role is encoding or decoding a given +// frame, so it cannot itself refuse a TSCF frame built by "the server" — +// enforcing that direction rule is the transport/application layer's job +// once it knows its own role; this module only documents it here and +// exposes the two header shapes distinctly enough that misusing one for the +// other's direction is a visible, deliberate choice rather than an accident. + +struct NtscfHeader { + StreamId stream_id{}; + uint16_t sequence_num = 0; // per-stream_id AVTPDU counter + uint16_t control_data_length = 0; // byte length of the ACF message that follows +}; + +struct TscfHeader { + StreamId stream_id{}; + uint16_t sequence_num = 0; + uint16_t control_data_length = 0; + bool timestamp_valid = false; // "tv" — whether avtp_timestamp below is meaningful + uint32_t avtp_timestamp = 0; // 32-bit; TSCF-only (extraction §2.6) +}; + +constexpr size_t kNtscfHeaderLen = 14; // 1 subtype + 1 flags + 2 seq + 8 stream_id + 2 cdl +constexpr size_t kTscfHeaderLen = 18; // kNtscfHeaderLen + 4 avtp_timestamp +namespace detail { +constexpr uint8_t kFlagStreamValid = 0x80; // "sv" — always set for these control formats +constexpr uint8_t kFlagTimestampValid = 0x40; // "tv" — TSCF only } // namespace detail -// ── Header validation ───────────────────────────────────────────────────────── +inline std::vector encode_ntscf_header(const NtscfHeader& h) { + std::vector buf(kNtscfHeaderLen, 0); + buf[0] = kSubtypeNtscf; + buf[1] = detail::kFlagStreamValid; + detail::put_u16(&buf[2], h.sequence_num); + detail::put_u64(&buf[4], h.stream_id.to_u64()); + detail::put_u16(&buf[12], h.control_data_length); + return buf; +} -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); +inline std::error_code decode_ntscf_header(const uint8_t* b, size_t len, NtscfHeader& out) { + if (len < 1) return make_error_code(WireErrc::short_buffer); + if (b[0] != kSubtypeNtscf) return make_error_code(WireErrc::bad_subtype); + if (len < kNtscfHeaderLen) return make_error_code(WireErrc::short_buffer); + out.sequence_num = detail::get_u16(&b[2]); + out.stream_id = StreamId::from_u64(detail::get_u64(&b[4])); + out.control_data_length = detail::get_u16(&b[12]); 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); +inline std::vector encode_tscf_header(const TscfHeader& h) { + std::vector buf(kTscfHeaderLen, 0); + buf[0] = kSubtypeTscf; + buf[1] = static_cast(detail::kFlagStreamValid | + (h.timestamp_valid ? detail::kFlagTimestampValid : 0)); + detail::put_u16(&buf[2], h.sequence_num); + detail::put_u64(&buf[4], h.stream_id.to_u64()); + detail::put_u16(&buf[12], h.control_data_length); + detail::put_u32(&buf[14], h.avtp_timestamp); 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); +inline std::error_code decode_tscf_header(const uint8_t* b, size_t len, TscfHeader& out) { + if (len < 1) return make_error_code(WireErrc::short_buffer); + if (b[0] != kSubtypeTscf) return make_error_code(WireErrc::bad_subtype); + if (len < kTscfHeaderLen) return make_error_code(WireErrc::short_buffer); + out.timestamp_valid = (b[1] & detail::kFlagTimestampValid) != 0; + out.sequence_num = detail::get_u16(&b[2]); + out.stream_id = StreamId::from_u64(detail::get_u64(&b[4])); + out.control_data_length = detail::get_u16(&b[12]); + out.avtp_timestamp = detail::get_u32(&b[14]); 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); +// ── ACF shared header ("byte_message_info") ─────────────────────────────────── +// Fields common to both ACF_ABB and ACF_GBB messages (extraction §2.4). Every +// field the roadmap calls out is represented individually below rather than +// left packed in an opaque byte, so later milestones (compound requests, +// safety variants, …) can extend behavior around a given field without +// having to re-derive its bit position from scratch. + +struct AcfMessageInfo { + uint8_t acf_msg_type = kAcfMsgTypeAbb; // ACF_ABB or ACF_GBB + uint16_t acf_msg_length = 0; // quadlets, including this shared header + uint8_t pad = 0; // 0–3 trailing pad bytes added for quadlet alignment + bool mtv = false; // message_timestamp valid (ACF_GBB only) + ByteBusId byte_bus_id = 0; // target endpoint, unique within stream_id only + bool evt_ack = false; // evt[3] — acknowledge flag + uint8_t evt_op = 0; // evt[2:0] — endpoint-defined sub-opcode + bool hs = false; // reserved for endpoint-specific use (e.g. I2C high-speed) + bool cs = false; // conditional-start; meaning depends on request kind (v2.5.0) + uint8_t transaction_num = 0; // correlates a request with its response/ack + bool op = false; // false = read, true = write (this codec's convention) + bool rsp = false; // set on every response/ack, clear on requests + bool err = false; // set alongside rsp for an error response + bool ms = false; // "more segments" — fragmentation, deferred to v2.8.0 + uint16_t read_size_or_segment_num = 0; // read_size when !ms, segment_num when ms +}; + +constexpr size_t kAcfCommonHeaderLen = 10; +constexpr size_t kAcfGbbTimestampLen = 8; + +namespace detail { +constexpr uint8_t kByte3MtvBit = 0x80; +constexpr uint8_t kByte3PadShift = 5; +constexpr uint8_t kByte3PadMask = 0x03; +constexpr uint8_t kByte5AckBit = 0x80; +constexpr uint8_t kByte5OpShift = 4; +constexpr uint8_t kByte5OpMask = 0x07; +constexpr uint8_t kByte5HsBit = 0x08; +constexpr uint8_t kByte5CsBit = 0x04; +constexpr uint8_t kByte7OpBit = 0x80; +constexpr uint8_t kByte7RspBit = 0x40; +constexpr uint8_t kByte7ErrBit = 0x20; +constexpr uint8_t kByte7MsBit = 0x10; +} // namespace detail + +inline void encode_acf_message_info(const AcfMessageInfo& info, uint8_t* out10) noexcept { + out10[0] = info.acf_msg_type; + detail::put_u16(&out10[1], info.acf_msg_length); + out10[3] = static_cast( + (info.mtv ? detail::kByte3MtvBit : 0) | + (static_cast(info.pad & detail::kByte3PadMask) << detail::kByte3PadShift)); + out10[4] = info.byte_bus_id; + out10[5] = static_cast( + (info.evt_ack ? detail::kByte5AckBit : 0) | + (static_cast(info.evt_op & detail::kByte5OpMask) << detail::kByte5OpShift) | + (info.hs ? detail::kByte5HsBit : 0) | + (info.cs ? detail::kByte5CsBit : 0)); + out10[6] = info.transaction_num; + out10[7] = static_cast( + (info.op ? detail::kByte7OpBit : 0) | + (info.rsp ? detail::kByte7RspBit : 0) | + (info.err ? detail::kByte7ErrBit : 0) | + (info.ms ? detail::kByte7MsBit : 0)); + detail::put_u16(&out10[8], info.read_size_or_segment_num); +} + +inline void decode_acf_message_info(const uint8_t* in10, AcfMessageInfo& out) noexcept { + out.acf_msg_type = in10[0]; + out.acf_msg_length = detail::get_u16(&in10[1]); + out.mtv = (in10[3] & detail::kByte3MtvBit) != 0; + out.pad = static_cast((in10[3] >> detail::kByte3PadShift) & detail::kByte3PadMask); + out.byte_bus_id = in10[4]; + out.evt_ack = (in10[5] & detail::kByte5AckBit) != 0; + out.evt_op = static_cast((in10[5] >> detail::kByte5OpShift) & detail::kByte5OpMask); + out.hs = (in10[5] & detail::kByte5HsBit) != 0; + out.cs = (in10[5] & detail::kByte5CsBit) != 0; + out.transaction_num = in10[6]; + out.op = (in10[7] & detail::kByte7OpBit) != 0; + out.rsp = (in10[7] & detail::kByte7RspBit) != 0; + out.err = (in10[7] & detail::kByte7ErrBit) != 0; + out.ms = (in10[7] & detail::kByte7MsBit) != 0; + out.read_size_or_segment_num = detail::get_u16(&in10[8]); +} + +// ── ACF_ABB / ACF_GBB message encode / decode ───────────────────────────────── +// ACF_ABB carries no timestamp field at all; ACF_GBB always reserves a +// 64-bit message_timestamp slot immediately after the shared header, +// regardless of whether `mtv` marks it valid (extraction §2.3, §2.7). + +inline std::vector encode_acf_abb(AcfMessageInfo info, + const std::vector& payload) { + info.acf_msg_type = kAcfMsgTypeAbb; + info.mtv = false; + std::vector buf(kAcfCommonHeaderLen + payload.size()); + encode_acf_message_info(info, buf.data()); + std::copy(payload.begin(), payload.end(), buf.begin() + static_cast(kAcfCommonHeaderLen)); 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); +inline std::error_code decode_acf_abb(const uint8_t* b, size_t len, + AcfMessageInfo& out_info, + std::vector& out_payload) { + if (len < 1) return make_error_code(WireErrc::short_buffer); + if (b[0] != kAcfMsgTypeAbb) return make_error_code(WireErrc::bad_acf_msg_type); + if (len < kAcfCommonHeaderLen) return make_error_code(WireErrc::short_buffer); + decode_acf_message_info(b, out_info); + out_payload.assign(b + kAcfCommonHeaderLen, b + 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); +// message_timestamp is the 64-bit ACF_GBB timestamp slot (extraction §2.7, +// §2.9). Its validity is carried by AcfMessageInfo::mtv, not by the value +// itself — an all-zero timestamp is a legitimate valid value. +inline std::vector encode_acf_gbb(AcfMessageInfo info, uint64_t message_timestamp, + const std::vector& payload) { + info.acf_msg_type = kAcfMsgTypeGbb; + std::vector buf(kAcfCommonHeaderLen + kAcfGbbTimestampLen + payload.size()); + encode_acf_message_info(info, buf.data()); + detail::put_u64(&buf[kAcfCommonHeaderLen], message_timestamp); + std::copy(payload.begin(), payload.end(), + buf.begin() + static_cast(kAcfCommonHeaderLen + kAcfGbbTimestampLen)); 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); +inline std::error_code decode_acf_gbb(const uint8_t* b, size_t len, + AcfMessageInfo& out_info, + uint64_t& out_message_timestamp, + std::vector& out_payload) { + if (len < 1) return make_error_code(WireErrc::short_buffer); + if (b[0] != kAcfMsgTypeGbb) return make_error_code(WireErrc::bad_acf_msg_type); + if (len < kAcfCommonHeaderLen + kAcfGbbTimestampLen) + return make_error_code(WireErrc::short_buffer); + decode_acf_message_info(b, out_info); + out_message_timestamp = detail::get_u64(&b[kAcfCommonHeaderLen]); + out_payload.assign(b + kAcfCommonHeaderLen + kAcfGbbTimestampLen, b + len); return {}; } -// ── Control frame (subscribe / unsubscribe) ─────────────────────────────────── +// ── Standard request kind & response semantics ──────────────────────────────── +// The mandatory baseline request kind: best-effort, unconditional dispatch +// via ACF_ABB (extraction §2.7). Conditional kinds (compound, triggered, +// timed, chained, …) are a later milestone (v2.5.0) layered on top of the +// `AcfMessageInfo` fields already defined above; this module only wires up +// the one kind every RC Server must support. -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; +enum class RequestKind : uint8_t { + Standard = 0, +}; + +inline AcfMessageInfo make_standard_request(ByteBusId bus_id, uint8_t transaction_num, + bool write, uint16_t read_size) noexcept { + AcfMessageInfo info; + info.acf_msg_type = kAcfMsgTypeAbb; + info.byte_bus_id = bus_id; + info.transaction_num = transaction_num; + info.op = write; + info.rsp = false; + info.err = false; + info.read_size_or_segment_num = write ? 0 : read_size; + return info; +} + +// The four response semantic types a request can produce, mapped onto the +// shared header's evt/op/rsp/err fields (extraction §2.7, §2.8). +enum class ResponseKind : uint8_t { + Acknowledge = 0, // received, no data — evt_ack set, rsp clear + WriteResponse = 1, // completed write — rsp set, op set, err clear + ReadResponse = 2, // completed read, payload carries the data — rsp set, op clear, err clear + ErrorResponse = 3, // failed — rsp set, err set +}; + +inline ResponseKind response_kind_of(const AcfMessageInfo& info) noexcept { + if (!info.rsp) return ResponseKind::Acknowledge; + if (info.err) return ResponseKind::ErrorResponse; + return info.op ? ResponseKind::WriteResponse : ResponseKind::ReadResponse; +} + +// make_response builds the header for a response/ack to `request`. It always +// copies byte_bus_id and transaction_num from the request unchanged — this +// is the mandatory echo-back rule for byte_bus_id (extraction §2.1) — +// callers cannot construct a response with a different byte_bus_id than the +// request it answers through this function. +inline AcfMessageInfo make_response(const AcfMessageInfo& request, ResponseKind kind) noexcept { + AcfMessageInfo resp; + resp.acf_msg_type = request.acf_msg_type; + resp.byte_bus_id = request.byte_bus_id; + resp.transaction_num = request.transaction_num; + resp.evt_ack = (kind == ResponseKind::Acknowledge); + resp.rsp = (kind != ResponseKind::Acknowledge); + resp.err = (kind == ResponseKind::ErrorResponse); + resp.op = (kind == ResponseKind::WriteResponse); + return resp; +} + +// ── Timestamp fallback rules ────────────────────────────────────────────────── +// avtp_timestamp (TSCF-only, 32-bit) and message_timestamp (ACF_GBB-only, +// 64-bit, valid only when `mtv` is set) are two independent, optional +// sources of timing information for the same logical message (extraction +// §2.6, §2.9). effective_timestamp prefers the TSCF timestamp when present +// and valid, falls back to a valid ACF_GBB message_timestamp, and returns +// std::nullopt — rather than silently defaulting to zero — when neither +// source is usable, so callers can distinguish "no timestamp" from +// "timestamp is zero". + +inline std::optional effective_timestamp(const TscfHeader* tscf, + const AcfMessageInfo* acf_info, + uint64_t message_timestamp) noexcept { + if (tscf != nullptr && tscf->timestamp_valid) + return uint64_t{tscf->avtp_timestamp}; + if (acf_info != nullptr && acf_info->acf_msg_type == kAcfMsgTypeGbb && acf_info->mtv) + return message_timestamp; + return std::nullopt; } } // namespace wire diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7ac1ffc..8994a7e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,10 @@ add_executable(test_wire test_wire.cpp) target_link_libraries(test_wire PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_wire COMMAND test_wire) +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_e2e test_e2e.cpp) target_link_libraries(test_e2e PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_e2e COMMAND test_e2e) diff --git a/tests/test_legacy_wire.cpp b/tests/test_legacy_wire.cpp new file mode 100644 index 0000000..84be8ca --- /dev/null +++ b/tests/test_legacy_wire.cpp @@ -0,0 +1,157 @@ +// 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/wire.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_wire.cpp b/tests/test_wire.cpp index c3d3507..7a782ca 100644 --- a/tests/test_wire.cpp +++ b/tests/test_wire.cpp @@ -1,153 +1,303 @@ -// 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 +// fusa:test REQ-WIRE-001 +// fusa:test REQ-WIRE-002 +// fusa:test REQ-WIRE-003 +// fusa:test REQ-WIRE-004 +// fusa:test REQ-WIRE-005 +// fusa:test REQ-WIRE-006 +// fusa:test REQ-WIRE-007 +// fusa:test REQ-WIRE-008 +// fusa:test REQ-WIRE-009 +// fusa:test REQ-WIRE-010 +// fusa:test REQ-WIRE-011 +// fusa:test REQ-WIRE-012 +// fusa:test REQ-WIRE-013 +// fusa:test REQ-WIRE-014 + +// Tests for rcp/wire.hpp — the TC18 AVTPDU/ACF wire codec (ROADMAP.md +// milestone 44, "Wire Format Core", v2.0.0). #include #include -using namespace rcp; using namespace rcp::wire; -// ── Header constants ────────────────────────────────────────────────────────── +namespace { + +StreamId make_stream_id(uint8_t mac_seed, uint16_t suffix) { + StreamId id; + for (auto& b : id.mac) b = mac_seed++; + id.suffix = suffix; + return id; +} + +} // namespace + +// ── StreamId ─────────────────────────────────────────────────────────────────── + +TEST_CASE("StreamId round-trips through to_u64/from_u64", "[wire][REQ-WIRE-007]") { + auto id = make_stream_id(0x02, 0xBEEF); + auto back = StreamId::from_u64(id.to_u64()); + REQUIRE(back == id); + REQUIRE(back.suffix == 0xBEEF); +} + +TEST_CASE("Distinct MAC halves produce distinct StreamId values", "[wire][REQ-WIRE-007]") { + auto a = make_stream_id(0x02, 0x0001); + auto b = make_stream_id(0x03, 0x0001); + REQUIRE(a != b); +} + +// ── NTSCF header ─────────────────────────────────────────────────────────────── + +TEST_CASE("NTSCF header round-trips stream_id, sequence_num, control_data_length", "[wire][REQ-WIRE-001]") { + NtscfHeader hdr; + hdr.stream_id = make_stream_id(0xAA, 7); + hdr.sequence_num = 4242; + hdr.control_data_length = 128; + + auto buf = encode_ntscf_header(hdr); + REQUIRE(buf.size() == kNtscfHeaderLen); + REQUIRE(buf[0] == kSubtypeNtscf); + + NtscfHeader out; + REQUIRE_FALSE(decode_ntscf_header(buf.data(), buf.size(), out)); + REQUIRE(out.stream_id == hdr.stream_id); + REQUIRE(out.sequence_num == hdr.sequence_num); + REQUIRE(out.control_data_length == hdr.control_data_length); +} + +// ── TSCF header ──────────────────────────────────────────────────────────────── + +TEST_CASE("TSCF header round-trips avtp_timestamp when timestamp_valid is set", "[wire][REQ-WIRE-002][REQ-WIRE-011]") { + TscfHeader hdr; + hdr.stream_id = make_stream_id(0x10, 99); + hdr.sequence_num = 5; + hdr.control_data_length = 64; + hdr.timestamp_valid = true; + hdr.avtp_timestamp = 0xCAFEBABE; + + auto buf = encode_tscf_header(hdr); + REQUIRE(buf.size() == kTscfHeaderLen); + REQUIRE(buf[0] == kSubtypeTscf); + + TscfHeader out; + REQUIRE_FALSE(decode_tscf_header(buf.data(), buf.size(), out)); + REQUIRE(out.timestamp_valid); + REQUIRE(out.avtp_timestamp == hdr.avtp_timestamp); + REQUIRE(out.stream_id == hdr.stream_id); +} + +TEST_CASE("TSCF header marks timestamp invalid distinctly from a zero timestamp", "[wire][REQ-WIRE-011]") { + TscfHeader hdr; + hdr.timestamp_valid = false; + hdr.avtp_timestamp = 0; + + auto buf = encode_tscf_header(hdr); + TscfHeader out; + REQUIRE_FALSE(decode_tscf_header(buf.data(), buf.size(), out)); + REQUIRE_FALSE(out.timestamp_valid); +} + +// ── AVTPDU subtype validation ────────────────────────────────────────────────── -TEST_CASE("Wire header length and magic are correct", "[wire][REQ-UDP-001]") { - REQUIRE(HeaderLen == 16); - REQUIRE(MagicByte0 == 'R'); - REQUIRE(MagicByte1 == 'C'); +TEST_CASE("decode_ntscf_header rejects a TSCF-subtype buffer", "[wire][REQ-WIRE-003]") { + TscfHeader tscf; + auto buf = encode_tscf_header(tscf); + NtscfHeader out; + auto ec = decode_ntscf_header(buf.data(), buf.size(), out); + REQUIRE(ec); + REQUIRE(ec == make_error_code(WireErrc::bad_subtype)); } -TEST_CASE("Wire version is 1", "[wire][REQ-UDP-002]") { - REQUIRE(ProtoVer == 1); +TEST_CASE("decode_tscf_header rejects an NTSCF-subtype buffer", "[wire][REQ-WIRE-003]") { + NtscfHeader ntscf; + auto buf = encode_ntscf_header(ntscf); + TscfHeader out; + auto ec = decode_tscf_header(buf.data(), buf.size(), out); + REQUIRE(ec); + REQUIRE(ec == make_error_code(WireErrc::bad_subtype)); } -TEST_CASE("MaxPayload is non-zero", "[wire][REQ-UDP-003]") { - REQUIRE(MaxPayload > 0); +// ── ACF_ABB ───────────────────────────────────────────────────────────────────── + +TEST_CASE("ACF_ABB round-trips the shared header and payload with no timestamp field", "[wire][REQ-WIRE-004][REQ-WIRE-006]") { + AcfMessageInfo info; + info.byte_bus_id = 7; + info.transaction_num = 42; + info.evt_ack = true; + info.evt_op = 5; + info.hs = true; + info.cs = false; + info.op = true; + info.rsp = false; + info.err = false; + info.ms = false; + info.pad = 2; + info.read_size_or_segment_num = 16; + + std::vector payload = {0x01, 0x02, 0x03}; + auto frame = encode_acf_abb(info, payload); + REQUIRE(frame.size() == kAcfCommonHeaderLen + payload.size()); + REQUIRE(frame[0] == kAcfMsgTypeAbb); + + AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(decode_acf_abb(frame.data(), frame.size(), out, out_payload)); + REQUIRE(out.acf_msg_type == kAcfMsgTypeAbb); + REQUIRE(out.byte_bus_id == info.byte_bus_id); + REQUIRE(out.transaction_num == info.transaction_num); + REQUIRE(out.evt_ack == info.evt_ack); + REQUIRE(out.evt_op == info.evt_op); + REQUIRE(out.hs == info.hs); + REQUIRE(out.cs == info.cs); + REQUIRE(out.op == info.op); + REQUIRE(out.rsp == info.rsp); + REQUIRE(out.err == info.err); + REQUIRE(out.ms == info.ms); + REQUIRE(out.pad == info.pad); + REQUIRE(out.mtv == false); // ACF_ABB never carries a timestamp + REQUIRE(out.read_size_or_segment_num == info.read_size_or_segment_num); + REQUIRE(out_payload == payload); } -// ── Command round-trip ──────────────────────────────────────────────────────── +// ── ACF_GBB ───────────────────────────────────────────────────────────────────── -TEST_CASE("encode_command / decode_command round-trip with no payload", "[wire][REQ-UDP-004]") { - Command cmd; - cmd.id = 0xDEADBEEF; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Set; - cmd.priority = Priority::High; +TEST_CASE("ACF_GBB round-trips a 64-bit message_timestamp alongside the shared header", "[wire][REQ-WIRE-005][REQ-WIRE-006]") { + AcfMessageInfo info; + info.byte_bus_id = 3; + info.transaction_num = 9; + info.mtv = true; + info.rsp = true; + info.op = false; - auto frame = encode_command(cmd); - REQUIRE(frame.size() >= HeaderLen); + uint64_t ts = 0x0123456789ABCDEFULL; + std::vector payload = {0xAA, 0xBB, 0xCC, 0xDD}; + auto frame = encode_acf_gbb(info, ts, payload); + REQUIRE(frame.size() == kAcfCommonHeaderLen + kAcfGbbTimestampLen + payload.size()); + REQUIRE(frame[0] == kAcfMsgTypeGbb); - 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()); + AcfMessageInfo out; + uint64_t out_ts = 0; + std::vector out_payload; + REQUIRE_FALSE(decode_acf_gbb(frame.data(), frame.size(), out, out_ts, out_payload)); + REQUIRE(out.mtv == true); + REQUIRE(out_ts == ts); + REQUIRE(out_payload == payload); } -TEST_CASE("encode_command / decode_command round-trip with payload", "[wire][REQ-UDP-005]") { - Command cmd; - cmd.id = 42; - cmd.zone = Zone::RearRight; - cmd.type = CommandType::Get; - cmd.payload = {0x01, 0x02, 0x03, 0x04}; +TEST_CASE("decode_acf_abb rejects an ACF_GBB-typed buffer and vice versa", "[wire][REQ-WIRE-014]") { + AcfMessageInfo info; + auto gbb_frame = encode_acf_gbb(info, 0, {}); + AcfMessageInfo out; + std::vector out_payload; + auto ec = decode_acf_abb(gbb_frame.data(), gbb_frame.size(), out, out_payload); + REQUIRE(ec); + REQUIRE(ec == make_error_code(WireErrc::bad_acf_msg_type)); - auto frame = encode_command(cmd); - Command out; - REQUIRE_FALSE(decode_command(frame.data(), frame.size(), out)); - REQUIRE(out.payload == cmd.payload); + auto abb_frame = encode_acf_abb(info, {}); + uint64_t ts = 0; + auto ec2 = decode_acf_gbb(abb_frame.data(), abb_frame.size(), out, ts, out_payload); + REQUIRE(ec2); + REQUIRE(ec2 == make_error_code(WireErrc::bad_acf_msg_type)); } -TEST_CASE("decode_command rejects truncated frames", "[wire][REQ-UDP-006]") { - Command cmd; - cmd.id = 1; - cmd.zone = Zone::Central; - auto frame = encode_command(cmd); +// ── Short-buffer validation ───────────────────────────────────────────────────── + +TEST_CASE("Decoders reject buffers shorter than their fixed header", "[wire][REQ-WIRE-013]") { + NtscfHeader ntscf_out; + std::vector too_short(kNtscfHeaderLen - 1, 0); + REQUIRE(decode_ntscf_header(too_short.data(), too_short.size(), ntscf_out)); - Command out; - REQUIRE(decode_command(frame.data(), HeaderLen - 1, out)); // truncated + AcfMessageInfo abb_out; + std::vector abb_payload; + std::vector too_short_acf(kAcfCommonHeaderLen - 1, 0); + REQUIRE(decode_acf_abb(too_short_acf.data(), too_short_acf.size(), abb_out, abb_payload)); } -// ── Response round-trip ─────────────────────────────────────────────────────── +// ── byte_bus_id echo rule ─────────────────────────────────────────────────────── -TEST_CASE("encode_response / decode_response round-trip", "[wire][REQ-UDP-007]") { - Response resp; - resp.command_id = 0x1234; - resp.zone = Zone::FrontRight; - resp.status = ResponseStatus::OK; - resp.payload = {0xFF, 0xFE}; +TEST_CASE("make_response echoes byte_bus_id and transaction_num from the request unchanged", "[wire][REQ-WIRE-008]") { + auto req = make_standard_request(/*bus_id=*/9, /*transaction_num=*/17, /*write=*/false, /*read_size=*/4); + auto resp = make_response(req, ResponseKind::ReadResponse); + REQUIRE(resp.byte_bus_id == req.byte_bus_id); + REQUIRE(resp.transaction_num == req.transaction_num); +} + +// ── Standard request kind ─────────────────────────────────────────────────────── - 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); +TEST_CASE("make_standard_request builds an unconditional ACF_ABB read request", "[wire][REQ-WIRE-009]") { + auto req = make_standard_request(1, 1, /*write=*/false, /*read_size=*/8); + REQUIRE(req.acf_msg_type == kAcfMsgTypeAbb); + REQUIRE_FALSE(req.op); + REQUIRE_FALSE(req.rsp); + REQUIRE(req.read_size_or_segment_num == 8); } -// ── Status round-trip ───────────────────────────────────────────────────────── +TEST_CASE("make_standard_request builds an unconditional ACF_ABB write request", "[wire][REQ-WIRE-009]") { + auto req = make_standard_request(1, 1, /*write=*/true, /*read_size=*/0); + REQUIRE(req.acf_msg_type == kAcfMsgTypeAbb); + REQUIRE(req.op); + REQUIRE_FALSE(req.rsp); +} + +// ── Four response semantic types ──────────────────────────────────────────────── + +TEST_CASE("The four response kinds map onto distinct, recoverable header states", "[wire][REQ-WIRE-010]") { + auto req = make_standard_request(2, 5, false, 4); -TEST_CASE("encode_status / decode_status round-trip", "[wire][REQ-UDP-008]") { - Status st; - st.zone = Zone::RearLeft; - st.seq = 99; - st.healthy = true; - st.payload = {0xAB}; + auto ack = make_response(req, ResponseKind::Acknowledge); + REQUIRE(response_kind_of(ack) == ResponseKind::Acknowledge); - 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); + auto write_resp = make_response(req, ResponseKind::WriteResponse); + REQUIRE(response_kind_of(write_resp) == ResponseKind::WriteResponse); + + auto read_resp = make_response(req, ResponseKind::ReadResponse); + REQUIRE(response_kind_of(read_resp) == ResponseKind::ReadResponse); + + auto err_resp = make_response(req, ResponseKind::ErrorResponse); + REQUIRE(response_kind_of(err_resp) == ResponseKind::ErrorResponse); + + // All four are pairwise distinguishable through the shared header alone. + REQUIRE(response_kind_of(ack) != response_kind_of(write_resp)); + REQUIRE(response_kind_of(write_resp) != response_kind_of(read_resp)); + REQUIRE(response_kind_of(read_resp) != response_kind_of(err_resp)); + REQUIRE(response_kind_of(err_resp) != response_kind_of(ack)); +} + +// ── Timestamp fallback rules ──────────────────────────────────────────────────── + +TEST_CASE("effective_timestamp prefers a valid TSCF avtp_timestamp", "[wire][REQ-WIRE-012]") { + TscfHeader tscf; + tscf.timestamp_valid = true; + tscf.avtp_timestamp = 111; + + AcfMessageInfo gbb_info; + gbb_info.acf_msg_type = kAcfMsgTypeGbb; + gbb_info.mtv = true; + + auto ts = effective_timestamp(&tscf, &gbb_info, /*message_timestamp=*/222); + REQUIRE(ts.has_value()); + REQUIRE(*ts == 111); } -// ── Frame magic / version ───────────────────────────────────────────────────── +TEST_CASE("effective_timestamp falls back to a valid ACF_GBB message_timestamp", "[wire][REQ-WIRE-012]") { + AcfMessageInfo gbb_info; + gbb_info.acf_msg_type = kAcfMsgTypeGbb; + gbb_info.mtv = true; -TEST_CASE("Frames begin with 'RC' magic and correct version", "[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); + auto ts = effective_timestamp(nullptr, &gbb_info, /*message_timestamp=*/333); + REQUIRE(ts.has_value()); + REQUIRE(*ts == 333); } -TEST_CASE("decode_command rejects wrong magic", "[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", "[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", "[wire][REQ-UDP-012]") { - auto frame = encode_control(TypeSubscribe, Zone::Central); - REQUIRE(frame.size() == HeaderLen); - REQUIRE(frame[0] == 'R'); - REQUIRE(frame[3] == TypeSubscribe); +TEST_CASE("effective_timestamp is nullopt, not zero, when neither source is valid", "[wire][REQ-WIRE-012]") { + TscfHeader tscf; // timestamp_valid defaults to false + AcfMessageInfo abb_info; + abb_info.acf_msg_type = kAcfMsgTypeAbb; // ACF_ABB never has a timestamp + + auto ts = effective_timestamp(&tscf, &abb_info, /*message_timestamp=*/0); + REQUIRE_FALSE(ts.has_value()); + + auto ts2 = effective_timestamp(nullptr, nullptr, 0); + REQUIRE_FALSE(ts2.has_value()); }