diff --git a/CMakeLists.txt b/CMakeLists.txt index 1156aee..151433b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,7 @@ add_library(secsgem src/secsi/header.cpp src/secsi/block.cpp src/secsi/protocol.cpp + src/secsi/tcp_transport.cpp src/gem/control_state.cpp src/gem/communication_state.cpp src/gem/process_job_state.cpp @@ -85,6 +86,7 @@ add_executable(secsgem_tests tests/test_hsms.cpp tests/test_hsms_connection.cpp tests/test_secsi.cpp + tests/test_secsi_tcp.cpp tests/test_control_state.cpp tests/test_communication_state.cpp tests/test_data_model.cpp diff --git a/COMPLIANCE.md b/COMPLIANCE.md index e6b3c15..fbaa007 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -54,7 +54,8 @@ Legend: | 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. | | 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/`. | --- diff --git a/include/secsgem/secsi/tcp_transport.hpp b/include/secsgem/secsi/tcp_transport.hpp new file mode 100644 index 0000000..b7819b1 --- /dev/null +++ b/include/secsgem/secsi/tcp_transport.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 { + public: + using MessageHandler = std::function; + using ErrorHandler = std::function; + using LogHandler = std::function; + + 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& 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 read_buf_{}; + std::vector 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 diff --git a/src/secsi/tcp_transport.cpp b/src/secsi/tcp_transport.cpp new file mode 100644 index 0000000..4b7080b --- /dev/null +++ b/src/secsi/tcp_transport.cpp @@ -0,0 +1,132 @@ +#include "secsgem/secsi/tcp_transport.hpp" + +#include + +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 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 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& actions) { + for (auto& act : actions) { + if (auto* t = std::get_if(&act)) { + auto buf = std::make_shared>(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(&act)) { + deliver_block(std::move(d->block)); + } else if (auto* st = std::get_if(&act)) { + start_timer(st->which); + } else if (auto* ct = std::get_if(&act)) { + cancel_timer(ct->which); + } else if (auto* e = std::get_if(&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 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 diff --git a/tests/test_secsi_tcp.cpp b/tests/test_secsi_tcp.cpp new file mode 100644 index 0000000..b33cc4d --- /dev/null +++ b/tests/test_secsi_tcp.cpp @@ -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 + +#include +#include +#include +#include +#include +#include + +#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 +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(std::move(sp.a), Role::Master, 1); + auto slave = std::make_shared(std::move(sp.b), Role::Slave, 1); + + std::vector received; + slave->set_message_handler([&](s2::Message m) { received.push_back(std::move(m)); }); + + std::vector 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(std::move(sp.a), Role::Master, 1); + auto slave = std::make_shared(std::move(sp.b), Role::Slave, 1); + + std::vector received; + slave->set_message_handler([&](s2::Message m) { received.push_back(std::move(m)); }); + + std::vector 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(std::move(sp.a), Role::Master, 1); + auto slave = std::make_shared(std::move(sp.b), Role::Slave, 1); + + std::vector 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 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(); +}