72fa73fee0
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>
153 lines
5.1 KiB
C++
153 lines
5.1 KiB
C++
// 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();
|
|
}
|