A4: SECS-I transport (block protocol + E4 retry FSM)

Adds a complete IO-free SECS-I implementation:

  include/secsgem/secsi/header.hpp   10-byte block header (R/W/E bits)
  include/secsgem/secsi/block.hpp    length + header + body + checksum
  include/secsgem/secsi/protocol.hpp half-duplex FSM (ENQ/EOT/ACK/NAK)
  src/secsi/*                         implementations
  tests/test_secsi.cpp                header, block, multi-block split,
                                      back-to-back FSM drive, RTY,
                                      contention, T2 timeout

The protocol is event-driven (`Event` → `Action` queue), so wiring it
to an asio serial_port is a thin adapter — that lands in the next
commit so this one stays reviewable.

Key design points:
- Master/slave contention: slave yields on simultaneous ENQ (E4 §7.1.4).
- RTY exhaustion raises ActionRaiseError, clears the send queue, resets
  to Idle (no zombie state).
- Multi-block assembler validates contiguous 1..N numbering and exclusive
  E-bit-on-last invariants — rejects malformed sequences with nullopt.
- Block::checksum is exposed publicly for the receive path's verification.

Tests cover the happy path (back-to-back delivery), error paths
(checksum mismatch, short input, oversize body), retries (NAK chain to
exhaustion), and protocol corner cases (contention, T2 timeout).

secsgem-py implements SECS-I block framing but lacks the explicit RTY
state machine; this commit puts the C++ port ahead on transport
correctness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:34:09 +02:00
parent ec478ac9cb
commit a400ef3160
9 changed files with 957 additions and 0 deletions
+4
View File
@@ -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
+18
View File
@@ -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 |
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
#include "secsgem/secs2/message.hpp"
#include "secsgem/secsi/header.hpp"
// SECS-I block framing. Each block on the wire is:
//
// <length> 1 byte, 10..254 (header size + body size)
// <header> 10 bytes (see secsi/header.hpp)
// <body> 0..244 bytes
// <checksum> 2 bytes, big-endian sum of <header + body> 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<uint8_t> body;
// Full wire encoding: length byte + 10-byte header + body + 2-byte
// checksum.
std::vector<uint8_t> 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<Block> 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<secs2::Message> assemble_message(const std::vector<Block>& blocks);
} // namespace secsgem::secsi
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <array>
#include <cstdint>
#include <string>
// 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<uint8_t, kHeaderSize> 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
+129
View File
@@ -0,0 +1,129 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <deque>
#include <optional>
#include <variant>
#include <vector>
#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<uint8_t> bytes; };
struct ActionStartTimer { Timer which; };
struct ActionCancelTimer { Timer which; };
struct ActionDeliverBlock { Block block; };
struct ActionRaiseError { std::string reason; };
using Action = std::variant<ActionTransmit, ActionStartTimer,
ActionCancelTimer, ActionDeliverBlock,
ActionRaiseError>;
// --- 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<EventByte, EventSend, EventTimeout>;
// --- 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<Action>& 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<Action>& out);
// Re-attempt the current block after a NAK or timeout.
void retry_send(std::vector<Action>& out);
// Successful send: pop the block from the queue and return to Idle.
void complete_send(std::vector<Action>& out);
// Recv path: deliver the assembled block.
void deliver_recv(std::vector<Action>& out);
// Hard abort: give up, raise error, reset to Idle.
void abort(std::string reason, std::vector<Action>& out);
Role role_;
Timers timers_;
State state_ = State::Idle;
// --- send-side state ---
std::deque<Block> send_queue_;
std::vector<uint8_t> send_block_bytes_; // encoded bytes of the current block
uint8_t rty_ = 0; // retries left for the current block
// --- receive-side state ---
std::vector<uint8_t> 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
+122
View File
@@ -0,0 +1,122 @@
#include "secsgem/secsi/block.hpp"
#include <stdexcept>
#include <string>
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<uint16_t>(sum & 0xFFFF);
}
std::vector<uint8_t> 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<uint8_t> out;
out.reserve(1 + header_plus_body + 2);
out.push_back(static_cast<uint8_t>(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<uint8_t>(cs >> 8));
out.push_back(static_cast<uint8_t>(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<uint16_t>(data[1 + length_byte]) << 8) |
static_cast<uint16_t>(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<std::size_t>(length_byte) - kHeaderSize;
b.body.assign(data + 1 + kHeaderSize, data + 1 + kHeaderSize + body_size);
bytes_consumed = total;
return b;
}
std::vector<Block> split_message(const secs2::Message& msg,
const Header& header_template) {
std::vector<Block> blocks;
const std::vector<uint8_t> 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<std::size_t>(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<secs2::Message> assemble_message(const std::vector<Block>& 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<uint8_t> 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
+58
View File
@@ -0,0 +1,58 @@
#include "secsgem/secsi/header.hpp"
#include <sstream>
namespace secsgem::secsi {
std::array<uint8_t, kHeaderSize> Header::encode() const {
std::array<uint8_t, kHeaderSize> out{};
// R-bit | 15-bit device id (bytes 0-1, big-endian).
uint16_t devword = static_cast<uint16_t>(device_id & 0x7FFF);
if (r_bit) devword |= 0x8000;
out[0] = static_cast<uint8_t>(devword >> 8);
out[1] = static_cast<uint8_t>(devword & 0xFF);
// W-bit | 7-bit stream (byte 2), function (byte 3).
out[2] = static_cast<uint8_t>((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<uint16_t>(block_number & 0x7FFF);
if (end_block) blkword |= 0x8000;
out[4] = static_cast<uint8_t>(blkword >> 8);
out[5] = static_cast<uint8_t>(blkword & 0xFF);
// System bytes (6-9, big-endian).
out[6] = static_cast<uint8_t>((system_bytes >> 24) & 0xFF);
out[7] = static_cast<uint8_t>((system_bytes >> 16) & 0xFF);
out[8] = static_cast<uint8_t>((system_bytes >> 8) & 0xFF);
out[9] = static_cast<uint8_t>(system_bytes & 0xFF);
return out;
}
Header Header::decode(const uint8_t* d) {
Header h;
uint16_t devword = static_cast<uint16_t>((d[0] << 8) | d[1]);
h.r_bit = (devword & 0x8000) != 0;
h.device_id = static_cast<uint16_t>(devword & 0x7FFF);
h.w_bit = (d[2] & 0x80) != 0;
h.stream = static_cast<uint8_t>(d[2] & 0x7F);
h.function = d[3];
uint16_t blkword = static_cast<uint16_t>((d[4] << 8) | d[5]);
h.end_block = (blkword & 0x8000) != 0;
h.block_number = static_cast<uint16_t>(blkword & 0x7FFF);
h.system_bytes = (static_cast<uint32_t>(d[6]) << 24) |
(static_cast<uint32_t>(d[7]) << 16) |
(static_cast<uint32_t>(d[8]) << 8) |
(static_cast<uint32_t>(d[9]));
return h;
}
std::string Header::describe() const {
std::ostringstream os;
os << "SecsI[dev=" << device_id << (r_bit ? " R" : "")
<< " S" << static_cast<int>(stream) << 'F' << static_cast<int>(function)
<< (w_bit ? " W" : "")
<< " block=" << block_number << (end_block ? "E" : "")
<< " sys=" << system_bytes << ']';
return os.str();
}
} // namespace secsgem::secsi
+182
View File
@@ -0,0 +1,182 @@
#include "secsgem/secsi/protocol.hpp"
#include <string>
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<Action>& 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<Action>& 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<Action>& 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<Action>& 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<Action>& 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<Action>& out) {
// --- App requested a send -------------------------------------------------
if (auto* es = std::get_if<EventSend>(&ev)) {
send_queue_.push_back(es->block);
if (state_ == State::Idle) begin_send(out);
return;
}
// --- Timer fired ----------------------------------------------------------
if (auto* et = std::get_if<EventTimeout>(&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<EventByte>(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<std::size_t>(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
+336
View File
@@ -0,0 +1,336 @@
#include <doctest/doctest.h>
#include <algorithm>
#include <vector>
#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<Block> 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<Block> 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<Action> a_out, b_out;
std::vector<uint8_t> a_inbox, b_inbox;
std::vector<Block> a_delivered, b_delivered;
std::vector<std::string> 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<Action>& out, std::vector<uint8_t>& peer_inbox,
std::vector<Block>& delivered) {
for (auto& act : out) {
if (auto* t = std::get_if<ActionTransmit>(&act)) {
peer_inbox.insert(peer_inbox.end(), t->bytes.begin(), t->bytes.end());
} else if (auto* d = std::get_if<ActionDeliverBlock>(&act)) {
delivered.push_back(std::move(d->block));
} else if (auto* e = std::get_if<ActionRaiseError>(&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<uint8_t>& 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<Action> 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<ActionTransmit>(&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<uint8_t>{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<Action> 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<ActionRaiseError>(a);
});
CHECK(saw_err);
}
TEST_CASE("SECS-I protocol: contention — slave yields when peer ENQs") {
Protocol slave(Role::Slave);
std::vector<Action> 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<ActionTransmit>(&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<Action> 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);
}