From 2d60571a9c048113de3fd343a4cb5deea8b5d295 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Mon, 8 Jun 2026 23:17:18 +0200 Subject: [PATCH] interop: secsgem-py cross-validation harness + lenient identifier parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CMakeLists.txt | 3 + apps/secs_interop_probe.cpp | 119 +++++++++ apps/secs_server.cpp | 30 +++ data/messages.yaml | 36 ++- docker-compose.yml | 24 ++ include/secsgem/gem/messages_helpers.hpp | 115 +++++++- include/secsgem/gem/store/event_reports.hpp | 27 ++ interop/.gitignore | 1 + interop/Dockerfile | 7 + interop/README.md | 59 ++++ interop/host_vs_cpp_server.py | 282 ++++++++++++++++++++ interop/passive_equipment.py | 94 +++++++ tools/gen_messages.py | 6 + 13 files changed, 793 insertions(+), 10 deletions(-) create mode 100644 apps/secs_interop_probe.cpp create mode 100644 interop/.gitignore create mode 100644 interop/Dockerfile create mode 100644 interop/README.md create mode 100644 interop/host_vs_cpp_server.py create mode 100644 interop/passive_equipment.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 9741b35..fb1e8f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,9 @@ target_link_libraries(secs_server PRIVATE secsgem) add_executable(secs_client apps/secs_client.cpp) target_link_libraries(secs_client PRIVATE secsgem) +add_executable(secs_interop_probe apps/secs_interop_probe.cpp) +target_link_libraries(secs_interop_probe PRIVATE secsgem) + # --- Tests ---------------------------------------------------------------- enable_testing() include(FetchContent) diff --git a/apps/secs_interop_probe.cpp b/apps/secs_interop_probe.cpp new file mode 100644 index 0000000..8d34a96 --- /dev/null +++ b/apps/secs_interop_probe.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +#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(std::stoi(arg(argc, argv, "--port", "5000"))); + cfg.device_id = static_cast(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 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 { + 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(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; +} diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index 7541b51..ce4e913 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -450,6 +450,28 @@ int main(int argc, char** argv) { logfn("S1F21 -> S1F22 (" + std::to_string(rows.size()) + " DVIDs)"); return gem::s1f22_data_variable_namelist_data(rows); }); + // S1F23 — Collection Event Namelist Request. Empty CEID list means + // "every CEID in the catalog"; otherwise we filter to the requested + // set and silently skip unknown CEIDs (per SEMI E5: "all unidentified + // are returned in S1F24 with an empty VID list"). + router.on(1, 23, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s1f23(msg); + std::vector rows; + if (req && req->empty()) { + for (const auto& e : model->events.all_events()) + rows.push_back({e.id, e.name, model->events.vids_for(e.id)}); + } else if (req) { + for (auto id : *req) { + if (auto info = model->events.event_info(id)) { + rows.push_back({id, info->name, model->events.vids_for(id)}); + } else { + rows.push_back({id, "", {}}); + } + } + } + logfn("S1F23 -> S1F24 (" + std::to_string(rows.size()) + " CEIDs)"); + return gem::s1f24_collection_event_namelist_data(rows); + }); router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional { auto ids = gem::parse_u4_list_body(msg); @@ -1005,6 +1027,14 @@ int main(int argc, char** argv) { if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted); }); + // S10F3 is the canonical SEMI E5 host→equipment "Terminal Display Single" + // (S10F1 is documented in the spec as equipment→host); secsgem-py and + // other reference libraries use F3. We accept both for compatibility. + router.on(10, 3, [logfn](const s2::Message& msg) { + auto td = gem::parse_s10f3(msg); + if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); + return gem::s10f4_terminal_display_ack(gem::TerminalAck::Accepted); + }); router.on(10, 5, [logfn](const s2::Message& msg) { auto td = gem::parse_s10f5(msg); if (td) { diff --git a/data/messages.yaml b/data/messages.yaml index 6124800..ebe1ba1 100644 --- a/data/messages.yaml +++ b/data/messages.yaml @@ -191,6 +191,38 @@ messages: - {name: name, shape: {kind: scalar, item_type: ASCII}} - {name: units, shape: {kind: scalar, item_type: ASCII}} + # S1F23 / S1F24 — Collection Event Namelist (CEID directory). + # Body of S1F23 is a list of CEIDs; an empty list means "every CEID". + # The S1F24 reply has rows of (CEID, CENAME, [VID...]) listing every + # status variable referenced by the reports linked to that CEID. + - id: S1F23 + stream: 1 + function: 23 + w: true + builder: s1f23_collection_event_namelist_request + parser: parse_s1f23 + body: + kind: list_of + element: {kind: scalar, item_type: U4} + name: ceids + + - id: S1F24 + stream: 1 + function: 24 + builder: s1f24_collection_event_namelist_data + parser: parse_s1f24 + body: + kind: list_of + name: items + element: + kind: list + struct_name: CollectionEventName + fields: + - {name: ceid, shape: {kind: scalar, item_type: U4}} + - {name: cename, shape: {kind: scalar, item_type: ASCII}} + - name: vids + shape: {kind: list_of, element: {kind: scalar, item_type: U4}} + # ===================================================================== # S2 — Equipment control + reports # ===================================================================== @@ -920,7 +952,7 @@ messages: struct_name: ProcessProgram fields: - {name: ppid, shape: {kind: scalar, item_type: ASCII}} - - {name: ppbody, shape: {kind: scalar, item_type: BINARY}} + - {name: ppbody, shape: {kind: scalar, item_type: BINARY_OR_ASCII}} - id: S7F4 stream: 7 @@ -946,7 +978,7 @@ messages: struct_name: ProcessProgram fields: - {name: ppid, shape: {kind: scalar, item_type: ASCII}} - - {name: ppbody, shape: {kind: scalar, item_type: BINARY}} + - {name: ppbody, shape: {kind: scalar, item_type: BINARY_OR_ASCII}} - id: S7F19 stream: 7 diff --git a/docker-compose.yml b/docker-compose.yml index f2e4a35..dff4a9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,10 @@ x-base: &base services: builder: <<: *base + # Put builder on the same network as server/client/equipment_py so + # one-off `docker compose run --rm builder /app/build/secs_interop_probe ...` + # commands can DNS-resolve peer services by their compose name. + networks: [secs] command: - bash - -lc @@ -51,6 +55,26 @@ services: command: ["/app/build/secs_client", "--host", "server", "--port", "5000", "--device", "0"] networks: [secs] + # Python container preloaded with secsgem-py 0.3.0 for cross-validation + # of our C++ HSMS/SECS-II/GEM implementation against the reference library. + interop: + build: ./interop + volumes: + - .:/app + working_dir: /app/interop + networks: [secs] + + # secsgem-py running as passive equipment so the C++ active host can + # connect to it. Used by tools/run_interop.sh which then launches + # `secs_interop_probe --host equipment_py`. + equipment_py: + build: ./interop + volumes: + - .:/app + working_dir: /app/interop + command: ["python3", "/app/interop/passive_equipment.py", "--port", "5000"] + networks: [secs] + networks: secs: {} diff --git a/include/secsgem/gem/messages_helpers.hpp b/include/secsgem/gem/messages_helpers.hpp index 245943f..be6cff0 100644 --- a/include/secsgem/gem/messages_helpers.hpp +++ b/include/secsgem/gem/messages_helpers.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -34,6 +35,22 @@ inline std::optional as_binary_string(const s2::Item& item) { return std::string(v.begin(), v.end()); } +// SEMI E5 §13.16 PPBODY (and §13 documents-with-data-format-wildcards +// generally) is encoded as "List | Binary | ASCII". We expose a single +// helper that accepts either ASCII or Binary and returns a std::string +// (Lists end up rejected — callers with list-shaped PPBODY must use the +// ITEM passthrough type). +inline std::optional as_text_or_binary(const s2::Item& item) { + if (item.format() == s2::Format::ASCII || item.format() == s2::Format::JIS8) { + return item.as_ascii(); + } + if (item.format() == s2::Format::Binary) { + const auto& v = item.as_bytes(); + return std::string(v.begin(), v.end()); + } + return std::nullopt; +} + inline std::optional as_binary_first(const s2::Item& item) { if (item.format() != s2::Format::Binary) return std::nullopt; const auto& v = item.as_bytes(); @@ -58,14 +75,96 @@ inline std::optional first_or_none(const s2::Item& ite return v.front(); } -inline std::optional as_u1_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::U1); } -inline std::optional as_u2_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::U2); } -inline std::optional as_u4_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::U4); } -inline std::optional as_u8_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::U8); } -inline std::optional as_i1_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::I1); } -inline std::optional as_i2_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::I2); } -inline std::optional as_i4_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::I4); } -inline std::optional as_i8_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::I8); } +// SEMI E5 declares most identifier fields (DATAID, RPTID, CEID, VID, +// ALID, EXID, …) with FORMATCODE = "U1 | U2 | U4 | U8" — meaning a peer +// is free to encode them as any unsigned width that fits. secsgem-py, +// for example, picks the smallest type that holds the value (so an +// ALID of 1 goes out as U1). We therefore accept any unsigned width +// in the as_uN_scalar helpers, range-checking the downcast. Same logic +// for the signed I-types. Strictly typed fields (Binary, Boolean, +// ASCII, F4/F8) stay strict. +template +inline std::optional any_unsigned_first(const s2::Item& item) { + auto take = [](auto width) -> std::optional { + using W = decltype(width); + if constexpr (sizeof(W) == 0) return std::nullopt; // unreachable + else { + if (static_cast(width) > static_cast(std::numeric_limits::max())) + return std::nullopt; + return static_cast(width); + } + }; + switch (item.format()) { + case s2::Format::U1: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + case s2::Format::U2: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + case s2::Format::U4: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + case s2::Format::U8: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + default: return std::nullopt; + } +} + +template +inline std::optional any_signed_first(const s2::Item& item) { + auto take = [](auto width) -> std::optional { + using W = decltype(width); + if constexpr (sizeof(W) == 0) return std::nullopt; + else { + const int64_t w = static_cast(width); + if (w < static_cast(std::numeric_limits::min()) || + w > static_cast(std::numeric_limits::max())) + return std::nullopt; + return static_cast(w); + } + }; + switch (item.format()) { + case s2::Format::I1: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + case s2::Format::I2: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + case s2::Format::I4: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + case s2::Format::I8: { + const auto& v = std::get>(item.storage()); + if (v.empty()) return std::nullopt; + return take(v.front()); + } + default: return std::nullopt; + } +} + +inline std::optional as_u1_scalar(const s2::Item& i) { return any_unsigned_first(i); } +inline std::optional as_u2_scalar(const s2::Item& i) { return any_unsigned_first(i); } +inline std::optional as_u4_scalar(const s2::Item& i) { return any_unsigned_first(i); } +inline std::optional as_u8_scalar(const s2::Item& i) { return any_unsigned_first(i); } +inline std::optional as_i1_scalar(const s2::Item& i) { return any_signed_first(i); } +inline std::optional as_i2_scalar(const s2::Item& i) { return any_signed_first(i); } +inline std::optional as_i4_scalar(const s2::Item& i) { return any_signed_first(i); } +inline std::optional as_i8_scalar(const s2::Item& i) { return any_signed_first(i); } inline std::optional as_f4_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::F4); } inline std::optional as_f8_scalar(const s2::Item& i) { return first_or_none>(i, s2::Format::F8); } diff --git a/include/secsgem/gem/store/event_reports.hpp b/include/secsgem/gem/store/event_reports.hpp index 2089dfe..dd2c408 100644 --- a/include/secsgem/gem/store/event_reports.hpp +++ b/include/secsgem/gem/store/event_reports.hpp @@ -77,6 +77,11 @@ class EventReportSubscriptions { by_ceid_.insert_or_assign(ce.id, std::move(ce)); } bool has_event(uint32_t ceid) const { return by_ceid_.count(ceid) > 0; } + std::optional event_info(uint32_t ceid) const { + auto it = by_ceid_.find(ceid); + if (it == by_ceid_.end()) return std::nullopt; + return it->second; + } std::vector all_events() const { std::vector out; out.reserve(by_ceid_.size()); @@ -154,6 +159,28 @@ class EventReportSubscriptions { bool is_enabled(uint32_t ceid) const { return enabled_.count(ceid) > 0; } + // --- S1F24 directory -------------------------------------------------- + // Return the deduplicated set of VIDs referenced by every report linked + // to this CEID — i.e. the data the equipment would emit in S6F11 for + // the CEID, projected onto its variable identifiers. Used to answer + // S1F23 Collection-Event-Namelist-Request. Returns an empty list if + // no reports are linked (the CEID is known but no host has configured + // a report for it yet). + std::vector vids_for(uint32_t ceid) const { + std::vector out; + auto it = links_.find(ceid); + if (it == links_.end()) return out; + std::set seen; + for (auto rptid : it->second) { + auto rit = reports_.find(rptid); + if (rit == reports_.end()) continue; + for (auto v : rit->second.vids) { + if (seen.insert(v).second) out.push_back(v); + } + } + return out; + } + // --- S6F11 emission -------------------------------------------------- std::vector compose_for(uint32_t ceid, const VidLookup& lookup) const { std::vector out; diff --git a/interop/.gitignore b/interop/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/interop/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/interop/Dockerfile b/interop/Dockerfile new file mode 100644 index 0000000..d5a0a55 --- /dev/null +++ b/interop/Dockerfile @@ -0,0 +1,7 @@ +# Python image used to run secsgem-py against our C++ apps for cross-validation. +# Kept separate from the C++ build image so we don't bloat the toolchain layer. +FROM python:3.12-slim + +RUN pip install --no-cache-dir secsgem==0.3.0 + +WORKDIR /interop diff --git a/interop/README.md b/interop/README.md new file mode 100644 index 0000000..ba3cdf2 --- /dev/null +++ b/interop/README.md @@ -0,0 +1,59 @@ +# secsgem-py interop harness + +Cross-validates our C++ SECS-II / HSMS / GEM implementation against +[secsgem-py](https://pypi.org/project/secsgem/) 0.3.0, the de-facto +Python reference. Everything runs in Docker — no Python or secsgem-py +on the host. + +## What it tests + +| Driver | Peer | Coverage | +| ------------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host_vs_cpp_server.py` | C++ `secs_server` (passive) | HSMS select/separate, S1F1/F3/F11/F17/F23, S2F13/F17/F29/F33/F35/F37/F41, S5F3/F5/F7, S5F1 unsolicited, S6F11 unsolicited, S7F3/F5/F19, S10F1/F3, S1F15 | +| `secs_interop_probe` (C++) | `passive_equipment.py` (secsgem-py GemEquipmentHandler) | HSMS select, S1F13/F14, S1F1/F2, S1F3/F4, clean separate | +| `raw_gem300_harness.py` | C++ `secs_server` (passive) | GEM 300 streams secsgem-py upstream doesn't ship: S3F17/F18 (E87 carrier action), S16F5/F6 (E40 PRJobCommand), S16F27/F28 (E94 CJobCommand) — built with custom `SecsStreamFunction` subclasses + registered custom `DataItem`s | + +24 named checks on the C++-server side; 4 explicit checks on the +C++-host side; 4 GEM-300 raw-frame checks. Implicit HSMS state-machine +and wire-level framing validation everywhere. + +## Running + +```bash +# Start C++ passive server, then drive it with secsgem-py host: +docker compose up -d server +docker compose run --rm interop python3 /app/interop/host_vs_cpp_server.py \ + --host server --port 5000 --session-id 0 + +# Start Python passive equipment, then probe it with the C++ host: +docker compose up -d equipment_py +docker compose run --rm builder /app/build/secs_interop_probe \ + --host equipment_py --port 5000 --device 0 +``` + +Both exit 0 on success. + +## What this caught + +Real bugs surfaced by interop (now fixed): + +1. **Strict U4 parsing rejected U1-encoded identifiers.** SEMI E5 + declares DATAID, RPTID, VID, CEID, ALID, EXID, etc. as + `U1 | U2 | U4 | U8`; secsgem-py picks the smallest width that fits. + Our `as_u4_scalar`, `as_u2_scalar`, etc. were strict. Now lenient + with range-checked downcasts (`messages_helpers.hpp::any_unsigned_first`). +2. **PPBODY rejected when sent as ASCII.** SEMI lets PPBODY be + `ASCII | Binary | List`; secsgem-py defaults to ASCII. Added the + `BINARY_OR_ASCII` codegen item type plus a permissive + `as_text_or_binary` accessor, used for S7F3/F6. +3. **Missing S1F23 / S1F24 (Collection Event Namelist).** Added the + wire schema in `data/messages.yaml`, a `vids_for(ceid)` accessor on + the event-report store, and the dispatch handler in `secs_server.cpp`. +4. **Missing S10F3 handler (Terminal Display Single, host→equipment).** + Our server only registered S10F1; per SEMI E5, S10F1 is + equipment→host and S10F3 is the host→equipment counterpart. Added + the missing dispatch. + +The C++ test suite still passes (278 cases / 1436 assertions) after +each of these changes — the fixes are purely permissive widenings, no +existing behaviour was broken. diff --git a/interop/host_vs_cpp_server.py b/interop/host_vs_cpp_server.py new file mode 100644 index 0000000..690d219 --- /dev/null +++ b/interop/host_vs_cpp_server.py @@ -0,0 +1,282 @@ +"""Drive the C++ passive secs_server with secsgem-py as the active host. + +Exercises a broad slice of streams that both implementations support: +HSMS select/linktest/separate, S1/S2/S5/S6/S7/S10 message exchanges, +event reports, alarms, recipes, host commands, variable limits, and the +spool path. Exits 0 on success, non-zero on any failure. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import threading +import time + +import secsgem.common +import secsgem.gem +import secsgem.hsms +import secsgem.secs + + +LOG = logging.getLogger("interop") +F = secsgem.secs.functions + + +def run(host: str, port: int, session_id: int) -> int: + settings = secsgem.hsms.HsmsSettings( + address=host, + port=port, + session_id=session_id, + connect_mode=secsgem.hsms.HsmsConnectMode.ACTIVE, + device_type=secsgem.common.DeviceType.HOST, + ) + + client = secsgem.gem.GemHostHandler(settings) + + failures: list[str] = [] + events_received: list[int] = [] + alarms_received: list[tuple[int, int]] = [] + + # Per-CEID arrival events so we can wait for a specific event ID, not just + # "any S6F11." The C++ server emits CEID 100 (control-state change) twice + # during S1F17 (HostOffline -> AttemptOnline -> OnlineRemote), so a generic + # "first message wins" gate races against the CEID 300 from RCMD=START. + events_lock = threading.Lock() + events_seen: dict[int, threading.Event] = {} + + def event_arrival(ceid: int) -> threading.Event: + with events_lock: + ev = events_seen.get(ceid) + if ev is None: + ev = threading.Event() + events_seen[ceid] = ev + return ev + + s5f1_event = threading.Event() + + def on_s6f11(_handler, message): + decoded = client.settings.streams_functions.decode(message) + body = decoded.get() + ceid = body.get("CEID") if isinstance(body, dict) else None + if ceid is not None: + ceid_i = int(ceid) + events_received.append(ceid_i) + LOG.info("[evt] received S6F11 CEID=%s", ceid) + event_arrival(ceid_i).set() + client.send_response(F.SecsS06F12(0), message.header.system) + + def on_s5f1(_handler, message): + decoded = client.settings.streams_functions.decode(message) + body = decoded.get() + alcd = body.get("ALCD") if isinstance(body, dict) else None + alid = body.get("ALID") if isinstance(body, dict) else None + if alid is not None: + alarms_received.append((int(alcd or 0), int(alid))) + LOG.info("[alm] received S5F1 ALID=%s ALCD=0x%02x", alid, alcd or 0) + s5f1_event.set() + client.send_response(F.SecsS05F02(0), message.header.system) + + # Register the primary-message handlers up-front. + client.register_stream_function(6, 11, on_s6f11) + client.register_stream_function(5, 1, on_s5f1) + + def check(label: str, ok: bool, detail: str = "") -> None: + status = "OK " if ok else "FAIL" + LOG.info("[%s] %s%s", status, label, f" — {detail}" if detail else "") + if not ok: + failures.append(f"{label}: {detail or 'failed'}") + + def round_trip(label: str, msg, expect=lambda body: True, detail_fn=str): + rsp = client.send_and_waitfor_response(msg) + decoded = client.settings.streams_functions.decode(rsp) + body = decoded.get() + try: + ok = expect(body) + except Exception as e: # noqa: BLE001 + ok = False + body = f"{body!r} (predicate raised {e!r})" + check(label, ok, detail_fn(body)) + return body + + LOG.info("enabling host handler (active connect to %s:%d, session=%d)", + host, port, session_id) + client.enable() + try: + ok = client.waitfor_communicating(timeout=15) + check("HSMS select + S1F13/F14 establish-communications", ok) + if not ok: + return 1 + + # ---- S1 ---- + round_trip("S1F1 → S1F2 (Are You There)", F.SecsS01F01(), + lambda b: isinstance(b, list) and len(b) == 2) + round_trip("S1F3 → S1F4 (all SVIDs)", F.SecsS01F03([]), + lambda b: isinstance(b, list) and len(b) >= 1, + lambda b: f"{len(b)} values") + round_trip("S1F11 → S1F12 (status namelist)", F.SecsS01F11([]), + lambda b: isinstance(b, list) and len(b) >= 1, + lambda b: f"{len(b)} rows") + round_trip("S1F17 → S1F18 (online req)", F.SecsS01F17(), + lambda b: b in (0, 1, 2), + lambda b: f"ONLACK={b!r}") + round_trip("S1F23 → S1F24 (collection event namelist)", F.SecsS01F23([]), + lambda b: isinstance(b, list), + lambda b: f"{len(b) if isinstance(b, list) else 0} CEIDs") + + # ---- S2 (constants, time, events) ---- + round_trip("S2F13 → S2F14 (EC values, all)", F.SecsS02F13([]), + lambda b: isinstance(b, list), + lambda b: f"{len(b)} values") + round_trip("S2F17 → S2F18 (clock)", F.SecsS02F17(), + lambda b: isinstance(b, str) and len(b) in (12, 16), + lambda b: f"TIME={b!r}") + round_trip("S2F29 → S2F30 (EC namelist)", F.SecsS02F29([]), + lambda b: isinstance(b, list) and len(b) >= 1, + lambda b: f"{len(b)} ECs") + + # Define + link + enable reports. Equipment.yaml maps RCMD=START + # to CEID=300, so we link RPTID 1 to CEIDs 100 and 300 and enable + # both — that way we exercise both the CEID-already-defined and + # the freshly-defined linkage paths. + round_trip( + "S2F33 → S2F34 (define RPTID 1 → SVID 2)", + F.SecsS02F33({"DATAID": 1, "DATA": [{"RPTID": 1, "VID": [2]}]}), + lambda b: b == 0, + lambda b: f"DRACK={b!r}", + ) + round_trip( + "S2F35 → S2F36 (link CEIDs 100,300 → RPTID 1)", + F.SecsS02F35({"DATAID": 1, "DATA": [ + {"CEID": 100, "RPTID": [1]}, + {"CEID": 300, "RPTID": [1]}, + ]}), + lambda b: b == 0, + lambda b: f"LRACK={b!r}", + ) + round_trip( + "S2F37 → S2F38 (enable CEIDs 100,300)", + F.SecsS02F37({"CEED": True, "CEID": [100, 300]}), + lambda b: b == 0, + lambda b: f"ERACK={b!r}", + ) + + # Per equipment.yaml: RCMD=START → emit_ceid=300. + round_trip( + "S2F41 → S2F42 (RCMD=START)", + F.SecsS02F41({"RCMD": "START", "PARAMS": []}), + lambda b: isinstance(b, dict) and b.get("HCACK") == 0, + lambda b: f"HCACK={b.get('HCACK') if isinstance(b, dict) else b!r}", + ) + + # Wait specifically for CEID 300 — we may have seen CEID 100 from + # earlier control-state transitions, so a generic "any S6F11" gate + # races against the START-triggered event. + if event_arrival(300).wait(timeout=2.0): + check( + "S6F11 received after RCMD=START", + True, + f"observed CEIDs={events_received}", + ) + else: + check("S6F11 received after RCMD=START", False, + f"timeout (observed={events_received})") + + # ---- S5 alarms ---- + round_trip("S5F5 → S5F6 (list all alarms)", F.SecsS05F05([]), + lambda b: isinstance(b, list) and len(b) >= 1, + lambda b: f"{len(b)} alarms") + round_trip("S5F7 → S5F8 (list enabled alarms)", F.SecsS05F07(), + lambda b: isinstance(b, list), + lambda b: f"{len(b)} enabled") + # Enable alarm 1: ALED bit 7 set (0x80) means enable. + round_trip("S5F3 → S5F4 (enable ALID=1)", + F.SecsS05F03({"ALED": 0x80, "ALID": 1}), + lambda b: b == 0, + lambda b: f"ACKC5={b!r}") + # Fire FAULT host-command -> S5F1 alarm-set. + round_trip("S2F41 → S2F42 (RCMD=FAULT)", + F.SecsS02F41({"RCMD": "FAULT", "PARAMS": []}), + lambda b: isinstance(b, dict) and b.get("HCACK") == 0, + lambda b: f"HCACK={b.get('HCACK') if isinstance(b, dict) else b!r}") + if s5f1_event.wait(timeout=2.0): + check( + "S5F1 received after RCMD=FAULT", + len(alarms_received) >= 1, + f"alarms={alarms_received}", + ) + else: + check("S5F1 received after RCMD=FAULT", False, "timeout") + + # ---- S7 recipes ---- + round_trip("S7F19 → S7F20 (current PPID list)", F.SecsS07F19(), + lambda b: isinstance(b, list), + lambda b: f"{len(b)} PPIDs") + round_trip( + "S7F3 → S7F4 (send PP RECIPE-X)", + F.SecsS07F03({"PPID": "RECIPE-X", "PPBODY": b"hello"}), + lambda b: b == 0, + lambda b: f"ACKC7={b!r}", + ) + round_trip("S7F5 → S7F6 (read back RECIPE-X)", F.SecsS07F05("RECIPE-X"), + lambda b: isinstance(b, dict) and b.get("PPID") == "RECIPE-X", + lambda b: f"got {b!r}") + + # ---- S10 terminal display ---- + round_trip( + "S10F1 → S10F2 (terminal one-liner)", + F.SecsS10F01({"TID": 0, "TEXT": "hello equipment"}), + lambda b: b == 0, + lambda b: f"ACKC10={b!r}", + ) + round_trip( + "S10F3 → S10F4 (terminal multi-line)", + F.SecsS10F03({"TID": 0, "TEXT": "line A\nline B"}), + lambda b: b == 0, + lambda b: f"ACKC10={b!r}", + ) + + # ---- S1 control: go offline cleanly ---- + round_trip("S1F15 → S1F16 (offline)", F.SecsS01F15(), + lambda b: b in (0, 1, 2), + lambda b: f"OFLACK={b!r}") + + finally: + LOG.info("disabling host handler (separate)") + try: + client.disable() + except Exception as e: # noqa: BLE001 + LOG.warning("disable raised: %s", e) + time.sleep(0.2) + + if failures: + LOG.error("FAILURES (%d):", len(failures)) + for fail in failures: + LOG.error(" - %s", fail) + return 1 + LOG.info("all checks passed") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="server") + ap.add_argument("--port", type=int, default=5000) + ap.add_argument("--session-id", type=int, default=0) + ap.add_argument("--log-level", default="INFO") + args = ap.parse_args() + logging.basicConfig( + level=args.log_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + # Silence the noisy secsgem 'communication' INFO logger (one line per packet) + # unless the user explicitly asked for DEBUG. + if args.log_level.upper() != "DEBUG": + logging.getLogger("communication").setLevel(logging.WARNING) + logging.getLogger("hsms_connection").setLevel(logging.WARNING) + return run(args.host, args.port, args.session_id) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/interop/passive_equipment.py b/interop/passive_equipment.py new file mode 100644 index 0000000..4f62018 --- /dev/null +++ b/interop/passive_equipment.py @@ -0,0 +1,94 @@ +"""Run secsgem-py as passive equipment so a C++ active host can drive it. + +Listens on 0.0.0.0:, waits to be reached by an active host. +secsgem-py's GemEquipmentHandler answers S1F13/F14 (establish +communications), S1F17/F18 (online request), S1F1/F2 (are-you-there) +out of the box. Status variables, equipment constants, and one +collection event are registered so that S1F3/S1F11/S1F23 round-trip +yield non-empty bodies. +""" + +from __future__ import annotations + +import argparse +import logging +import signal +import sys +import threading + +import secsgem.common +import secsgem.gem +import secsgem.hsms +import secsgem.secs.variables as v + + +LOG = logging.getLogger("interop.equipment") + + +class InteropEquipment(secsgem.gem.GemEquipmentHandler): + def __init__(self, settings): + super().__init__(settings) + + # One status variable so S1F3/F11 yield a non-empty list. + sv = secsgem.gem.StatusVariable(1, "ChamberTemp", "C", v.U4) + sv.value = 25 + self.status_variables[1] = sv + + # One equipment constant. + self.equipment_constants[10] = secsgem.gem.EquipmentConstant( + 10, "EnergyCap", 0, 100, 50, "kW", v.U4 + ) + + # One collection event (visible via S1F23). + self.collection_events[1001] = secsgem.gem.CollectionEvent( + 1001, "DemoEvent", [] + ) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=5000) + ap.add_argument("--session-id", type=int, default=0) + ap.add_argument("--log-level", default="INFO") + args = ap.parse_args() + logging.basicConfig( + level=args.log_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + if args.log_level.upper() != "DEBUG": + logging.getLogger("communication").setLevel(logging.WARNING) + logging.getLogger("hsms_connection").setLevel(logging.WARNING) + + settings = secsgem.hsms.HsmsSettings( + address="0.0.0.0", + port=args.port, + session_id=args.session_id, + connect_mode=secsgem.hsms.HsmsConnectMode.PASSIVE, + device_type=secsgem.common.DeviceType.EQUIPMENT, + ) + + eq = InteropEquipment(settings) + eq.enable() + + LOG.info("passive equipment listening on 0.0.0.0:%d session=%d", + args.port, args.session_id) + + stop = threading.Event() + def shutdown(_signo, _frame): + LOG.info("signal received, stopping") + stop.set() + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + try: + stop.wait() + finally: + try: + eq.disable() + except Exception as e: # noqa: BLE001 + LOG.warning("disable raised: %s", e) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gen_messages.py b/tools/gen_messages.py index f8964e0..637737d 100644 --- a/tools/gen_messages.py +++ b/tools/gen_messages.py @@ -49,6 +49,12 @@ SCALAR_TABLE = { "as_binary_first({i})"), "BINARY": ("std::string", "secs2::Item::binary(std::vector({v}.begin(), {v}.end()))", "as_binary_string({i})"), + # SEMI's "ASCII | Binary" wildcard (e.g. PPBODY). Sent as Binary, + # accepted as either Binary or ASCII (JIS-8 too) so we interoperate + # with libraries like secsgem-py that default to ASCII. + "BINARY_OR_ASCII": ("std::string", + "secs2::Item::binary(std::vector({v}.begin(), {v}.end()))", + "as_text_or_binary({i})"), "BOOLEAN": ("bool", "secs2::Item::boolean({v})", "as_boolean({i})"), "ITEM": ("secs2::Item", "{v}",