From b99d84f956b7fbcd7fef37f7694863ab4f6abfd9 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Tue, 9 Jun 2026 14:56:15 +0200 Subject: [PATCH] =?UTF-8?q?hsms-gs:=20worked=20integration=20example=20+?= =?UTF-8?q?=20INTEGRATION.md=20=C2=A77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebase has supported HSMS-GS since the original landing (test_hsms_gs.cpp covers the wire-level Select.req-per-session walk-list, the per-session Reject(EntityNotSelected) behaviour, and session-routed data dispatch). But the documentation said exactly one line about it ("Connection::add_session(device_id) registers extra sessions on one TCP socket") and there was no end-to-end test using the Server/Client API customers actually build against. INTEGRATION.md §7 is a new section showing the realistic pattern: - Server-side: register the primary session via Server::Config, then `add_session` for the second MES in the on_connection callback. Per-session message handler + selected handler so each MES gets its own router (or its own per-session data view over a shared EquipmentDataModel). - Active-mode: same `add_session` on the host-side Connection for multi-tool fleet controllers. - Equipment-initiated push: pick the session_id when sending unsolicited primaries (S5F1, S6F11, S10F1). - Pointer to the wire tests + the new integration test for customers who want to see the failure modes. tests/test_hsms_gs_integration.cpp drives two MES sessions (device_id 1 + 2) through the Server/Client API end to end: - Both sessions complete Select.req independently - S1F1 sent on each session returns a distinct MDLN ("EQUIP-SESS-1" vs "EQUIP-SESS-2"), proving per-session dispatch routes correctly - Per-session router fires exactly once per session, no cross-talk Pre-existing §§8-10 in INTEGRATION.md got bumped to §§9-11 to make room. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 1 + INTEGRATION.md | 86 ++++++++++++++- tests/test_hsms_gs_integration.cpp | 165 +++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 tests/test_hsms_gs_integration.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 16042b8..1810cad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -148,6 +148,7 @@ add_executable(secsgem_tests tests/test_persistence_upgrade.cpp tests/test_config_validate.cpp tests/test_metrics_prometheus.cpp + tests/test_hsms_gs_integration.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_compile_definitions(secsgem_tests PRIVATE diff --git a/INTEGRATION.md b/INTEGRATION.md index 629fe0e..1532a28 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -533,7 +533,85 @@ production. --- -## 7. Recommended layout for a vendor application +## 7. HSMS-GS: one tool, multiple MES + +Most fab tools talk to one MES. Some — particularly tools shared by +multiple production lines or sites — need to serve two or more MES +schedulers simultaneously over a single HSMS-GS connection. E37 §11 +calls these "general sessions": one TCP socket, multiple session +identifiers, independent SELECTED state per session. + +The library models this as additional sessions on the same +`hsms::Connection`: + +```cpp +server.on_connection([](std::shared_ptr conn) { + // Primary session (device_id=1) was registered by Server::Config; + // add a second session for the second MES. + conn->add_session(/*device_id=*/2); + + // Per-session message routing — each MES gets a distinct dispatcher, + // distinct SVID views, distinct alarm enable state, distinct + // recipe namespace if you want. Or share state via a common + // EquipmentDataModel and just route messages here. + conn->set_session_message_handler(1, [model_1](const secs2::Message& m) { + return router_1.dispatch(m); + }); + conn->set_session_message_handler(2, [model_2](const secs2::Message& m) { + return router_2.dispatch(m); + }); + + // Per-session SELECT state observers. These fire when each MES + // completes its Select.req handshake; independent of each other. + conn->set_session_selected_handler(1, [] { + log("MES-1 selected"); + }); + conn->set_session_selected_handler(2, [] { + log("MES-2 selected"); + }); +}); +``` + +When the equipment emits an unsolicited primary (S5F1, S6F11, +S10F1), choose the session explicitly: + +```cpp +// Alarm goes to MES-1 only. +conn->send_data(/*session_id=*/1, gem::s5f1_alarm_report(0x84, 1, "high")); + +// Event report goes to both. +auto event = gem::s6f11_event_report(0, 300, reports); +conn->send_data(1, event); +conn->send_data(2, event); +``` + +### Active-mode (host side) GS + +The host (active) connection initiates Select.req for each registered +session serially — session 1 first, then once 1 reaches SELECTED, +session 2. Customers building a multi-tool fleet controller use the +same `add_session` API on the `Client`-derived `Connection`: + +```cpp +client.on_connection([](std::shared_ptr conn) { + conn->add_session(2); // a second tool's session + conn->set_session_selected_handler(1, [] { /* tool A ready */ }); + conn->set_session_selected_handler(2, [] { /* tool B ready */ }); +}); +``` + +### Rejection semantics + +A data frame whose `session_id` field doesn't match any registered +session gets a Reject(EntityNotSelected) response, per E37 §7.7 — the +peer's MES will see this and know to back off. See +`tests/test_hsms_gs.cpp` for the wire-level coverage and +`tests/test_hsms_gs_integration.cpp` for the end-to-end Server/Client +pattern. + +--- + +## 8. Recommended layout for a vendor application ``` /opt/acme-secsgem/ @@ -564,7 +642,7 @@ EXECUTING / PAUSE / …) up to the tool builder. Copy --- -## 8. Test the integration +## 9. Test the integration Don't ship without: @@ -591,7 +669,7 @@ Don't ship without: --- -## 9. When to extend the runtime +## 10. When to extend the runtime The library is open to extension. Common reasons to add code: @@ -613,7 +691,7 @@ contribution. --- -## 10. Going from "stack" to "certified GEM tool" +## 11. Going from "stack" to "certified GEM tool" This codebase passes its own conformance harness and cross-validates against `secsgem-py`, but a real *certified* GEM tool needs more: diff --git a/tests/test_hsms_gs_integration.cpp b/tests/test_hsms_gs_integration.cpp new file mode 100644 index 0000000..575c912 --- /dev/null +++ b/tests/test_hsms_gs_integration.cpp @@ -0,0 +1,165 @@ +// HSMS-GS integration test: drives a passive equipment with TWO MES +// sessions (device_id 1 + 2) over a single TCP connection through the +// Server/Client high-level API customers actually use. Each MES gets +// its own router + selected handler + send path. +// +// This is the "two MES, one tool" pattern that the codebase advertises +// in COMPLIANCE.md §1 ("HSMS-GS (general-session) ✅") and that +// INTEGRATION.md §7 mentions. Until now there was no end-to-end test +// covering the Server/Client integration — only direct Connection +// wire tests in test_hsms_gs.cpp. + +#include + +#include +#include +#include +#include +#include + +#include "secsgem/endpoint.hpp" +#include "secsgem/gem/messages.hpp" +#include "secsgem/secs2/message.hpp" + +using namespace secsgem; +using namespace std::chrono_literals; +namespace s2 = secsgem::secs2; +namespace gem = secsgem::gem; + +namespace { + +template +void run_until(asio::io_context& io, Pred pred, + std::chrono::seconds budget = 10s) { + asio::steady_timer cap(io); + cap.expires_after(budget); + cap.async_wait([&io](std::error_code ec) { + if (!ec) io.stop(); + }); + asio::steady_timer poll(io); + std::function tick = [&](std::error_code ec) { + if (ec) return; + if (pred()) { io.stop(); return; } + poll.expires_after(1ms); + poll.async_wait(tick); + }; + poll.expires_after(1ms); + poll.async_wait(tick); + io.run(); + io.restart(); +} + +} // namespace + +TEST_CASE("HSMS-GS integration: two MES sessions on one Server, distinct dispatch") { + asio::io_context io; + + // Pick an OS-allocated port for the test. + asio::ip::tcp::acceptor probe( + io, asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), 0)); + const auto port = probe.local_endpoint().port(); + probe.close(); + + // Equipment: passive Server bound to the primary session (device_id=1) + // by construction, then add_session(2) for the second MES. Each + // session has its own router (different MDLN/SOFTREV so we can tell + // them apart on the wire). + Server::Config sc; + sc.port = port; + sc.device_id = 1; + Server server(io, sc); + + std::shared_ptr equipment_conn; + int sess1_messages = 0; + int sess2_messages = 0; + + server.on_connection([&](std::shared_ptr conn) { + equipment_conn = conn; + conn->add_session(2); + + conn->set_session_message_handler( + 1, [&sess1_messages](const s2::Message& m) + -> std::optional { + ++sess1_messages; + if (m.stream == 1 && m.function == 1) + return gem::s1f2_on_line_data("EQUIP-SESS-1", "1.0"); + return std::nullopt; + }); + + conn->set_session_message_handler( + 2, [&sess2_messages](const s2::Message& m) + -> std::optional { + ++sess2_messages; + if (m.stream == 1 && m.function == 1) + return gem::s1f2_on_line_data("EQUIP-SESS-2", "2.0"); + return std::nullopt; + }); + }); + server.start(); + + // Active host running TWO sessions over a single TCP connection. + // Mirrors the multi-MES "one tool, two factory schedulers" pattern. + Client::Config cc; + cc.host = "127.0.0.1"; + cc.port = port; + cc.device_id = 1; + cc.timers.linktest = 0ms; + Client client(io, cc); + + std::shared_ptr host_conn; + bool sess1_selected = false; + bool sess2_selected = false; + + client.on_connection([&](std::shared_ptr conn) { + host_conn = conn; + conn->add_session(2); + conn->set_session_selected_handler(1, [&] { sess1_selected = true; }); + conn->set_session_selected_handler(2, [&] { sess2_selected = true; }); + }); + client.start(); + + // Both sessions go through Select.req individually (Active mode + // walk-list: session 1 first, then session 2 once 1 is SELECTED). + run_until(io, [&] { return sess1_selected && sess2_selected; }); + REQUIRE(sess1_selected); + REQUIRE(sess2_selected); + REQUIRE(equipment_conn); + REQUIRE(host_conn); + + // Host sends S1F1 on session 1 → expects MDLN "EQUIP-SESS-1". + std::optional sess1_reply; + host_conn->send_request(1, s2::Message(1, 1, true), + [&](std::error_code ec, const s2::Message& m) { + if (!ec) sess1_reply = m; + }); + + // Host sends S1F1 on session 2 → expects MDLN "EQUIP-SESS-2". + std::optional sess2_reply; + host_conn->send_request(2, s2::Message(1, 1, true), + [&](std::error_code ec, const s2::Message& m) { + if (!ec) sess2_reply = m; + }); + + run_until(io, + [&] { return sess1_reply.has_value() && sess2_reply.has_value(); }); + + REQUIRE(sess1_reply.has_value()); + REQUIRE(sess2_reply.has_value()); + CHECK(sess1_reply->stream == 1); + CHECK(sess1_reply->function == 2); + CHECK(sess2_reply->stream == 1); + CHECK(sess2_reply->function == 2); + + // Both routers fired, each on its own session — no cross-talk. + CHECK(sess1_messages == 1); + CHECK(sess2_messages == 1); + + // The replies have different MDLNs proving the per-session + // dispatch returned distinct payloads. + const auto sml1 = sess1_reply->sml(); + const auto sml2 = sess2_reply->sml(); + CHECK(sml1.find("EQUIP-SESS-1") != std::string::npos); + CHECK(sml2.find("EQUIP-SESS-2") != std::string::npos); + + host_conn->separate(); +}