conformance: expand harness from 8 to 47 host-driven checks

The previous harness only exercised S1F1/F11/F13/F19, S2F17/F29, S5F5,
S7F19 — about 15% of what COMPLIANCE.md claims as .  Customers running
secs_conformance against their tool got near-zero conformance signal
on dynamic event reports, GEM 300, alarm management, exception
recovery, terminal services, spool, and PP management.

This expansion covers, in one sequential run:
- Establish comms + identification (S1F13/F1)
- Status / DVID / CEID / EC namelists + values
  (S1F11/F3/F21/F23, S2F29/F13)
- Dynamic event reports: define / link / enable + readback paths
  (S2F33/F35/F37, S6F15/F19/F21)
- All three remote-command forms (S2F41/F21/F49)
- Equipment-initiated S6F11 observation triggered by RCMD=START
- Trace init, limits attrs, spool reset + transmit
  (S2F23, S2F47, S2F43, S6F23)
- Alarm management: list, list-enabled, enable (S5F5/F7/F3)
- Exception recovery: request + abort (S5F13/F17)
- PP load-inquire / list / request (S7F1/F19/F5)
- Terminal display both directions (S10F3, S10F5)
- E40 PJ create / monitor / command / dequeue
  (S16F11/F7/F5/F13)
- E94 CJ create / command / delete (S14F9, S16F27, S14F11)
- E87 carrier action / slot map / transfer / cancel
  (S3F17/F19/F25/F27)
- E39 GetAttr (S14F1)
- GEM compliance self-report (S1F19)

Pass criterion is the spec-mandated reply function code, not any
specific ACK value — CarrierIDUnknown / Denied_UnknownObject /
PpidNotFound / Error are well-formed F-coded replies and count as
protocol-conformant.  This lets the harness run against any equipment
without preloading state.

47 / 47 PASS against the in-repo demo server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 13:54:28 +02:00
parent 06f287b415
commit a4419e15cd
2 changed files with 308 additions and 100 deletions
+14
View File
@@ -351,6 +351,20 @@ edge cases — not just the happy path.
The E30 §6.5 Communication state machine is unit-tested independently of The E30 §6.5 Communication state machine is unit-tested independently of
the transport (timer firings simulated via test callbacks). the transport (timer firings simulated via test callbacks).
Live conformance harness: **`build/secs_conformance --host <ip> --port <p>`**
walks 47 host-driven checks against a passive equipment, covering every
E30 fundamental + additional capability that COMPLIANCE.md ✅ — establish
comms, identification, status/DVID/CEID/EC namelists + values, dynamic
event reports (define/link/enable + S6F15/F19/F21 readbacks), unsolicited
S6F11 observation after RCMD, all three remote-command forms
(S2F41/F21/F49), trace init, limits, spool reset + transmit, alarms
(list/list-enabled/enable), exception recover/abort (S5F13/F17), PP
load-inquire/list/request, both terminal-display directions
(S10F3/F5), E40 PJ create/monitor/command/dequeue, E94 CJ
create/command/delete, E87 carrier action/slot-map/transfer/cancel,
and E39 GetAttr. Exits 0 with PASS/FAIL summary; intended to be run
against vendor equipment as the first-line conformance probe.
--- ---
## 7. Interoperability with secsgem-py 0.3.0 ## 7. Interoperability with secsgem-py 0.3.0
+275 -81
View File
@@ -1,6 +1,6 @@
// secs_conformance — runnable conformance harness. // secs_conformance — runnable conformance harness.
// //
// Drives a passive GEM-300 equipment through a fixed set of host-initiated // Drives a passive GEM-300 equipment through a fixed sequence of host-initiated
// checks that exercise every claimed E5/E30/E37 + GEM 300 capability, // checks that exercise every claimed E5/E30/E37 + GEM 300 capability,
// reports per-check pass/fail, and exits 0 if every check passed. Point // reports per-check pass/fail, and exits 0 if every check passed. Point
// it at any HSMS-SS equipment to validate end-to-end conformance from // it at any HSMS-SS equipment to validate end-to-end conformance from
@@ -9,7 +9,14 @@
// Run: secs_conformance --host <addr> --port 5000 --device 0 // Run: secs_conformance --host <addr> --port 5000 --device 0
// //
// The check list deliberately mirrors COMPLIANCE.md so that anything // The check list deliberately mirrors COMPLIANCE.md so that anything
// the audit claims as ✅ has a runnable assertion behind it here. // the audit claims as ✅ has a runnable assertion behind it here. The
// pass criterion is the spec-mandated reply *function code* — we don't
// require any particular ACK value, since the harness has no equipment
// state to predict. CarrierIDUnknown / Denied_UnknownObject / etc. are
// well-formed F-coded replies and count as protocol-conformant.
//
// One observation step waits for an unsolicited S6F11 after issuing an
// RCMD that the demo equipment binds to a CEID emission.
#include <asio.hpp> #include <asio.hpp>
#include <chrono> #include <chrono>
@@ -20,12 +27,14 @@
#include <optional> #include <optional>
#include <string> #include <string>
#include <system_error> #include <system_error>
#include <utility>
#include <vector> #include <vector>
#include "secsgem/endpoint.hpp" #include "secsgem/endpoint.hpp"
#include "secsgem/gem/messages.hpp" #include "secsgem/gem/messages.hpp"
#include "secsgem/gem/messages_helpers.hpp" #include "secsgem/gem/messages_helpers.hpp"
#include "secsgem/hsms/connection.hpp" #include "secsgem/hsms/connection.hpp"
#include "secsgem/secs2/codec.hpp"
#include "secsgem/secs2/message.hpp" #include "secsgem/secs2/message.hpp"
using namespace secsgem; using namespace secsgem;
@@ -47,43 +56,78 @@ struct Check {
std::string detail; std::string detail;
}; };
std::vector<Check> checks;
void log(const std::string& m) { void log(const std::string& m) {
std::cout << "[conformance] " << m << std::endl; std::cout << "[conformance] " << m << std::endl;
} }
// Shared harness state. Built on the io_context heap so async steps
// can capture by raw pointer without lifetime juggling.
struct Harness {
std::vector<Check> checks;
// Every unsolicited primary the equipment sends us, in arrival order.
std::vector<std::pair<uint8_t, uint8_t>> observed;
void record(const std::string& name, bool ok, const std::string& detail = "") { void record(const std::string& name, bool ok, const std::string& detail = "") {
checks.push_back({name, ok, detail}); checks.push_back({name, ok, detail});
log((ok ? "PASS " : "FAIL ") + name + (detail.empty() ? "" : "" + detail)); log((ok ? "PASS " : "FAIL ") + name + (detail.empty() ? "" : "" + detail));
} }
};
// One check in the async chain: send `req` and dispatch `ok` / // One sequential step: send `req`, expect a reply with (expected_stream,
// `fail` based on whether a reply matching (expected_stream, // expected_function), record pass/fail, advance.
// expected_function) arrives within T3. void request_check(asio::io_context& io,
void chain_check(std::shared_ptr<Connection> conn, std::shared_ptr<Connection> conn,
Harness* h,
const std::string& name, const std::string& name,
s2::Message req, s2::Message req,
uint8_t expected_stream, uint8_t expected_stream,
uint8_t expected_function, uint8_t expected_function,
std::function<void()> next) { std::function<void()> next) {
conn->send_request(std::move(req), conn->send_request(std::move(req),
[name, expected_stream, expected_function, next, conn] [h, name, expected_stream, expected_function, next, &io]
(std::error_code ec, const s2::Message& m) { (std::error_code ec, const s2::Message& m) {
if (ec) { if (ec) {
record(name, false, "no reply / " + ec.message()); h->record(name, false, "no reply / " + ec.message());
} else { } else {
const bool ok = m.stream == expected_stream && const bool ok = m.stream == expected_stream &&
m.function == expected_function; m.function == expected_function;
record(name, ok, h->record(name, ok,
ok ? "" : "got S" + ok ? "" : "got S" +
std::to_string(m.stream) + "F" + std::to_string(m.stream) + "F" +
std::to_string(m.function)); std::to_string(m.function));
} }
next(); asio::post(io, next);
}); });
} }
// Wait `dt` then run `next` on the io_context.
void pause_then(asio::io_context& io, std::chrono::milliseconds dt,
std::function<void()> next) {
auto t = std::make_shared<asio::steady_timer>(io);
t->expires_after(dt);
t->async_wait([t, next](std::error_code) { next(); });
}
// Sequential runner that walks a vector of steps to completion.
struct Sequence : std::enable_shared_from_this<Sequence> {
using Step = std::function<void(std::function<void()>)>;
std::vector<Step> steps;
std::size_t i = 0;
std::function<void()> finish;
void run() {
if (i >= steps.size()) {
if (finish) finish();
return;
}
auto self = shared_from_this();
steps[i]([self] {
++self->i;
self->run();
});
}
};
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
@@ -92,105 +136,255 @@ int main(int argc, char** argv) {
cfg.port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000"))); 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.device_id = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--device", "0")));
cfg.timers.linktest = 0ms; cfg.timers.linktest = 0ms;
cfg.timers.t3 = 5s; cfg.timers.t3 = 3s;
asio::io_context io; asio::io_context io;
Client client(io, cfg); Client client(io, cfg);
client.on_log([](const std::string& m) { log("hsms: " + m); }); client.on_log([](const std::string& m) { log("hsms: " + m); });
auto h = std::make_shared<Harness>();
asio::steady_timer deadline(io); asio::steady_timer deadline(io);
deadline.expires_after(60s); deadline.expires_after(5min);
deadline.async_wait([&](std::error_code ec) { deadline.async_wait([&](std::error_code ec) {
if (!ec) { if (!ec) {
log("global 60s deadline reached, halting"); log("global 5min deadline reached, halting");
io.stop(); io.stop();
} }
}); });
client.on_connection([&](std::shared_ptr<Connection> conn) { client.on_connection([&io, h](std::shared_ptr<Connection> conn) {
// Reply to equipment-initiated primaries so the equipment's T3 doesn't // Capture every equipment-initiated primary into the mailbox and
// fire on our watch. // reply to the ones with W=1 so the equipment's T3 doesn't fire on
// our watch. S16F9 (E40 PRJobAlert) is W=0 — no reply.
conn->set_message_handler( conn->set_message_handler(
[](const s2::Message& msg) -> std::optional<s2::Message> { [h](const s2::Message& msg) -> std::optional<s2::Message> {
h->observed.push_back({msg.stream, msg.function});
if (msg.stream == 1 && msg.function == 13) { if (msg.stream == 1 && msg.function == 13) {
return gem::s1f14_establish_comms_ack( return gem::s1f14_establish_comms_ack(
gem::CommAck::Accept, {"CONFORMANCE", "1.0"}); gem::CommAck::Accept, {"CONFORMANCE", "1.0"});
} }
if (msg.stream == 5 && msg.function == 1) {
return gem::s5f2_alarm_ack(gem::AlarmAck::Accept);
}
if (msg.stream == 6 && msg.function == 11) {
return gem::s6f12_event_report_ack(gem::EventReportAck::Accept);
}
if (msg.stream == 6 && msg.function == 25) {
return gem::s6f26_spool_data_ready_ack(gem::EventReportAck::Accept);
}
if (msg.stream == 16 && msg.function == 9) {
return std::nullopt; // W=0 primary
}
if (msg.reply_expected) return s2::Message(msg.stream, 0, false);
return std::nullopt; return std::nullopt;
}); });
conn->set_selected_handler([&, conn] { conn->set_selected_handler([&io, h, conn] {
record("E37 §7.2 SELECT handshake", true, h->record("E37 §7.2 SELECT handshake", true, "selected");
"selected against " + cfg.host + ":" + std::to_string(cfg.port));
// The chain runs forward through async callbacks: each check's auto seq = std::make_shared<Sequence>();
// reply handler kicks off the next check. Final closure calls
// separate() and posts the summary. // Helper closure that pushes a request/reply step.
auto finish = [&, conn] { auto add_req = [&io, h, conn, seq](const std::string& name,
s2::Message req,
uint8_t es, uint8_t ef) {
seq->steps.push_back([&io, h, conn, name, req = std::move(req), es, ef]
(std::function<void()> next) mutable {
request_check(io, conn, h.get(), name, std::move(req), es, ef,
std::move(next));
});
};
// === E30 §6.5 / §6.7 — establish comms + identification ===
add_req("E30 §6.5 S1F13/F14 Establish Comms",
gem::s1f13_establish_comms("HOST", "1.0"), 1, 14);
add_req("E30 §6.7 S1F1/F2 Are You There",
s2::Message(1, 1, true), 1, 2);
// === E30 §6.13 — status data collection ===
add_req("E30 §6.13 S1F11/F12 SVID Namelist",
gem::s1f11_status_namelist_request({}), 1, 12);
add_req("E30 §6.13 S1F3/F4 SVID Values",
gem::s1f3_selected_status_request({}), 1, 4);
// === E30 §6.11 / §6.10 — DVID + CEID namelists ===
add_req("E30 §6.11 S1F21/F22 DVID Namelist",
gem::s1f21_data_variable_namelist_request({}), 1, 22);
add_req("E30 §6.10 S1F23/F24 CEID Namelist",
gem::s1f23_collection_event_namelist_request({}), 1, 24);
// === E30 §6.16 — equipment constants ===
add_req("E30 §6.16 S2F29/F30 ECID Namelist",
gem::s2f29_ec_namelist_request({}), 2, 30);
add_req("E30 §6.16 S2F13/F14 EC Values",
gem::s2f13_ec_request({}), 2, 14);
// === E30 §6.20 — clock ===
add_req("E30 §6.20 S2F17/F18 Clock",
s2::Message(2, 17, true), 2, 18);
// === E30 §6.6 — dynamic event reports ===
constexpr uint32_t kDataId = 9001;
constexpr uint32_t kRptid = 9000;
constexpr uint32_t kCeidStart = 300; // ProcessStarted, from equipment.yaml
add_req("E30 §6.6 S2F33/F34 Define Report",
gem::s2f33_define_report(kDataId, {{kRptid, {1}}}), 2, 34);
add_req("E30 §6.6 S2F35/F36 Link Event Report",
gem::s2f35_link_event_report(kDataId, {{kCeidStart, {kRptid}}}),
2, 36);
add_req("E30 §6.6 S2F37/F38 Enable Event",
gem::s2f37_enable_event(true, {kCeidStart}), 2, 38);
// === E30 §6.15 — remote control ===
add_req("E30 §6.15 S2F41/F42 Host Command",
gem::s2f41_host_command("START", {}), 2, 42);
// Observe the unsolicited S6F11 that START triggers. Pause 400ms
// to give the equipment's async post a chance to land, then walk
// the mailbox.
seq->steps.push_back([&io, h](std::function<void()> next) {
pause_then(io, 400ms, [h, next] {
const bool seen = std::any_of(
h->observed.begin(), h->observed.end(),
[](const auto& p) { return p.first == 6 && p.second == 11; });
h->record("E30 §6.6 S6F11 Unsolicited Event Report (after START)",
seen, seen ? "" : "no S6F11 observed within 400ms");
next();
});
});
add_req("E30 §6.15 S2F21/F22 Legacy Remote Command",
gem::s2f21_remote_command("STOP"), 2, 22);
add_req("E30 §6.15 S2F49/F50 Enhanced Remote Command",
gem::s2f49_enhanced_host_command(0u, "EQUIPMENT", "NOP", {}),
2, 50);
// === E30 §6.12 — trace data collection ===
add_req("E30 §6.12 S2F23/F24 Trace Initialize",
gem::s2f23_trace_initialize_send(8001, "000001", 0, 1, {1}),
2, 24);
// === E30 §6.21 — limits monitoring (read-only) ===
add_req("E30 §6.21 S2F47/F48 Limit Attributes Request",
gem::s2f47_variable_limit_attribute_request({}), 2, 48);
// === E30 §6.22 — spooling ===
add_req("E30 §6.22 S2F43/F44 Reset Spooling",
gem::s2f43_reset_spooling({5, 6}), 2, 44);
add_req("E30 §6.22 S6F23/F24 Request Spool Data",
gem::s6f23_request_spool_data(gem::SpoolRequestCode::Transmit),
6, 24);
// === E30 §6.14 — alarm management ===
add_req("E30 §6.14 S5F5/F6 List Alarms",
gem::s5f5_list_alarms_request({}), 5, 6);
add_req("E30 §6.14 S5F7/F8 List Enabled Alarms",
s2::Message(5, 7, true), 5, 8);
add_req("E30 §6.14 S5F3/F4 Enable Alarm",
gem::s5f3_enable_alarm(gem::kAlarmEnableByte, 1), 5, 4);
// === E5 §13 — exception recovery (well-formed reply even if EXID
// unknown) ===
add_req("E5 §13 S5F13/F14 Exception Recover Request",
gem::s5f13_exception_recover_request(999, "NOP"), 5, 14);
add_req("E5 §13 S5F17/F18 Exception Recover Abort",
gem::s5f17_exception_recover_abort_request(999), 5, 18);
// === E30 §6.6 — host-initiated event/report read-back ===
add_req("E30 §6.6 S6F15/F16 Event Report Request",
gem::s6f15_event_report_request(kCeidStart), 6, 16);
add_req("E30 §6.6 S6F19/F20 Individual Report Request",
gem::s6f19_individual_report_request(kRptid), 6, 20);
add_req("E30 §6.6 S6F21/F22 Annotated Report Request",
gem::s6f21_annotated_report_request(kRptid), 6, 22);
// === E30 §6.17 — process program management ===
add_req("E30 §6.17 S7F19/F20 PP List",
s2::Message(7, 19, true), 7, 20);
add_req("E30 §6.17 S7F5/F6 PP Request",
gem::s7f5_process_program_request("RECIPE-A"), 7, 6);
add_req("E30 §6.17 S7F1/F2 PP Load Inquire",
gem::s7f1_pp_load_inquire("RECIPE-X", 64u), 7, 2);
// === E30 §6.19 — equipment terminal services ===
add_req("E30 §6.19 S10F3/F4 Terminal Display Single",
gem::s10f3_terminal_display_single(0, "conformance probe"),
10, 4);
add_req("E30 §6.19 S10F5/F6 Terminal Display Multi",
gem::s10f5_terminal_display_multi(0, {"line 1", "line 2"}),
10, 6);
// === E40 — process jobs ===
const std::string kConfPj = "PJ-CONF-1";
gem::PRJobCreateRequest pj_req{
kConfPj, gem::MaterialFlag::Substrate,
gem::ProcessRecipeMethod::RecipeOnly,
gem::RecipeSpec{"RECIPE-A", {}},
{"WFR-C-1"}, {}};
add_req("E40 S16F11/F12 PRJobCreate",
gem::s16f11_pr_job_create(pj_req), 16, 12);
add_req("E40 S16F7/F8 PRJobMonitor",
gem::s16f7_pr_job_monitor({{kConfPj, gem::kAlarmEnableByte}}),
16, 8);
// === E94 — control jobs ===
const std::string kConfCj = "CJ-CONF-1";
add_req("E94 S14F9/F10 CreateControlJob",
gem::s14f9_create_control_job(kConfCj, {kConfPj}), 14, 10);
add_req("E94 S16F27/F28 CJobCommand (CJSTOP)",
gem::s16f27_cj_command(kConfCj, "CJSTOP"), 16, 28);
add_req("E94 S14F11/F12 DeleteControlJob",
gem::s14f11_delete_control_job(kConfCj), 14, 12);
add_req("E40 S16F5/F6 PRJobCommand (PJABORT)",
gem::s16f5_pr_job_command(kConfPj, "PJABORT"), 16, 6);
add_req("E40 S16F13/F14 PRJobDequeue",
gem::s16f13_pr_job_dequeue(kConfPj), 16, 14);
// === E87 — carrier management (well-formed reply even when the
// carrier id is unknown to the equipment) ===
add_req("E87 S3F17/F18 CarrierAction",
gem::s3f17_carrier_action(0u, "ProceedWithCarrier",
"CAR-CONF-1", {}),
3, 18);
add_req("E87 S3F19/F20 Slot Map Verify",
gem::s3f19_slot_map_verify("CAR-CONF-1", {}), 3, 20);
add_req("E87 S3F25/F26 Carrier Transfer",
gem::s3f25_carrier_transfer("CAR-CONF-1", 1, 2), 3, 26);
add_req("E87 S3F27/F28 Cancel Carrier",
gem::s3f27_cancel_carrier("CAR-CONF-1"), 3, 28);
// === E39 — generic object service (Denied_UnknownObject is a
// well-formed F-coded reply) ===
add_req("E39 S14F1/F2 GetAttr",
gem::s14f1_get_attr("CONF-OBJ", "EQUIPMENT", {}),
14, 2);
// === E30 §6.10 — documentation (last so failures earlier are
// visible against the equipment's self-report) ===
add_req("E30 §6.10 S1F19/F20 GEM Compliance",
gem::s1f19_get_gem_compliance_request(), 1, 20);
// Cleanup + summary.
seq->finish = [&io, h, conn] {
log("all checks done; separating"); log("all checks done; separating");
conn->separate(); conn->separate();
asio::post(io, [&] { asio::post(io, [&io, h] {
std::cout << "\n========================================" << std::endl; std::cout << "\n========================================\n";
std::cout << " conformance summary (" << cfg.host << ":" std::cout << " conformance summary\n";
<< cfg.port << ")" << std::endl; std::cout << "========================================\n";
std::cout << "========================================" << std::endl;
int passed = 0; int passed = 0;
for (const auto& c : checks) { for (const auto& c : h->checks) {
std::cout << (c.passed ? " [PASS] " : " [FAIL] ") << c.name; std::cout << (c.passed ? " [PASS] " : " [FAIL] ") << c.name;
if (!c.detail.empty()) std::cout << "" << c.detail; if (!c.detail.empty()) std::cout << "" << c.detail;
std::cout << std::endl; std::cout << "\n";
if (c.passed) ++passed; if (c.passed) ++passed;
} }
std::cout << "\n " << passed << " / " << checks.size() std::cout << "\n " << passed << " / " << h->checks.size()
<< " checks passed" << std::endl; << " checks passed\n";
io.stop(); io.stop();
}); });
}; };
// E30 §6.10 documentation (last in chain) seq->run();
auto step8 = [conn, finish] {
chain_check(conn, "E30 §6.10 S1F19/F20 GEM Compliance",
s2::Message(1, 19, true), 1, 20, finish);
};
// E30 §6.17 PP list
auto step7 = [conn, step8] {
chain_check(conn, "E30 §6.17 S7F19/F20 PP List",
s2::Message(7, 19, true), 7, 20, step8);
};
// E30 §6.14 alarms
auto step6 = [conn, step7] {
chain_check(conn, "E30 §6.14 S5F5/F6 List Alarms",
s2::Message(5, 5, true, s2::Item::list({})),
5, 6, step7);
};
// E30 §6.20 clock
auto step5 = [conn, step6] {
chain_check(conn, "E30 §6.20 S2F17/F18 Clock",
s2::Message(2, 17, true), 2, 18, step6);
};
// E30 §6.16 ECID namelist
auto step4 = [conn, step5] {
chain_check(conn, "E30 §6.16 S2F29/F30 ECID Namelist",
s2::Message(2, 29, true, s2::Item::list({})),
2, 30, step5);
};
// E30 §6.13 SVID namelist
auto step3 = [conn, step4] {
chain_check(conn, "E30 §6.13 S1F11/F12 SVID Namelist",
s2::Message(1, 11, true, s2::Item::list({})),
1, 12, step4);
};
// E30 §6.7 S1F1/F2
auto step2 = [conn, step3] {
chain_check(conn, "E30 §6.7 S1F1/F2 Are You There",
s2::Message(1, 1, true), 1, 2, step3);
};
// E30 §6.5 establish comms
auto step1 = [conn, step2] {
chain_check(conn, "E30 §6.5 S1F13/F14 Establish Comms",
gem::s1f13_establish_comms("HOST", "1.0"), 1, 14, step2);
};
step1();
}); });
}); });
@@ -200,6 +394,6 @@ int main(int argc, char** argv) {
return 2; return 2;
} }
for (const auto& c : checks) if (!c.passed) return 1; for (const auto& c : h->checks) if (!c.passed) return 1;
return checks.empty() ? 3 : 0; return h->checks.empty() ? 3 : 0;
} }