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
+48
View File
@@ -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