interop: secsgem-py cross-validation harness + lenient identifier parsing
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>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user