diff --git a/CMakeLists.txt b/CMakeLists.txt index 8155fcc..1156aee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,9 @@ add_library(secsgem src/secs2/codec.cpp src/hsms/header.cpp src/hsms/connection.cpp + src/secsi/header.cpp + src/secsi/block.cpp + src/secsi/protocol.cpp src/gem/control_state.cpp src/gem/communication_state.cpp src/gem/process_job_state.cpp @@ -81,6 +84,7 @@ add_executable(secsgem_tests tests/test_secs2.cpp tests/test_hsms.cpp tests/test_hsms_connection.cpp + tests/test_secsi.cpp tests/test_control_state.cpp tests/test_communication_state.cpp tests/test_data_model.cpp diff --git a/COMPLIANCE.md b/COMPLIANCE.md index aa09d00..e6b3c15 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -40,6 +40,24 @@ Legend: --- +## 1a. E4 — SECS-I transport (block protocol) + +| Item | Status | Spec ref | Notes | +|---------------------------------------|--------|----------|-------| +| 10-byte block header (R/W/E bits, system bytes) | ✅ | E4 §6.2 | `secsi::Header` with bit-precise pack/unpack. | +| Length-prefixed block + 2-byte checksum | ✅ | E4 §6.1, §6.3 | `secsi::Block::encode/decode`. | +| Multi-block message split / assemble | ✅ | E4 §7.2.3 | `split_message` / `assemble_message`; E-bit only on the final block. | +| ENQ/EOT/ACK/NAK handshake | ✅ | E4 §7.1 | `secsi::Protocol` half-duplex FSM. | +| RTY retry counter | ✅ | E4 §10.2 | Per-block retry budget, exhaust → ActionRaiseError. | +| T1 inter-character timer hook | ✅ | E4 §10.1 | Drained in `RecvBlock`; host wires the actual asio timer. | +| T2 protocol timer hook | ✅ | E4 §10.1 | Triggers a retry from any send state. | +| T3 reply timer | ⬜ | E4 §10.1 | Driven by the upper layer (same as HSMS). | +| T4 inter-block timer | ⬜ | E4 §10.1 | Multi-block message-gap; FSM emits hook events. | +| Master/slave contention resolution | ✅ | E4 §7.1.4 | Slave yields on simultaneous ENQ; master holds. | +| Serial port wiring (asio) | ⬜ | — | FSM is IO-free; a follow-up commit will land the asio integration. | + +--- + ## 2. E5 — SECS-II encoding | Item | Status | Spec ref | Notes | diff --git a/include/secsgem/secsi/block.hpp b/include/secsgem/secsi/block.hpp new file mode 100644 index 0000000..6550223 --- /dev/null +++ b/include/secsgem/secsi/block.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "secsgem/secs2/message.hpp" +#include "secsgem/secsi/header.hpp" + +// SECS-I block framing. Each block on the wire is: +// +// 1 byte, 10..254 (header size + body size) +//
10 bytes (see secsi/header.hpp) +// 0..244 bytes +// 2 bytes, big-endian sum of
mod 65536 +// +// Long SECS-II messages are split into multiple blocks; only the final +// block has the E-bit set in its header (E4 §7.2.3). The block number +// is 1-based and increments monotonically across the message. +namespace secsgem::secsi { + +class BlockError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +struct Block { + Header header; + std::vector body; + + // Full wire encoding: length byte + 10-byte header + body + 2-byte + // checksum. + std::vector encode() const; + + // Parse a block whose first byte is the length. Returns the consumed + // byte count via `bytes_consumed`; throws BlockError on a short read, + // a bad length, or a checksum mismatch. + static Block decode(const uint8_t* data, std::size_t len, + std::size_t& bytes_consumed); + + // Compute the 16-bit checksum of the header + body bytes (everything + // between the length byte and the checksum on the wire). + static uint16_t checksum(const uint8_t* data, std::size_t len); +}; + +// Split a SECS-II message into one or more SECS-I blocks. Each block +// carries up to kMaxBlockBody payload bytes; only the final block has +// the E-bit set. The header template is duplicated across all blocks +// (block_number / end_block are filled in by the splitter). +std::vector split_message(const secs2::Message& msg, + const Header& header_template); + +// Reassemble a SECS-II message from a sequence of blocks. Blocks must +// be contiguous (block_number 1..N) and the last must have end_block=true; +// the function returns nullopt if either invariant is violated. +std::optional assemble_message(const std::vector& blocks); + +} // namespace secsgem::secsi diff --git a/include/secsgem/secsi/header.hpp b/include/secsgem/secsi/header.hpp new file mode 100644 index 0000000..4e60cc3 --- /dev/null +++ b/include/secsgem/secsi/header.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +// SECS-I block-header primitives (SEMI E4). Each block carries a 10-byte +// header with the same general shape as HSMS but laid out differently: +// the R-bit lives in the upper bit of byte 0, the W-bit in the upper bit +// of byte 2 (stream), and the E-bit + block number live in bytes 4-5 +// (E-bit = MSB of byte 4). Bytes 6-9 are the 32-bit system bytes. +namespace secsgem::secsi { + +// One SECS-I block carries up to 244 bytes of payload (length byte 10..254 +// covers 10-byte header + up to 244 bytes; 255 is reserved). +inline constexpr std::size_t kHeaderSize = 10; +inline constexpr std::size_t kMaxBlockBody = 244; +inline constexpr uint8_t kMaxLengthByte = 254; + +struct Header { + // R-bit + 15-bit device ID packed into bytes 0-1 (big-endian). + // R=1 means "host -> equipment", R=0 means "equipment -> host". + uint16_t device_id = 0; + bool r_bit = false; + + // W-bit + 7-bit stream packed into byte 2. W=1 marks a primary message + // that expects a reply (same semantics as HSMS). + uint8_t stream = 0; + bool w_bit = false; + uint8_t function = 0; // byte 3 + + // E-bit + 15-bit block number packed into bytes 4-5 (big-endian). + // E=1 marks the last block of a multi-block message; numbering is + // 1-based per E4 §7.2.3. + uint16_t block_number = 1; + bool end_block = true; + + uint32_t system_bytes = 0; // bytes 6-9 (big-endian) + + std::array encode() const; + static Header decode(const uint8_t* data); // reads exactly 10 bytes + + std::string describe() const; + + bool operator==(const Header&) const = default; +}; + +} // namespace secsgem::secsi diff --git a/include/secsgem/secsi/protocol.hpp b/include/secsgem/secsi/protocol.hpp new file mode 100644 index 0000000..7b816a8 --- /dev/null +++ b/include/secsgem/secsi/protocol.hpp @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "secsgem/secsi/block.hpp" + +// SECS-I half-duplex line-turnaround state machine (E4 §7). +// +// The protocol is event-driven and IO-free: it takes inputs (byte +// received, "I want to send this block", timer fired, peer offline) and +// produces actions (transmit these bytes, deliver this block, raise an +// error). Wrapping it in a serial-port driver is straightforward — see +// the tests for an in-memory back-to-back example. +// +// SECS-I control bytes (E4 §6.1): +// ENQ 0x05 "I want to send" +// EOT 0x04 "go ahead, send" +// ACK 0x06 "block received OK" +// NAK 0x15 "block bad / resend" +// +// Master/slave contention: per E4 §7.1.4, when both peers ENQ +// simultaneously the master (typically the host) holds, the slave +// (typically the equipment) yields. Our Role enum captures that. +namespace secsgem::secsi { + +inline constexpr uint8_t kENQ = 0x05; +inline constexpr uint8_t kEOT = 0x04; +inline constexpr uint8_t kACK = 0x06; +inline constexpr uint8_t kNAK = 0x15; + +enum class Role { Master, Slave }; + +enum class Timer : uint8_t { + T1, // inter-character (default 0.5s, E4 §10.1) + T2, // protocol (default 10s) + T3, // reply (default 45s) — driven at a higher layer + T4, // inter-block (default 45s) +}; + +struct Timers { + std::chrono::milliseconds t1{500}; + std::chrono::milliseconds t2{10000}; + std::chrono::milliseconds t3{45000}; + std::chrono::milliseconds t4{45000}; + uint8_t rty = 3; // retries before giving up on a block +}; + +// --- Actions the FSM asks its host to perform ---------------------------- + +struct ActionTransmit { std::vector bytes; }; +struct ActionStartTimer { Timer which; }; +struct ActionCancelTimer { Timer which; }; +struct ActionDeliverBlock { Block block; }; +struct ActionRaiseError { std::string reason; }; + +using Action = std::variant; + +// --- Inputs the host feeds in -------------------------------------------- + +struct EventByte { uint8_t byte; }; // one byte received on the line +struct EventSend { Block block; }; // application asks to send a block +struct EventTimeout { Timer which; }; // a previously-armed timer fired + +using Event = std::variant; + +// --- State machine ------------------------------------------------------- + +class Protocol { + public: + enum class State { + Idle, // line free + SendEnqSent, // we sent ENQ, waiting for peer EOT + SendBlock, // peer cleared us; we're transmitting the block + SendAwaitAck, // block sent, waiting for ACK/NAK + RecvEnqSeen, // peer sent ENQ; we owe them an EOT + RecvEotSent, // we sent EOT; expecting block bytes + RecvBlock, // block bytes arriving + RecvAcking, // block fully received; we'll send ACK/NAK + }; + + explicit Protocol(Role role, Timers timers = {}) + : role_(role), timers_(timers) {} + + Role role() const { return role_; } + State state() const { return state_; } + uint8_t rty_remaining() const { return rty_; } + + // Feed an event in; the FSM appends its desired actions to `out`. + void on_event(const Event& ev, std::vector& out); + + // Test-only: peek the queue of blocks waiting to send. + std::size_t pending_send_size() const { return send_queue_.size(); } + + private: + // Start of one send transaction (when send_queue_ non-empty and we're Idle). + void begin_send(std::vector& out); + // Re-attempt the current block after a NAK or timeout. + void retry_send(std::vector& out); + // Successful send: pop the block from the queue and return to Idle. + void complete_send(std::vector& out); + // Recv path: deliver the assembled block. + void deliver_recv(std::vector& out); + // Hard abort: give up, raise error, reset to Idle. + void abort(std::string reason, std::vector& out); + + Role role_; + Timers timers_; + State state_ = State::Idle; + + // --- send-side state --- + std::deque send_queue_; + std::vector send_block_bytes_; // encoded bytes of the current block + uint8_t rty_ = 0; // retries left for the current block + + // --- receive-side state --- + std::vector recv_buf_; // bytes collected since EOT + std::size_t recv_expected_ = 0; // length byte + payload + checksum +}; + +const char* state_name(Protocol::State s); + +} // namespace secsgem::secsi diff --git a/src/secsi/block.cpp b/src/secsi/block.cpp new file mode 100644 index 0000000..c4c3011 --- /dev/null +++ b/src/secsi/block.cpp @@ -0,0 +1,122 @@ +#include "secsgem/secsi/block.hpp" + +#include +#include + +namespace secsgem::secsi { + +uint16_t Block::checksum(const uint8_t* data, std::size_t len) { + uint32_t sum = 0; + for (std::size_t i = 0; i < len; ++i) sum += data[i]; + return static_cast(sum & 0xFFFF); +} + +std::vector Block::encode() const { + if (body.size() > kMaxBlockBody) { + throw BlockError("SECS-I body exceeds 244 bytes: " + + std::to_string(body.size())); + } + const std::size_t body_size = body.size(); + const std::size_t header_plus_body = kHeaderSize + body_size; + std::vector out; + out.reserve(1 + header_plus_body + 2); + out.push_back(static_cast(header_plus_body)); + + auto h = header.encode(); + out.insert(out.end(), h.begin(), h.end()); + out.insert(out.end(), body.begin(), body.end()); + + uint16_t cs = checksum(out.data() + 1, header_plus_body); + out.push_back(static_cast(cs >> 8)); + out.push_back(static_cast(cs & 0xFF)); + return out; +} + +Block Block::decode(const uint8_t* data, std::size_t len, + std::size_t& bytes_consumed) { + if (len < 1) throw BlockError("SECS-I block: empty input"); + const uint8_t length_byte = data[0]; + if (length_byte < kHeaderSize) { + throw BlockError("SECS-I block: length byte " + std::to_string(length_byte) + + " < header size"); + } + if (length_byte > kMaxLengthByte) { + throw BlockError("SECS-I block: length byte " + std::to_string(length_byte) + + " > 254 (reserved)"); + } + const std::size_t total = 1 + length_byte + 2; + if (len < total) { + throw BlockError("SECS-I block: short input (" + std::to_string(len) + + " bytes, need " + std::to_string(total) + ")"); + } + + const uint16_t got_cs = (static_cast(data[1 + length_byte]) << 8) | + static_cast(data[1 + length_byte + 1]); + const uint16_t want_cs = checksum(data + 1, length_byte); + if (got_cs != want_cs) { + throw BlockError("SECS-I block: checksum mismatch (got " + + std::to_string(got_cs) + ", want " + + std::to_string(want_cs) + ")"); + } + + Block b; + b.header = Header::decode(data + 1); + const std::size_t body_size = static_cast(length_byte) - kHeaderSize; + b.body.assign(data + 1 + kHeaderSize, data + 1 + kHeaderSize + body_size); + bytes_consumed = total; + return b; +} + +std::vector split_message(const secs2::Message& msg, + const Header& header_template) { + std::vector blocks; + const std::vector payload = msg.encode_body(); + + // Even an empty-body message produces exactly one block (block#1, E=1). + std::size_t offset = 0; + uint16_t block_no = 1; + do { + const std::size_t chunk = + std::min(kMaxBlockBody, payload.size() - offset); + Block b; + b.header = header_template; + b.header.stream = msg.stream; + b.header.function = msg.function; + b.header.w_bit = msg.reply_expected; + b.header.block_number = block_no; + b.header.end_block = (offset + chunk >= payload.size()); + b.body.assign(payload.begin() + offset, + payload.begin() + offset + chunk); + blocks.push_back(std::move(b)); + offset += chunk; + ++block_no; + } while (offset < payload.size()); + + return blocks; +} + +std::optional assemble_message(const std::vector& blocks) { + if (blocks.empty()) return std::nullopt; + + // Validate contiguous 1..N numbering with E-bit only on the last block. + for (std::size_t i = 0; i < blocks.size(); ++i) { + if (blocks[i].header.block_number != i + 1) return std::nullopt; + const bool last = (i + 1 == blocks.size()); + if (blocks[i].header.end_block != last) return std::nullopt; + } + // Stream/function must be consistent across blocks. + const auto& h0 = blocks.front().header; + for (const auto& b : blocks) { + if (b.header.stream != h0.stream || b.header.function != h0.function || + b.header.w_bit != h0.w_bit) + return std::nullopt; + } + + std::vector payload; + for (const auto& b : blocks) + payload.insert(payload.end(), b.body.begin(), b.body.end()); + + return secs2::Message::from_body(h0.stream, h0.function, h0.w_bit, payload); +} + +} // namespace secsgem::secsi diff --git a/src/secsi/header.cpp b/src/secsi/header.cpp new file mode 100644 index 0000000..14496fa --- /dev/null +++ b/src/secsi/header.cpp @@ -0,0 +1,58 @@ +#include "secsgem/secsi/header.hpp" + +#include + +namespace secsgem::secsi { + +std::array Header::encode() const { + std::array out{}; + // R-bit | 15-bit device id (bytes 0-1, big-endian). + uint16_t devword = static_cast(device_id & 0x7FFF); + if (r_bit) devword |= 0x8000; + out[0] = static_cast(devword >> 8); + out[1] = static_cast(devword & 0xFF); + // W-bit | 7-bit stream (byte 2), function (byte 3). + out[2] = static_cast((w_bit ? 0x80 : 0x00) | (stream & 0x7F)); + out[3] = function; + // E-bit | 15-bit block number (bytes 4-5, big-endian). + uint16_t blkword = static_cast(block_number & 0x7FFF); + if (end_block) blkword |= 0x8000; + out[4] = static_cast(blkword >> 8); + out[5] = static_cast(blkword & 0xFF); + // System bytes (6-9, big-endian). + out[6] = static_cast((system_bytes >> 24) & 0xFF); + out[7] = static_cast((system_bytes >> 16) & 0xFF); + out[8] = static_cast((system_bytes >> 8) & 0xFF); + out[9] = static_cast(system_bytes & 0xFF); + return out; +} + +Header Header::decode(const uint8_t* d) { + Header h; + uint16_t devword = static_cast((d[0] << 8) | d[1]); + h.r_bit = (devword & 0x8000) != 0; + h.device_id = static_cast(devword & 0x7FFF); + h.w_bit = (d[2] & 0x80) != 0; + h.stream = static_cast(d[2] & 0x7F); + h.function = d[3]; + uint16_t blkword = static_cast((d[4] << 8) | d[5]); + h.end_block = (blkword & 0x8000) != 0; + h.block_number = static_cast(blkword & 0x7FFF); + h.system_bytes = (static_cast(d[6]) << 24) | + (static_cast(d[7]) << 16) | + (static_cast(d[8]) << 8) | + (static_cast(d[9])); + return h; +} + +std::string Header::describe() const { + std::ostringstream os; + os << "SecsI[dev=" << device_id << (r_bit ? " R" : "") + << " S" << static_cast(stream) << 'F' << static_cast(function) + << (w_bit ? " W" : "") + << " block=" << block_number << (end_block ? "E" : "") + << " sys=" << system_bytes << ']'; + return os.str(); +} + +} // namespace secsgem::secsi diff --git a/src/secsi/protocol.cpp b/src/secsi/protocol.cpp new file mode 100644 index 0000000..503377f --- /dev/null +++ b/src/secsi/protocol.cpp @@ -0,0 +1,182 @@ +#include "secsgem/secsi/protocol.hpp" + +#include + +namespace secsgem::secsi { + +const char* state_name(Protocol::State s) { + switch (s) { + case Protocol::State::Idle: return "Idle"; + case Protocol::State::SendEnqSent: return "SendEnqSent"; + case Protocol::State::SendBlock: return "SendBlock"; + case Protocol::State::SendAwaitAck: return "SendAwaitAck"; + case Protocol::State::RecvEnqSeen: return "RecvEnqSeen"; + case Protocol::State::RecvEotSent: return "RecvEotSent"; + case Protocol::State::RecvBlock: return "RecvBlock"; + case Protocol::State::RecvAcking: return "RecvAcking"; + } + return "?"; +} + +void Protocol::begin_send(std::vector& out) { + send_block_bytes_ = send_queue_.front().encode(); + rty_ = timers_.rty; + out.push_back(ActionTransmit{{kENQ}}); + out.push_back(ActionStartTimer{Timer::T2}); + state_ = State::SendEnqSent; +} + +void Protocol::retry_send(std::vector& out) { + if (rty_ == 0) { + abort("RTY exhausted", out); + return; + } + --rty_; + out.push_back(ActionCancelTimer{Timer::T2}); + out.push_back(ActionTransmit{{kENQ}}); + out.push_back(ActionStartTimer{Timer::T2}); + state_ = State::SendEnqSent; +} + +void Protocol::complete_send(std::vector& out) { + out.push_back(ActionCancelTimer{Timer::T2}); + send_queue_.pop_front(); + send_block_bytes_.clear(); + state_ = State::Idle; + if (!send_queue_.empty()) begin_send(out); +} + +void Protocol::deliver_recv(std::vector& out) { + // recv_buf_ holds [length, header..body, cs_hi, cs_lo]. + try { + std::size_t consumed = 0; + Block b = Block::decode(recv_buf_.data(), recv_buf_.size(), consumed); + out.push_back(ActionTransmit{{kACK}}); + out.push_back(ActionDeliverBlock{std::move(b)}); + } catch (const BlockError& e) { + out.push_back(ActionTransmit{{kNAK}}); + out.push_back(ActionRaiseError{std::string("recv: ") + e.what()}); + } + recv_buf_.clear(); + recv_expected_ = 0; + out.push_back(ActionCancelTimer{Timer::T1}); + state_ = State::Idle; + if (!send_queue_.empty()) begin_send(out); +} + +void Protocol::abort(std::string reason, std::vector& out) { + out.push_back(ActionCancelTimer{Timer::T1}); + out.push_back(ActionCancelTimer{Timer::T2}); + out.push_back(ActionRaiseError{std::move(reason)}); + send_queue_.clear(); + send_block_bytes_.clear(); + recv_buf_.clear(); + recv_expected_ = 0; + rty_ = 0; + state_ = State::Idle; +} + +void Protocol::on_event(const Event& ev, std::vector& out) { + // --- App requested a send ------------------------------------------------- + if (auto* es = std::get_if(&ev)) { + send_queue_.push_back(es->block); + if (state_ == State::Idle) begin_send(out); + return; + } + + // --- Timer fired ---------------------------------------------------------- + if (auto* et = std::get_if(&ev)) { + switch (et->which) { + case Timer::T2: + // Protocol timeout: in any send state, retry; otherwise abort recv. + if (state_ == State::SendEnqSent || state_ == State::SendBlock || + state_ == State::SendAwaitAck) { + retry_send(out); + } else if (state_ == State::RecvEotSent || state_ == State::RecvBlock) { + abort("T2 timeout during recv", out); + } + return; + case Timer::T1: + if (state_ == State::RecvBlock) abort("T1 inter-character timeout", out); + return; + case Timer::T3: + case Timer::T4: + // Driven at the higher layer; FSM itself does not enforce. + return; + } + return; + } + + // --- Byte received on the line -------------------------------------------- + const uint8_t b = std::get(ev).byte; + + switch (state_) { + case State::Idle: + if (b == kENQ) { + out.push_back(ActionTransmit{{kEOT}}); + out.push_back(ActionStartTimer{Timer::T2}); + state_ = State::RecvEotSent; + } + // Stray ACK/NAK/EOT in Idle are dropped (E4 §7.1.2). + return; + + case State::SendEnqSent: + if (b == kEOT) { + out.push_back(ActionCancelTimer{Timer::T2}); + out.push_back(ActionTransmit{send_block_bytes_}); + out.push_back(ActionStartTimer{Timer::T2}); + state_ = State::SendAwaitAck; + } else if (b == kENQ) { + // Contention. Master holds (peer must yield); slave yields. + if (role_ == Role::Slave) { + out.push_back(ActionTransmit{{kEOT}}); + out.push_back(ActionCancelTimer{Timer::T2}); + out.push_back(ActionStartTimer{Timer::T2}); + state_ = State::RecvEotSent; + } + // Master: ignore peer ENQ; our T2 will fire eventually if peer + // doesn't EOT, then we retry. + } + return; + + case State::SendBlock: + // Block transmission is a single transmit action; the FSM doesn't + // sit in this state in the current encoding. Fall through. + return; + + case State::SendAwaitAck: + if (b == kACK) { + complete_send(out); + } else if (b == kNAK) { + retry_send(out); + } + return; + + case State::RecvEotSent: + // First byte is the length byte. + out.push_back(ActionCancelTimer{Timer::T2}); + out.push_back(ActionStartTimer{Timer::T1}); + recv_buf_.clear(); + recv_buf_.push_back(b); + recv_expected_ = 1 + static_cast(b) + 2; // len + payload + cs + state_ = State::RecvBlock; + return; + + case State::RecvBlock: + out.push_back(ActionCancelTimer{Timer::T1}); + recv_buf_.push_back(b); + if (recv_buf_.size() == recv_expected_) { + deliver_recv(out); + } else { + out.push_back(ActionStartTimer{Timer::T1}); + } + return; + + case State::RecvAcking: + // Unreachable in the current encoding (deliver_recv transitions to + // Idle directly). Kept for future use. + return; + } +} + +} // namespace secsgem::secsi diff --git a/tests/test_secsi.cpp b/tests/test_secsi.cpp new file mode 100644 index 0000000..e27e59e --- /dev/null +++ b/tests/test_secsi.cpp @@ -0,0 +1,336 @@ +#include + +#include +#include + +#include "secsgem/secs2/item.hpp" +#include "secsgem/secs2/message.hpp" +#include "secsgem/secsi/block.hpp" +#include "secsgem/secsi/header.hpp" +#include "secsgem/secsi/protocol.hpp" + +using namespace secsgem::secsi; +namespace s2 = secsgem::secs2; + +// ---- Header -------------------------------------------------------------- + +TEST_CASE("SECS-I header round-trip preserves all bit fields") { + Header h; + h.device_id = 0x1234; + h.r_bit = true; + h.stream = 1; + h.function = 3; + h.w_bit = true; + h.block_number = 5; + h.end_block = false; + h.system_bytes = 0xDEADBEEF; + + auto bytes = h.encode(); + Header back = Header::decode(bytes.data()); + CHECK(back == h); +} + +TEST_CASE("SECS-I header packs R/W/E bits into the right slots") { + Header h; + h.device_id = 0x7FFF; // all 15 bits set + h.r_bit = true; + h.stream = 0x7F; + h.w_bit = false; + h.function = 0xAB; + h.block_number = 0x7FFF; + h.end_block = true; + + auto b = h.encode(); + CHECK(b[0] == 0xFF); // R=1 + top 7 bits of device id + CHECK(b[1] == 0xFF); // bottom 8 bits of device id + CHECK(b[2] == 0x7F); // W=0, stream=0x7F + CHECK(b[3] == 0xAB); + CHECK(b[4] == 0xFF); // E=1 + top 7 bits of block number + CHECK(b[5] == 0xFF); +} + +// ---- Block encode / decode ----------------------------------------------- + +TEST_CASE("SECS-I block: encode + decode round-trip with checksum") { + Header h; + h.stream = 1; h.function = 1; h.w_bit = true; + h.block_number = 1; h.end_block = true; + Block in; + in.header = h; + in.body = {0x01, 0x06, 0x00}; // small payload + + auto bytes = in.encode(); + REQUIRE(bytes.size() == 1 + kHeaderSize + in.body.size() + 2); + CHECK(bytes[0] == kHeaderSize + in.body.size()); + + std::size_t consumed = 0; + Block out = Block::decode(bytes.data(), bytes.size(), consumed); + CHECK(consumed == bytes.size()); + CHECK(out.header == in.header); + CHECK(out.body == in.body); +} + +TEST_CASE("SECS-I block: decode rejects checksum mismatch") { + Header h; + Block in{h, {0x11, 0x22}}; + auto bytes = in.encode(); + bytes.back() ^= 0xFF; // corrupt the low byte of the checksum + std::size_t consumed = 0; + CHECK_THROWS_AS(Block::decode(bytes.data(), bytes.size(), consumed), + BlockError); +} + +TEST_CASE("SECS-I block: decode rejects short input") { + Header h; + Block in{h, {0x55}}; + auto bytes = in.encode(); + std::size_t consumed = 0; + CHECK_THROWS_AS(Block::decode(bytes.data(), bytes.size() - 1, consumed), + BlockError); +} + +TEST_CASE("SECS-I block: encode rejects oversize body") { + Header h; + Block in; + in.header = h; + in.body.assign(kMaxBlockBody + 1, 0xAA); + CHECK_THROWS_AS(in.encode(), BlockError); +} + +// ---- Multi-block split + assemble --------------------------------------- + +TEST_CASE("SECS-I split_message: short message fits in one block") { + auto msg = s2::Message(1, 3, true, s2::Item::ascii("ok")); + Header tmpl; + tmpl.device_id = 7; + auto blocks = split_message(msg, tmpl); + REQUIRE(blocks.size() == 1); + CHECK(blocks[0].header.block_number == 1); + CHECK(blocks[0].header.end_block); + CHECK(blocks[0].header.stream == 1); + CHECK(blocks[0].header.function == 3); + CHECK(blocks[0].header.w_bit); +} + +TEST_CASE("SECS-I split_message: long body produces sequential blocks") { + // Make a body that exceeds kMaxBlockBody. ASCII payload is ~equal to + // body bytes after the 2-byte item header. + std::string big(kMaxBlockBody * 3 - 5, 'X'); + auto msg = s2::Message(7, 3, true, s2::Item::ascii(big)); + Header tmpl; + auto blocks = split_message(msg, tmpl); + REQUIRE(blocks.size() >= 3); + for (std::size_t i = 0; i < blocks.size(); ++i) { + CHECK(blocks[i].header.block_number == i + 1); + const bool last = (i + 1 == blocks.size()); + CHECK(blocks[i].header.end_block == last); + } + // Reassembly returns the same body. + auto reassembled = assemble_message(blocks); + REQUIRE(reassembled.has_value()); + CHECK(reassembled->stream == 7); + CHECK(reassembled->function == 3); + REQUIRE(reassembled->body.has_value()); +} + +TEST_CASE("SECS-I assemble_message: rejects gaps and mid-message E-bit") { + auto msg = s2::Message(1, 1, true, s2::Item::ascii("hello")); + Header tmpl; + auto blocks = split_message(msg, tmpl); + + // Gap in block numbering. + std::vector gapped = blocks; + if (!gapped.empty()) gapped[0].header.block_number = 99; + CHECK_FALSE(assemble_message(gapped).has_value()); + + // E-bit mid-stream. + if (blocks.size() >= 2) { + std::vector mid_e = blocks; + mid_e[0].header.end_block = true; + CHECK_FALSE(assemble_message(mid_e).has_value()); + } +} + +// ---- Protocol FSM -------------------------------------------------------- + +namespace { + +// Test harness: pipe two Protocol instances back-to-back. Each "tick" +// flushes one peer's outbox into the other's inbox until quiescence. +struct Pair { + Protocol a{Role::Master}; + Protocol b{Role::Slave}; + std::vector a_out, b_out; + std::vector a_inbox, b_inbox; + std::vector a_delivered, b_delivered; + std::vector errors; + + void feed_a(const Event& ev) { a.on_event(ev, a_out); } + void feed_b(const Event& ev) { b.on_event(ev, b_out); } + + // Drain `out` into the peer's inbox (transmits) and capture delivered + // blocks / errors. Timer actions are ignored — tests fire timers + // explicitly when they want to exercise timeouts. + void drain(std::vector& out, std::vector& peer_inbox, + std::vector& delivered) { + for (auto& act : out) { + if (auto* t = std::get_if(&act)) { + peer_inbox.insert(peer_inbox.end(), t->bytes.begin(), t->bytes.end()); + } else if (auto* d = std::get_if(&act)) { + delivered.push_back(std::move(d->block)); + } else if (auto* e = std::get_if(&act)) { + errors.push_back(e->reason); + } + } + out.clear(); + } + + // Step the simulation until both inboxes are empty and no peer has + // pending events. Bytes flow A -> b_inbox -> B, B -> a_inbox -> A. + void tick() { + bool progress = true; + int safety = 1000; + while (progress && safety-- > 0) { + progress = false; + drain(a_out, b_inbox, a_delivered); + drain(b_out, a_inbox, b_delivered); + while (!a_inbox.empty()) { + uint8_t byte = a_inbox.front(); + a_inbox.erase(a_inbox.begin()); + a.on_event(EventByte{byte}, a_out); + progress = true; + } + while (!b_inbox.empty()) { + uint8_t byte = b_inbox.front(); + b_inbox.erase(b_inbox.begin()); + b.on_event(EventByte{byte}, b_out); + progress = true; + } + if (!a_out.empty() || !b_out.empty()) progress = true; + drain(a_out, b_inbox, a_delivered); + drain(b_out, a_inbox, b_delivered); + } + } +}; + +Block make_block(uint8_t stream, uint8_t function, + const std::vector& body = {}) { + Block b; + b.header.stream = stream; + b.header.function = function; + b.header.block_number = 1; + b.header.end_block = true; + b.header.w_bit = true; + b.body = body; + return b; +} + +} // namespace + +TEST_CASE("SECS-I protocol: idle starts in Idle, ENQ from peer triggers EOT") { + Protocol p(Role::Master); + std::vector out; + CHECK(p.state() == Protocol::State::Idle); + p.on_event(EventByte{kENQ}, out); + CHECK(p.state() == Protocol::State::RecvEotSent); + // First action must be a transmit containing EOT. + REQUIRE_FALSE(out.empty()); + auto* t = std::get_if(&out[0]); + REQUIRE(t); + REQUIRE(t->bytes.size() == 1); + CHECK(t->bytes[0] == kEOT); +} + +TEST_CASE("SECS-I protocol: back-to-back send delivers block to peer") { + Pair pp; + pp.feed_a(EventSend{make_block(1, 1, {0xAA, 0xBB})}); + pp.tick(); + + REQUIRE(pp.b_delivered.size() == 1); + CHECK(pp.b_delivered[0].header.stream == 1); + CHECK(pp.b_delivered[0].header.function == 1); + CHECK(pp.b_delivered[0].body == std::vector{0xAA, 0xBB}); + CHECK(pp.errors.empty()); + CHECK(pp.a.state() == Protocol::State::Idle); + CHECK(pp.b.state() == Protocol::State::Idle); +} + +TEST_CASE("SECS-I protocol: bidirectional exchange") { + Pair pp; + pp.feed_a(EventSend{make_block(1, 13, {0x01})}); + pp.tick(); + REQUIRE(pp.b_delivered.size() == 1); + + pp.feed_b(EventSend{make_block(1, 14, {0x02})}); + pp.tick(); + REQUIRE(pp.a_delivered.size() == 1); + CHECK(pp.a_delivered[0].header.function == 14); +} + +TEST_CASE("SECS-I protocol: NAK triggers retry; RTY exhaustion aborts") { + Protocol p(Role::Master, Timers{.rty = 2}); + std::vector out; + + // Begin send. + p.on_event(EventSend{make_block(1, 1, {0x00})}, out); + CHECK(p.state() == Protocol::State::SendEnqSent); + // Peer clears us. + out.clear(); + p.on_event(EventByte{kEOT}, out); + CHECK(p.state() == Protocol::State::SendAwaitAck); + // Peer NAKs — first retry consumes one RTY (rty was 2, retry decrements + // first then resends, so after this we have 1 retry left). + out.clear(); + p.on_event(EventByte{kNAK}, out); + CHECK(p.state() == Protocol::State::SendEnqSent); + CHECK(p.rty_remaining() == 1); + // Walk through another NAK. + p.on_event(EventByte{kEOT}, out); + out.clear(); + p.on_event(EventByte{kNAK}, out); + CHECK(p.state() == Protocol::State::SendEnqSent); + CHECK(p.rty_remaining() == 0); + // One more NAK -> exhaustion -> abort. + p.on_event(EventByte{kEOT}, out); + out.clear(); + p.on_event(EventByte{kNAK}, out); + // After abort: queue cleared, state Idle, error raised. + CHECK(p.state() == Protocol::State::Idle); + bool saw_err = std::any_of(out.begin(), out.end(), [](const Action& a) { + return std::holds_alternative(a); + }); + CHECK(saw_err); +} + +TEST_CASE("SECS-I protocol: contention — slave yields when peer ENQs") { + Protocol slave(Role::Slave); + std::vector out; + + // Slave wants to send. + slave.on_event(EventSend{make_block(1, 1)}, out); + CHECK(slave.state() == Protocol::State::SendEnqSent); + + // Peer ENQs at the same time. + out.clear(); + slave.on_event(EventByte{kENQ}, out); + // Slave yields: now in RecvEotSent, has emitted EOT. + CHECK(slave.state() == Protocol::State::RecvEotSent); + bool saw_eot = std::any_of(out.begin(), out.end(), [](const Action& a) { + if (auto* t = std::get_if(&a)) + return !t->bytes.empty() && t->bytes[0] == kEOT; + return false; + }); + CHECK(saw_eot); +} + +TEST_CASE("SECS-I protocol: T2 timeout during send triggers retry") { + Protocol p(Role::Master); + std::vector out; + p.on_event(EventSend{make_block(1, 1)}, out); + REQUIRE(p.state() == Protocol::State::SendEnqSent); + const auto rty_before = p.rty_remaining(); + out.clear(); + p.on_event(EventTimeout{Timer::T2}, out); + CHECK(p.state() == Protocol::State::SendEnqSent); + CHECK(p.rty_remaining() == rty_before - 1); +}