2d60571a9c
Adds a Docker-based interop harness that drives the C++ server with
secsgem-py 0.3.0 as the active host and probes a secsgem-py-passive
equipment from a minimal C++ active client. Surfaces and fixes four
interoperability bugs uncovered by cross-testing:
* SEMI E5 identifier formatcodes are a U1|U2|U4|U8 wildcard;
secsgem-py picks the narrowest fitting width while our parsers
only accepted U4. `as_uN_scalar` / `as_iN_scalar` now accept
any unsigned/signed width and range-check the downcast.
* PPBODY (S7F3/F6) is "ASCII | Binary | List" per the spec;
secsgem-py defaults to ASCII. Added BINARY_OR_ASCII codegen
item type with `as_text_or_binary` accessor.
* S1F23/F24 Collection Event Namelist was unimplemented; added
schema + `vids_for(ceid)` accessor on EventReportSubscriptions
plus the dispatch handler.
* S10F1 was registered as a host->equipment handler, but per
SEMI E5 §12 S10F1 is equipment->host; S10F3 is the actual
host->equipment Terminal Display Single. Added an S10F3
handler alongside (we keep S10F1 too for backward compat).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
120 lines
4.4 KiB
C++
120 lines
4.4 KiB
C++
// Minimal interop driver: connect, HSMS-select, S1F13, S1F1, S1F3,
|
|
// separate. Exits 0 if every step succeeds, non-zero on the first
|
|
// failure. Used by the interop harness to validate that our active
|
|
// (host) HSMS implementation talks to a third-party passive equipment
|
|
// — in particular, secsgem-py running as GemEquipmentHandler.
|
|
|
|
#include <asio.hpp>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <system_error>
|
|
|
|
#include "secsgem/endpoint.hpp"
|
|
#include "secsgem/gem/messages.hpp"
|
|
#include "secsgem/gem/messages_helpers.hpp"
|
|
#include "secsgem/secs2/message.hpp"
|
|
|
|
using namespace secsgem;
|
|
namespace s2 = secsgem::secs2;
|
|
namespace gem = secsgem::gem;
|
|
|
|
namespace {
|
|
|
|
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
|
|
for (int i = 1; i + 1 < argc; ++i)
|
|
if (key == argv[i]) return argv[i + 1];
|
|
return def;
|
|
}
|
|
|
|
int exit_code = 1;
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
Client::Config cfg;
|
|
cfg.host = arg(argc, argv, "--host", "127.0.0.1");
|
|
cfg.port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000")));
|
|
cfg.device_id = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--device", "0")));
|
|
cfg.timers.linktest = std::chrono::milliseconds(0);
|
|
|
|
asio::io_context io;
|
|
Client client(io, cfg);
|
|
|
|
auto logfn = [](const std::string& m) { std::cout << "[probe] " << m << std::endl; };
|
|
client.on_log(logfn);
|
|
|
|
// Bail out after 15s so a stuck handshake doesn't hang the test.
|
|
asio::steady_timer deadline(io);
|
|
deadline.expires_after(std::chrono::seconds(15));
|
|
deadline.async_wait([&](std::error_code ec) {
|
|
if (!ec) { logfn("DEADLINE reached, aborting"); io.stop(); }
|
|
});
|
|
|
|
client.on_connection([&](std::shared_ptr<Connection> conn) {
|
|
auto fail = [&io, logfn, conn](const char* where, std::error_code ec) {
|
|
logfn(std::string("FAIL ") + where + ": " + ec.message());
|
|
conn->close(where);
|
|
io.stop();
|
|
};
|
|
|
|
// The equipment will also send us its own S1F13 once SELECTED — we
|
|
// need to reply with S1F14, otherwise the equipment's transaction
|
|
// times out. Same for any unsolicited primary we don't care about.
|
|
conn->set_message_handler(
|
|
[logfn](const s2::Message& msg) -> std::optional<s2::Message> {
|
|
if (msg.stream == 1 && msg.function == 13) {
|
|
logfn("<- equipment S1F13, replying S1F14");
|
|
return gem::s1f14_establish_comms_ack(
|
|
gem::CommAck::Accept, {"INTEROP-PROBE", "0.0.1"});
|
|
}
|
|
if (msg.reply_expected) return s2::Message(msg.stream, 0, false);
|
|
return std::nullopt;
|
|
});
|
|
|
|
// Drive the probe sequence only after SELECT completes, otherwise
|
|
// our outbound S1F13 races the HSMS Select.req and the equipment
|
|
// rejects it.
|
|
conn->set_selected_handler([&, conn, logfn, fail]() {
|
|
// Step 1: S1F13 establish communications.
|
|
conn->send_request(
|
|
gem::s1f13_establish_comms("INTEROP-PROBE", "0.0.1"),
|
|
[&, conn, logfn, fail](std::error_code ec, const s2::Message& reply) {
|
|
if (ec) { fail("S1F13", ec); return; }
|
|
logfn("OK S1F13->S1F14");
|
|
|
|
// Step 2: S1F1 Are You There.
|
|
conn->send_request(
|
|
gem::s1f1_are_you_there(),
|
|
[&, conn, logfn, fail](std::error_code ec2, const s2::Message& reply2) {
|
|
if (ec2) { fail("S1F1", ec2); return; }
|
|
logfn("OK S1F1->S1F2");
|
|
|
|
// Step 3: S1F3 with empty SVID list (= "all").
|
|
conn->send_request(
|
|
gem::s1f3_selected_status_request({}),
|
|
[&, conn, logfn, fail](std::error_code ec3,
|
|
const s2::Message& reply3) {
|
|
if (ec3) { fail("S1F3", ec3); return; }
|
|
logfn(std::string("OK S1F3->S1F4 body=") + reply3.sml());
|
|
|
|
exit_code = 0;
|
|
logfn("ALL PROBES PASSED");
|
|
conn->separate();
|
|
// Give separate a beat to flush.
|
|
auto t = std::make_shared<asio::steady_timer>(io);
|
|
t->expires_after(std::chrono::milliseconds(200));
|
|
t->async_wait([t, &io](std::error_code) { io.stop(); });
|
|
});
|
|
});
|
|
});
|
|
}); // set_selected_handler
|
|
});
|
|
|
|
client.start();
|
|
io.run();
|
|
return exit_code;
|
|
}
|