Files
raphael a400ef3160 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>
2026-06-07 21:34:09 +02:00

337 lines
11 KiB
C++

#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);
}