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:
2026-06-08 23:17:18 +02:00
parent 9fbab92106
commit 2d60571a9c
13 changed files with 793 additions and 10 deletions
+119
View File
@@ -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;
}