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,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/secs2/message.hpp"
|
||||
#include "secsgem/secsi/header.hpp"
|
||||
|
||||
// SECS-I block framing. Each block on the wire is:
|
||||
//
|
||||
// <length> 1 byte, 10..254 (header size + body size)
|
||||
// <header> 10 bytes (see secsi/header.hpp)
|
||||
// <body> 0..244 bytes
|
||||
// <checksum> 2 bytes, big-endian sum of <header + body> mod 65536
|
||||
//
|
||||
// Long SECS-II messages are split into multiple blocks; only the final
|
||||
// block has the E-bit set in its header (E4 §7.2.3). The block number
|
||||
// is 1-based and increments monotonically across the message.
|
||||
namespace secsgem::secsi {
|
||||
|
||||
class BlockError : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
struct Block {
|
||||
Header header;
|
||||
std::vector<uint8_t> body;
|
||||
|
||||
// Full wire encoding: length byte + 10-byte header + body + 2-byte
|
||||
// checksum.
|
||||
std::vector<uint8_t> encode() const;
|
||||
|
||||
// Parse a block whose first byte is the length. Returns the consumed
|
||||
// byte count via `bytes_consumed`; throws BlockError on a short read,
|
||||
// a bad length, or a checksum mismatch.
|
||||
static Block decode(const uint8_t* data, std::size_t len,
|
||||
std::size_t& bytes_consumed);
|
||||
|
||||
// Compute the 16-bit checksum of the header + body bytes (everything
|
||||
// between the length byte and the checksum on the wire).
|
||||
static uint16_t checksum(const uint8_t* data, std::size_t len);
|
||||
};
|
||||
|
||||
// Split a SECS-II message into one or more SECS-I blocks. Each block
|
||||
// carries up to kMaxBlockBody payload bytes; only the final block has
|
||||
// the E-bit set. The header template is duplicated across all blocks
|
||||
// (block_number / end_block are filled in by the splitter).
|
||||
std::vector<Block> split_message(const secs2::Message& msg,
|
||||
const Header& header_template);
|
||||
|
||||
// Reassemble a SECS-II message from a sequence of blocks. Blocks must
|
||||
// be contiguous (block_number 1..N) and the last must have end_block=true;
|
||||
// the function returns nullopt if either invariant is violated.
|
||||
std::optional<secs2::Message> assemble_message(const std::vector<Block>& blocks);
|
||||
|
||||
} // namespace secsgem::secsi
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// SECS-I block-header primitives (SEMI E4). Each block carries a 10-byte
|
||||
// header with the same general shape as HSMS but laid out differently:
|
||||
// the R-bit lives in the upper bit of byte 0, the W-bit in the upper bit
|
||||
// of byte 2 (stream), and the E-bit + block number live in bytes 4-5
|
||||
// (E-bit = MSB of byte 4). Bytes 6-9 are the 32-bit system bytes.
|
||||
namespace secsgem::secsi {
|
||||
|
||||
// One SECS-I block carries up to 244 bytes of payload (length byte 10..254
|
||||
// covers 10-byte header + up to 244 bytes; 255 is reserved).
|
||||
inline constexpr std::size_t kHeaderSize = 10;
|
||||
inline constexpr std::size_t kMaxBlockBody = 244;
|
||||
inline constexpr uint8_t kMaxLengthByte = 254;
|
||||
|
||||
struct Header {
|
||||
// R-bit + 15-bit device ID packed into bytes 0-1 (big-endian).
|
||||
// R=1 means "host -> equipment", R=0 means "equipment -> host".
|
||||
uint16_t device_id = 0;
|
||||
bool r_bit = false;
|
||||
|
||||
// W-bit + 7-bit stream packed into byte 2. W=1 marks a primary message
|
||||
// that expects a reply (same semantics as HSMS).
|
||||
uint8_t stream = 0;
|
||||
bool w_bit = false;
|
||||
uint8_t function = 0; // byte 3
|
||||
|
||||
// E-bit + 15-bit block number packed into bytes 4-5 (big-endian).
|
||||
// E=1 marks the last block of a multi-block message; numbering is
|
||||
// 1-based per E4 §7.2.3.
|
||||
uint16_t block_number = 1;
|
||||
bool end_block = true;
|
||||
|
||||
uint32_t system_bytes = 0; // bytes 6-9 (big-endian)
|
||||
|
||||
std::array<uint8_t, kHeaderSize> encode() const;
|
||||
static Header decode(const uint8_t* data); // reads exactly 10 bytes
|
||||
|
||||
std::string describe() const;
|
||||
|
||||
bool operator==(const Header&) const = default;
|
||||
};
|
||||
|
||||
} // namespace secsgem::secsi
|
||||
@@ -0,0 +1,129 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <optional>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/secsi/block.hpp"
|
||||
|
||||
// SECS-I half-duplex line-turnaround state machine (E4 §7).
|
||||
//
|
||||
// The protocol is event-driven and IO-free: it takes inputs (byte
|
||||
// received, "I want to send this block", timer fired, peer offline) and
|
||||
// produces actions (transmit these bytes, deliver this block, raise an
|
||||
// error). Wrapping it in a serial-port driver is straightforward — see
|
||||
// the tests for an in-memory back-to-back example.
|
||||
//
|
||||
// SECS-I control bytes (E4 §6.1):
|
||||
// ENQ 0x05 "I want to send"
|
||||
// EOT 0x04 "go ahead, send"
|
||||
// ACK 0x06 "block received OK"
|
||||
// NAK 0x15 "block bad / resend"
|
||||
//
|
||||
// Master/slave contention: per E4 §7.1.4, when both peers ENQ
|
||||
// simultaneously the master (typically the host) holds, the slave
|
||||
// (typically the equipment) yields. Our Role enum captures that.
|
||||
namespace secsgem::secsi {
|
||||
|
||||
inline constexpr uint8_t kENQ = 0x05;
|
||||
inline constexpr uint8_t kEOT = 0x04;
|
||||
inline constexpr uint8_t kACK = 0x06;
|
||||
inline constexpr uint8_t kNAK = 0x15;
|
||||
|
||||
enum class Role { Master, Slave };
|
||||
|
||||
enum class Timer : uint8_t {
|
||||
T1, // inter-character (default 0.5s, E4 §10.1)
|
||||
T2, // protocol (default 10s)
|
||||
T3, // reply (default 45s) — driven at a higher layer
|
||||
T4, // inter-block (default 45s)
|
||||
};
|
||||
|
||||
struct Timers {
|
||||
std::chrono::milliseconds t1{500};
|
||||
std::chrono::milliseconds t2{10000};
|
||||
std::chrono::milliseconds t3{45000};
|
||||
std::chrono::milliseconds t4{45000};
|
||||
uint8_t rty = 3; // retries before giving up on a block
|
||||
};
|
||||
|
||||
// --- Actions the FSM asks its host to perform ----------------------------
|
||||
|
||||
struct ActionTransmit { std::vector<uint8_t> bytes; };
|
||||
struct ActionStartTimer { Timer which; };
|
||||
struct ActionCancelTimer { Timer which; };
|
||||
struct ActionDeliverBlock { Block block; };
|
||||
struct ActionRaiseError { std::string reason; };
|
||||
|
||||
using Action = std::variant<ActionTransmit, ActionStartTimer,
|
||||
ActionCancelTimer, ActionDeliverBlock,
|
||||
ActionRaiseError>;
|
||||
|
||||
// --- Inputs the host feeds in --------------------------------------------
|
||||
|
||||
struct EventByte { uint8_t byte; }; // one byte received on the line
|
||||
struct EventSend { Block block; }; // application asks to send a block
|
||||
struct EventTimeout { Timer which; }; // a previously-armed timer fired
|
||||
|
||||
using Event = std::variant<EventByte, EventSend, EventTimeout>;
|
||||
|
||||
// --- State machine -------------------------------------------------------
|
||||
|
||||
class Protocol {
|
||||
public:
|
||||
enum class State {
|
||||
Idle, // line free
|
||||
SendEnqSent, // we sent ENQ, waiting for peer EOT
|
||||
SendBlock, // peer cleared us; we're transmitting the block
|
||||
SendAwaitAck, // block sent, waiting for ACK/NAK
|
||||
RecvEnqSeen, // peer sent ENQ; we owe them an EOT
|
||||
RecvEotSent, // we sent EOT; expecting block bytes
|
||||
RecvBlock, // block bytes arriving
|
||||
RecvAcking, // block fully received; we'll send ACK/NAK
|
||||
};
|
||||
|
||||
explicit Protocol(Role role, Timers timers = {})
|
||||
: role_(role), timers_(timers) {}
|
||||
|
||||
Role role() const { return role_; }
|
||||
State state() const { return state_; }
|
||||
uint8_t rty_remaining() const { return rty_; }
|
||||
|
||||
// Feed an event in; the FSM appends its desired actions to `out`.
|
||||
void on_event(const Event& ev, std::vector<Action>& out);
|
||||
|
||||
// Test-only: peek the queue of blocks waiting to send.
|
||||
std::size_t pending_send_size() const { return send_queue_.size(); }
|
||||
|
||||
private:
|
||||
// Start of one send transaction (when send_queue_ non-empty and we're Idle).
|
||||
void begin_send(std::vector<Action>& out);
|
||||
// Re-attempt the current block after a NAK or timeout.
|
||||
void retry_send(std::vector<Action>& out);
|
||||
// Successful send: pop the block from the queue and return to Idle.
|
||||
void complete_send(std::vector<Action>& out);
|
||||
// Recv path: deliver the assembled block.
|
||||
void deliver_recv(std::vector<Action>& out);
|
||||
// Hard abort: give up, raise error, reset to Idle.
|
||||
void abort(std::string reason, std::vector<Action>& out);
|
||||
|
||||
Role role_;
|
||||
Timers timers_;
|
||||
State state_ = State::Idle;
|
||||
|
||||
// --- send-side state ---
|
||||
std::deque<Block> send_queue_;
|
||||
std::vector<uint8_t> send_block_bytes_; // encoded bytes of the current block
|
||||
uint8_t rty_ = 0; // retries left for the current block
|
||||
|
||||
// --- receive-side state ---
|
||||
std::vector<uint8_t> recv_buf_; // bytes collected since EOT
|
||||
std::size_t recv_expected_ = 0; // length byte + payload + checksum
|
||||
};
|
||||
|
||||
const char* state_name(Protocol::State s);
|
||||
|
||||
} // namespace secsgem::secsi
|
||||
Reference in New Issue
Block a user