a400ef3160
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>
337 lines
11 KiB
C++
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);
|
|
}
|