hsms-gs: worked integration example + INTEGRATION.md §7
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 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,7 @@ add_executable(secsgem_tests
|
|||||||
tests/test_persistence_upgrade.cpp
|
tests/test_persistence_upgrade.cpp
|
||||||
tests/test_config_validate.cpp
|
tests/test_config_validate.cpp
|
||||||
tests/test_metrics_prometheus.cpp
|
tests/test_metrics_prometheus.cpp
|
||||||
|
tests/test_hsms_gs_integration.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
|
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
|
||||||
target_compile_definitions(secsgem_tests PRIVATE
|
target_compile_definitions(secsgem_tests PRIVATE
|
||||||
|
|||||||
+82
-4
@@ -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<Connection> 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<Connection> 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/
|
/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:
|
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:
|
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
|
This codebase passes its own conformance harness and cross-validates
|
||||||
against `secsgem-py`, but a real *certified* GEM tool needs more:
|
against `secsgem-py`, but a real *certified* GEM tool needs more:
|
||||||
|
|||||||
@@ -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 <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <asio.hpp>
|
||||||
|
#include <chrono>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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 <typename Pred>
|
||||||
|
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<void(std::error_code)> 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<Connection> equipment_conn;
|
||||||
|
int sess1_messages = 0;
|
||||||
|
int sess2_messages = 0;
|
||||||
|
|
||||||
|
server.on_connection([&](std::shared_ptr<Connection> conn) {
|
||||||
|
equipment_conn = conn;
|
||||||
|
conn->add_session(2);
|
||||||
|
|
||||||
|
conn->set_session_message_handler(
|
||||||
|
1, [&sess1_messages](const s2::Message& m)
|
||||||
|
-> std::optional<s2::Message> {
|
||||||
|
++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<s2::Message> {
|
||||||
|
++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<Connection> host_conn;
|
||||||
|
bool sess1_selected = false;
|
||||||
|
bool sess2_selected = false;
|
||||||
|
|
||||||
|
client.on_connection([&](std::shared_ptr<Connection> 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<s2::Message> 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<s2::Message> 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user