A5: SECS-I-over-TCP convenience layer

Wires the SECS-I Protocol FSM behind an asio TCP socket so the block
protocol can run over loopback without serial hardware.  Mirrors
secsgem-py's `secsitcp/` adapter — useful for back-to-back simulators
and CI without a serial device.

Adds:
  include/secsgem/secsi/tcp_transport.hpp
  src/secsi/tcp_transport.cpp
  tests/test_secsi_tcp.cpp

The transport:
- Splits outgoing SECS-II messages into blocks (transparent multi-block).
- Accumulates incoming blocks until end_block=true, then assembles and
  delivers as a single SECS-II message — same surface as the HSMS
  Connection's MessageHandler.
- Drives T1 / T2 timers from asio steady_timer; T3/T4 stay upper-layer
  per the FSM contract.
- Auto-allocates monotonic system bytes per send.

Tests cover single-block delivery, multi-block reassembly (700-byte
ASCII body spanning multiple SECS-I blocks), and bidirectional exchange.

This closes Tranche A (catch-up to secsgem-py wire/transport surface).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:36:17 +02:00
parent a400ef3160
commit 72fa73fee0
5 changed files with 363 additions and 1 deletions
+2
View File
@@ -48,6 +48,7 @@ add_library(secsgem
src/secsi/header.cpp src/secsi/header.cpp
src/secsi/block.cpp src/secsi/block.cpp
src/secsi/protocol.cpp src/secsi/protocol.cpp
src/secsi/tcp_transport.cpp
src/gem/control_state.cpp src/gem/control_state.cpp
src/gem/communication_state.cpp src/gem/communication_state.cpp
src/gem/process_job_state.cpp src/gem/process_job_state.cpp
@@ -85,6 +86,7 @@ add_executable(secsgem_tests
tests/test_hsms.cpp tests/test_hsms.cpp
tests/test_hsms_connection.cpp tests/test_hsms_connection.cpp
tests/test_secsi.cpp tests/test_secsi.cpp
tests/test_secsi_tcp.cpp
tests/test_control_state.cpp tests/test_control_state.cpp
tests/test_communication_state.cpp tests/test_communication_state.cpp
tests/test_data_model.cpp tests/test_data_model.cpp
+2 -1
View File
@@ -54,7 +54,8 @@ Legend:
| T3 reply timer | ⬜ | E4 §10.1 | Driven by the upper layer (same as HSMS). | | T3 reply timer | ⬜ | E4 §10.1 | Driven by the upper layer (same as HSMS). |
| T4 inter-block timer | ⬜ | E4 §10.1 | Multi-block message-gap; FSM emits hook events. | | T4 inter-block timer | ⬜ | E4 §10.1 | Multi-block message-gap; FSM emits hook events. |
| Master/slave contention resolution | ✅ | E4 §7.1.4 | Slave yields on simultaneous ENQ; master holds. | | Master/slave contention resolution | ✅ | E4 §7.1.4 | Slave yields on simultaneous ENQ; master holds. |
| Serial port wiring (asio) | ⬜ | — | FSM is IO-free; a follow-up commit will land the asio integration. | | Serial port wiring (asio) | ⬜ | — | FSM is IO-free; serial integration is a wiring follow-up. |
| TCP tunnel for testing | ✅ | — | `secsi::TcpTransport` wraps the FSM behind an asio TCP socket; mirrors secsgem-py's `secsitcp/`. |
--- ---
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include <asio.hpp>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "secsgem/secs2/message.hpp"
#include "secsgem/secsi/block.hpp"
#include "secsgem/secsi/header.hpp"
#include "secsgem/secsi/protocol.hpp"
// SECS-I block framing tunneled over a TCP socket. Mirrors the
// secsgem-py `secsitcp/` convenience layer — useful for back-to-back
// testing and simulators where you want SECS-I semantics (ENQ/EOT/ACK,
// retries) but no actual serial hardware. The on-wire bytes are
// identical to what would travel over RS-232; only the carrier differs.
namespace secsgem::secsi {
class TcpTransport : public std::enable_shared_from_this<TcpTransport> {
public:
using MessageHandler = std::function<void(secs2::Message)>;
using ErrorHandler = std::function<void(const std::string&)>;
using LogHandler = std::function<void(const std::string&)>;
TcpTransport(asio::ip::tcp::socket socket, Role role, uint16_t device_id,
Timers timers = {});
void set_message_handler(MessageHandler h) { on_message_ = std::move(h); }
void set_error_handler(ErrorHandler h) { on_error_ = std::move(h); }
void set_log_handler(LogHandler h) { on_log_ = std::move(h); }
// Begin the read loop. Idempotent.
void start();
// Queue a SECS-II message for transmission. Internally split into
// SECS-I blocks if larger than kMaxBlockBody; system bytes are
// auto-assigned monotonically.
void send(secs2::Message msg);
// Hard-close the underlying socket. No graceful teardown — SECS-I has
// no equivalent of HSMS Separate.req at the protocol level.
void close();
Protocol::State state() const { return fsm_.state(); }
private:
void read_loop();
void run_actions(std::vector<Action>& actions);
void deliver_block(Block block);
void start_timer(Timer which);
void cancel_timer(Timer which);
void log(const std::string& msg);
asio::ip::tcp::socket socket_;
Protocol fsm_;
Timers timers_;
uint16_t device_id_;
bool started_ = false;
bool closed_ = false;
std::array<uint8_t, 256> read_buf_{};
std::vector<Block> assembler_; // partial multi-block message
uint32_t next_system_bytes_ = 1;
asio::steady_timer t1_;
asio::steady_timer t2_;
MessageHandler on_message_;
ErrorHandler on_error_;
LogHandler on_log_;
};
} // namespace secsgem::secsi
+132
View File
@@ -0,0 +1,132 @@
#include "secsgem/secsi/tcp_transport.hpp"
#include <variant>
namespace secsgem::secsi {
TcpTransport::TcpTransport(asio::ip::tcp::socket socket, Role role,
uint16_t device_id, Timers timers)
: socket_(std::move(socket)),
fsm_(role, timers),
timers_(timers),
device_id_(device_id),
t1_(socket_.get_executor()),
t2_(socket_.get_executor()) {}
void TcpTransport::start() {
if (started_) return;
started_ = true;
read_loop();
}
void TcpTransport::send(secs2::Message msg) {
Header tmpl;
tmpl.device_id = device_id_;
tmpl.system_bytes = next_system_bytes_++;
auto blocks = split_message(msg, tmpl);
std::vector<Action> actions;
for (auto& b : blocks) {
fsm_.on_event(EventSend{std::move(b)}, actions);
}
run_actions(actions);
}
void TcpTransport::close() {
if (closed_) return;
closed_ = true;
asio::error_code ec;
socket_.shutdown(asio::socket_base::shutdown_both, ec);
socket_.close(ec);
t1_.cancel();
t2_.cancel();
}
void TcpTransport::read_loop() {
if (closed_) return;
auto self = shared_from_this();
socket_.async_read_some(
asio::buffer(read_buf_),
[self](std::error_code ec, std::size_t n) {
if (ec) {
if (self->on_error_ && ec != asio::error::operation_aborted)
self->on_error_(std::string("read: ") + ec.message());
self->close();
return;
}
std::vector<Action> actions;
for (std::size_t i = 0; i < n; ++i) {
self->fsm_.on_event(EventByte{self->read_buf_[i]}, actions);
}
self->run_actions(actions);
self->read_loop();
});
}
void TcpTransport::run_actions(std::vector<Action>& actions) {
for (auto& act : actions) {
if (auto* t = std::get_if<ActionTransmit>(&act)) {
auto buf = std::make_shared<std::vector<uint8_t>>(std::move(t->bytes));
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(*buf),
[self, buf](std::error_code ec, std::size_t) {
if (ec && self->on_error_ &&
ec != asio::error::operation_aborted) {
self->on_error_(std::string("write: ") + ec.message());
self->close();
}
});
} else if (auto* d = std::get_if<ActionDeliverBlock>(&act)) {
deliver_block(std::move(d->block));
} else if (auto* st = std::get_if<ActionStartTimer>(&act)) {
start_timer(st->which);
} else if (auto* ct = std::get_if<ActionCancelTimer>(&act)) {
cancel_timer(ct->which);
} else if (auto* e = std::get_if<ActionRaiseError>(&act)) {
if (on_error_) on_error_(e->reason);
}
}
}
void TcpTransport::deliver_block(Block block) {
assembler_.push_back(std::move(block));
if (!assembler_.back().header.end_block) return;
auto msg = assemble_message(assembler_);
assembler_.clear();
if (!msg) {
if (on_error_) on_error_("assemble_message: malformed block sequence");
return;
}
if (on_message_) on_message_(std::move(*msg));
}
void TcpTransport::start_timer(Timer which) {
asio::steady_timer* timer = nullptr;
std::chrono::milliseconds dur{0};
switch (which) {
case Timer::T1: timer = &t1_; dur = timers_.t1; break;
case Timer::T2: timer = &t2_; dur = timers_.t2; break;
case Timer::T3: case Timer::T4: return; // upper-layer concern
}
timer->expires_after(dur);
auto self = shared_from_this();
timer->async_wait([self, which](std::error_code ec) {
if (ec) return; // cancelled or destroyed
std::vector<Action> actions;
self->fsm_.on_event(EventTimeout{which}, actions);
self->run_actions(actions);
});
}
void TcpTransport::cancel_timer(Timer which) {
switch (which) {
case Timer::T1: t1_.cancel(); break;
case Timer::T2: t2_.cancel(); break;
case Timer::T3: case Timer::T4: break;
}
}
void TcpTransport::log(const std::string& msg) {
if (on_log_) on_log_(msg);
}
} // namespace secsgem::secsi
+152
View File
@@ -0,0 +1,152 @@
// Integration test for the SECS-I TCP transport. Two TcpTransport
// instances are wired back-to-back over a loopback TCP pair; the test
// sends a SECS-II message in one direction and asserts the other side
// reassembles and delivers it. Verifies that the FSM, the framer, and
// the asio I/O loop play together end-to-end.
#include <doctest/doctest.h>
#include <asio.hpp>
#include <chrono>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "secsgem/secs2/item.hpp"
#include "secsgem/secs2/message.hpp"
#include "secsgem/secsi/tcp_transport.hpp"
using namespace secsgem::secsi;
namespace s2 = secsgem::secs2;
namespace {
struct SocketPair {
asio::io_context io;
asio::ip::tcp::socket a{io};
asio::ip::tcp::socket b{io};
SocketPair() {
asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint(
asio::ip::address_v4::loopback(), 0));
const auto port = acc.local_endpoint().port();
bool accepted = false, connected = false;
std::error_code ec_a, ec_b;
acc.async_accept(a, [&](std::error_code ec) { ec_a = ec; accepted = true; });
b.async_connect({asio::ip::address_v4::loopback(), port},
[&](std::error_code ec) { ec_b = ec; connected = true; });
while (!(accepted && connected)) {
if (io.stopped()) io.restart();
if (io.poll() == 0) std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
REQUIRE_FALSE(ec_a);
REQUIRE_FALSE(ec_b);
}
};
template <typename Pred>
void pump_until(asio::io_context& io, Pred pred,
std::chrono::milliseconds budget = std::chrono::seconds(5)) {
const auto deadline = std::chrono::steady_clock::now() + budget;
while (!pred()) {
if (std::chrono::steady_clock::now() > deadline) FAIL("pump_until budget exceeded");
if (io.stopped()) io.restart();
if (io.poll() == 0) std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
} // namespace
TEST_CASE("SECS-I TCP transport: single-block round-trip") {
SocketPair sp;
auto master = std::make_shared<TcpTransport>(std::move(sp.a), Role::Master, 1);
auto slave = std::make_shared<TcpTransport>(std::move(sp.b), Role::Slave, 1);
std::vector<s2::Message> received;
slave->set_message_handler([&](s2::Message m) { received.push_back(std::move(m)); });
std::vector<std::string> errors;
master->set_error_handler([&](const std::string& e) { errors.push_back(e); });
slave->set_error_handler([&](const std::string& e) { errors.push_back(e); });
master->start();
slave->start();
master->send(s2::Message(1, 13, true, s2::Item::ascii("LOT-99")));
pump_until(sp.io, [&] { return !received.empty() || !errors.empty(); });
REQUIRE(errors.empty());
REQUIRE(received.size() == 1);
CHECK(received[0].stream == 1);
CHECK(received[0].function == 13);
CHECK(received[0].reply_expected);
master->close();
slave->close();
pump_until(sp.io, [&] { return master->state() == Protocol::State::Idle; },
std::chrono::seconds(1));
}
TEST_CASE("SECS-I TCP transport: multi-block message reassembles correctly") {
SocketPair sp;
auto master = std::make_shared<TcpTransport>(std::move(sp.a), Role::Master, 1);
auto slave = std::make_shared<TcpTransport>(std::move(sp.b), Role::Slave, 1);
std::vector<s2::Message> received;
slave->set_message_handler([&](s2::Message m) { received.push_back(std::move(m)); });
std::vector<std::string> errors;
auto err = [&](const std::string& e) { errors.push_back(e); };
master->set_error_handler(err);
slave->set_error_handler(err);
master->start();
slave->start();
// Build a body that comfortably exceeds kMaxBlockBody.
std::string big(700, 'X');
master->send(s2::Message(7, 3, true, s2::Item::ascii(big)));
pump_until(sp.io, [&] { return !received.empty() || !errors.empty(); });
REQUIRE(errors.empty());
REQUIRE(received.size() == 1);
CHECK(received[0].stream == 7);
REQUIRE(received[0].body.has_value());
CHECK(received[0].body->as_ascii() == big);
master->close();
slave->close();
}
TEST_CASE("SECS-I TCP transport: bidirectional exchange") {
SocketPair sp;
auto master = std::make_shared<TcpTransport>(std::move(sp.a), Role::Master, 1);
auto slave = std::make_shared<TcpTransport>(std::move(sp.b), Role::Slave, 1);
std::vector<s2::Message> at_master, at_slave;
master->set_message_handler([&](s2::Message m) { at_master.push_back(std::move(m)); });
slave->set_message_handler([&](s2::Message m) { at_slave.push_back(std::move(m)); });
std::vector<std::string> errors;
auto err = [&](const std::string& e) { errors.push_back(e); };
master->set_error_handler(err);
slave->set_error_handler(err);
master->start();
slave->start();
master->send(s2::Message(1, 1, true, s2::Item::ascii("ping")));
pump_until(sp.io, [&] { return !at_slave.empty() || !errors.empty(); });
REQUIRE(errors.empty());
REQUIRE(at_slave.size() == 1);
slave->send(s2::Message(1, 2, false, s2::Item::ascii("pong")));
pump_until(sp.io, [&] { return !at_master.empty() || !errors.empty(); });
REQUIRE(errors.empty());
REQUIRE(at_master.size() == 1);
CHECK(at_master[0].function == 2);
master->close();
slave->close();
}