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,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
|
||||
Reference in New Issue
Block a user