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:
@@ -80,6 +80,9 @@ target_link_libraries(secs_server PRIVATE secsgem)
|
|||||||
add_executable(secs_client apps/secs_client.cpp)
|
add_executable(secs_client apps/secs_client.cpp)
|
||||||
target_link_libraries(secs_client PRIVATE secsgem)
|
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 ----------------------------------------------------------------
|
# --- Tests ----------------------------------------------------------------
|
||||||
enable_testing()
|
enable_testing()
|
||||||
include(FetchContent)
|
include(FetchContent)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -450,6 +450,28 @@ int main(int argc, char** argv) {
|
|||||||
logfn("S1F21 -> S1F22 (" + std::to_string(rows.size()) + " DVIDs)");
|
logfn("S1F21 -> S1F22 (" + std::to_string(rows.size()) + " DVIDs)");
|
||||||
return gem::s1f22_data_variable_namelist_data(rows);
|
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<gem::CollectionEventName> 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<s2::Message> {
|
router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional<s2::Message> {
|
||||||
auto ids = gem::parse_u4_list_body(msg);
|
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);
|
if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text);
|
||||||
return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted);
|
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) {
|
router.on(10, 5, [logfn](const s2::Message& msg) {
|
||||||
auto td = gem::parse_s10f5(msg);
|
auto td = gem::parse_s10f5(msg);
|
||||||
if (td) {
|
if (td) {
|
||||||
|
|||||||
+34
-2
@@ -191,6 +191,38 @@ messages:
|
|||||||
- {name: name, shape: {kind: scalar, item_type: ASCII}}
|
- {name: name, shape: {kind: scalar, item_type: ASCII}}
|
||||||
- {name: units, 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
|
# S2 — Equipment control + reports
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -920,7 +952,7 @@ messages:
|
|||||||
struct_name: ProcessProgram
|
struct_name: ProcessProgram
|
||||||
fields:
|
fields:
|
||||||
- {name: ppid, shape: {kind: scalar, item_type: ASCII}}
|
- {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
|
- id: S7F4
|
||||||
stream: 7
|
stream: 7
|
||||||
@@ -946,7 +978,7 @@ messages:
|
|||||||
struct_name: ProcessProgram
|
struct_name: ProcessProgram
|
||||||
fields:
|
fields:
|
||||||
- {name: ppid, shape: {kind: scalar, item_type: ASCII}}
|
- {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
|
- id: S7F19
|
||||||
stream: 7
|
stream: 7
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ x-base: &base
|
|||||||
services:
|
services:
|
||||||
builder:
|
builder:
|
||||||
<<: *base
|
<<: *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:
|
command:
|
||||||
- bash
|
- bash
|
||||||
- -lc
|
- -lc
|
||||||
@@ -51,6 +55,26 @@ services:
|
|||||||
command: ["/app/build/secs_client", "--host", "server", "--port", "5000", "--device", "0"]
|
command: ["/app/build/secs_client", "--host", "server", "--port", "5000", "--device", "0"]
|
||||||
networks: [secs]
|
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:
|
networks:
|
||||||
secs: {}
|
secs: {}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <limits>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <variant>
|
#include <variant>
|
||||||
@@ -34,6 +35,22 @@ inline std::optional<std::string> as_binary_string(const s2::Item& item) {
|
|||||||
return std::string(v.begin(), v.end());
|
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<std::string> 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<uint8_t> as_binary_first(const s2::Item& item) {
|
inline std::optional<uint8_t> as_binary_first(const s2::Item& item) {
|
||||||
if (item.format() != s2::Format::Binary) return std::nullopt;
|
if (item.format() != s2::Format::Binary) return std::nullopt;
|
||||||
const auto& v = item.as_bytes();
|
const auto& v = item.as_bytes();
|
||||||
@@ -58,14 +75,96 @@ inline std::optional<typename Vec::value_type> first_or_none(const s2::Item& ite
|
|||||||
return v.front();
|
return v.front();
|
||||||
}
|
}
|
||||||
|
|
||||||
inline std::optional<uint8_t> as_u1_scalar(const s2::Item& i) { return first_or_none<std::vector<uint8_t>>(i, s2::Format::U1); }
|
// SEMI E5 declares most identifier fields (DATAID, RPTID, CEID, VID,
|
||||||
inline std::optional<uint16_t> as_u2_scalar(const s2::Item& i) { return first_or_none<std::vector<uint16_t>>(i, s2::Format::U2); }
|
// ALID, EXID, …) with FORMATCODE = "U1 | U2 | U4 | U8" — meaning a peer
|
||||||
inline std::optional<uint32_t> as_u4_scalar(const s2::Item& i) { return first_or_none<std::vector<uint32_t>>(i, s2::Format::U4); }
|
// is free to encode them as any unsigned width that fits. secsgem-py,
|
||||||
inline std::optional<uint64_t> as_u8_scalar(const s2::Item& i) { return first_or_none<std::vector<uint64_t>>(i, s2::Format::U8); }
|
// for example, picks the smallest type that holds the value (so an
|
||||||
inline std::optional<int8_t> as_i1_scalar(const s2::Item& i) { return first_or_none<std::vector<int8_t>>(i, s2::Format::I1); }
|
// ALID of 1 goes out as U1). We therefore accept any unsigned width
|
||||||
inline std::optional<int16_t> as_i2_scalar(const s2::Item& i) { return first_or_none<std::vector<int16_t>>(i, s2::Format::I2); }
|
// in the as_uN_scalar helpers, range-checking the downcast. Same logic
|
||||||
inline std::optional<int32_t> as_i4_scalar(const s2::Item& i) { return first_or_none<std::vector<int32_t>>(i, s2::Format::I4); }
|
// for the signed I-types. Strictly typed fields (Binary, Boolean,
|
||||||
inline std::optional<int64_t> as_i8_scalar(const s2::Item& i) { return first_or_none<std::vector<int64_t>>(i, s2::Format::I8); }
|
// ASCII, F4/F8) stay strict.
|
||||||
|
template <typename Out>
|
||||||
|
inline std::optional<Out> any_unsigned_first(const s2::Item& item) {
|
||||||
|
auto take = [](auto width) -> std::optional<Out> {
|
||||||
|
using W = decltype(width);
|
||||||
|
if constexpr (sizeof(W) == 0) return std::nullopt; // unreachable
|
||||||
|
else {
|
||||||
|
if (static_cast<uint64_t>(width) > static_cast<uint64_t>(std::numeric_limits<Out>::max()))
|
||||||
|
return std::nullopt;
|
||||||
|
return static_cast<Out>(width);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
switch (item.format()) {
|
||||||
|
case s2::Format::U1: {
|
||||||
|
const auto& v = std::get<std::vector<uint8_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
case s2::Format::U2: {
|
||||||
|
const auto& v = std::get<std::vector<uint16_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
case s2::Format::U4: {
|
||||||
|
const auto& v = std::get<std::vector<uint32_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
case s2::Format::U8: {
|
||||||
|
const auto& v = std::get<std::vector<uint64_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
default: return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Out>
|
||||||
|
inline std::optional<Out> any_signed_first(const s2::Item& item) {
|
||||||
|
auto take = [](auto width) -> std::optional<Out> {
|
||||||
|
using W = decltype(width);
|
||||||
|
if constexpr (sizeof(W) == 0) return std::nullopt;
|
||||||
|
else {
|
||||||
|
const int64_t w = static_cast<int64_t>(width);
|
||||||
|
if (w < static_cast<int64_t>(std::numeric_limits<Out>::min()) ||
|
||||||
|
w > static_cast<int64_t>(std::numeric_limits<Out>::max()))
|
||||||
|
return std::nullopt;
|
||||||
|
return static_cast<Out>(w);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
switch (item.format()) {
|
||||||
|
case s2::Format::I1: {
|
||||||
|
const auto& v = std::get<std::vector<int8_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
case s2::Format::I2: {
|
||||||
|
const auto& v = std::get<std::vector<int16_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
case s2::Format::I4: {
|
||||||
|
const auto& v = std::get<std::vector<int32_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
case s2::Format::I8: {
|
||||||
|
const auto& v = std::get<std::vector<int64_t>>(item.storage());
|
||||||
|
if (v.empty()) return std::nullopt;
|
||||||
|
return take(v.front());
|
||||||
|
}
|
||||||
|
default: return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::optional<uint8_t> as_u1_scalar(const s2::Item& i) { return any_unsigned_first<uint8_t>(i); }
|
||||||
|
inline std::optional<uint16_t> as_u2_scalar(const s2::Item& i) { return any_unsigned_first<uint16_t>(i); }
|
||||||
|
inline std::optional<uint32_t> as_u4_scalar(const s2::Item& i) { return any_unsigned_first<uint32_t>(i); }
|
||||||
|
inline std::optional<uint64_t> as_u8_scalar(const s2::Item& i) { return any_unsigned_first<uint64_t>(i); }
|
||||||
|
inline std::optional<int8_t> as_i1_scalar(const s2::Item& i) { return any_signed_first<int8_t>(i); }
|
||||||
|
inline std::optional<int16_t> as_i2_scalar(const s2::Item& i) { return any_signed_first<int16_t>(i); }
|
||||||
|
inline std::optional<int32_t> as_i4_scalar(const s2::Item& i) { return any_signed_first<int32_t>(i); }
|
||||||
|
inline std::optional<int64_t> as_i8_scalar(const s2::Item& i) { return any_signed_first<int64_t>(i); }
|
||||||
inline std::optional<float> as_f4_scalar(const s2::Item& i) { return first_or_none<std::vector<float>>(i, s2::Format::F4); }
|
inline std::optional<float> as_f4_scalar(const s2::Item& i) { return first_or_none<std::vector<float>>(i, s2::Format::F4); }
|
||||||
inline std::optional<double> as_f8_scalar(const s2::Item& i) { return first_or_none<std::vector<double>>(i, s2::Format::F8); }
|
inline std::optional<double> as_f8_scalar(const s2::Item& i) { return first_or_none<std::vector<double>>(i, s2::Format::F8); }
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ class EventReportSubscriptions {
|
|||||||
by_ceid_.insert_or_assign(ce.id, std::move(ce));
|
by_ceid_.insert_or_assign(ce.id, std::move(ce));
|
||||||
}
|
}
|
||||||
bool has_event(uint32_t ceid) const { return by_ceid_.count(ceid) > 0; }
|
bool has_event(uint32_t ceid) const { return by_ceid_.count(ceid) > 0; }
|
||||||
|
std::optional<CollectionEvent> 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<CollectionEvent> all_events() const {
|
std::vector<CollectionEvent> all_events() const {
|
||||||
std::vector<CollectionEvent> out;
|
std::vector<CollectionEvent> out;
|
||||||
out.reserve(by_ceid_.size());
|
out.reserve(by_ceid_.size());
|
||||||
@@ -154,6 +159,28 @@ class EventReportSubscriptions {
|
|||||||
|
|
||||||
bool is_enabled(uint32_t ceid) const { return enabled_.count(ceid) > 0; }
|
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<uint32_t> vids_for(uint32_t ceid) const {
|
||||||
|
std::vector<uint32_t> out;
|
||||||
|
auto it = links_.find(ceid);
|
||||||
|
if (it == links_.end()) return out;
|
||||||
|
std::set<uint32_t> 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 --------------------------------------------------
|
// --- S6F11 emission --------------------------------------------------
|
||||||
std::vector<ReportData> compose_for(uint32_t ceid, const VidLookup& lookup) const {
|
std::vector<ReportData> compose_for(uint32_t ceid, const VidLookup& lookup) const {
|
||||||
std::vector<ReportData> out;
|
std::vector<ReportData> out;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Run secsgem-py as passive equipment so a C++ active host can drive it.
|
||||||
|
|
||||||
|
Listens on 0.0.0.0:<port>, 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())
|
||||||
@@ -49,6 +49,12 @@ SCALAR_TABLE = {
|
|||||||
"as_binary_first({i})"),
|
"as_binary_first({i})"),
|
||||||
"BINARY": ("std::string", "secs2::Item::binary(std::vector<uint8_t>({v}.begin(), {v}.end()))",
|
"BINARY": ("std::string", "secs2::Item::binary(std::vector<uint8_t>({v}.begin(), {v}.end()))",
|
||||||
"as_binary_string({i})"),
|
"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<uint8_t>({v}.begin(), {v}.end()))",
|
||||||
|
"as_text_or_binary({i})"),
|
||||||
"BOOLEAN": ("bool", "secs2::Item::boolean({v})",
|
"BOOLEAN": ("bool", "secs2::Item::boolean({v})",
|
||||||
"as_boolean({i})"),
|
"as_boolean({i})"),
|
||||||
"ITEM": ("secs2::Item", "{v}",
|
"ITEM": ("secs2::Item", "{v}",
|
||||||
|
|||||||
Reference in New Issue
Block a user