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:
@@ -45,6 +45,9 @@ add_library(secsgem
|
|||||||
src/secs2/codec.cpp
|
src/secs2/codec.cpp
|
||||||
src/hsms/header.cpp
|
src/hsms/header.cpp
|
||||||
src/hsms/connection.cpp
|
src/hsms/connection.cpp
|
||||||
|
src/secsi/header.cpp
|
||||||
|
src/secsi/block.cpp
|
||||||
|
src/secsi/protocol.cpp
|
||||||
src/gem/control_state.cpp
|
src/gem/control_state.cpp
|
||||||
src/gem/communication_state.cpp
|
src/gem/communication_state.cpp
|
||||||
src/gem/process_job_state.cpp
|
src/gem/process_job_state.cpp
|
||||||
@@ -81,6 +84,7 @@ add_executable(secsgem_tests
|
|||||||
tests/test_secs2.cpp
|
tests/test_secs2.cpp
|
||||||
tests/test_hsms.cpp
|
tests/test_hsms.cpp
|
||||||
tests/test_hsms_connection.cpp
|
tests/test_hsms_connection.cpp
|
||||||
|
tests/test_secsi.cpp
|
||||||
tests/test_control_state.cpp
|
tests/test_control_state.cpp
|
||||||
tests/test_communication_state.cpp
|
tests/test_communication_state.cpp
|
||||||
tests/test_data_model.cpp
|
tests/test_data_model.cpp
|
||||||
|
|||||||
@@ -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
|
## 2. E5 — SECS-II encoding
|
||||||
|
|
||||||
| Item | Status | Spec ref | Notes |
|
| Item | Status | Spec ref | Notes |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user