Files
secs-gem/include/secsgem/secsi/protocol.hpp
T
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

130 lines
4.6 KiB
C++

#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