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:
@@ -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