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
+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