Files
secs-gem/apps/secs_conformance.cpp
T
raphael fc3422a4a9 docs: move root .md files into docs/ + update every reference
Picks up the file renames that landed alongside the previous commit
and fixes everything that pointed at the old root locations:

- README.md doc-map updated: every entry now points at docs/X.md,
  with a new "docs/" lead entry pointing at the guided-tour index.
- README inline cross-refs (ARCHITECTURE / INTEGRATION / SECURITY /
  BENCHMARKS / MES_INTEROP / PROOFS) repointed to docs/.
- README "Interop" section rewritten — used to mention only
  secsgem-py; now covers all four external validators (secsgem-py
  31 / secs4java8 55 / tshark 69 frames / libFuzzer 200 k+ runs)
  with a one-line summary each, plus pointers to interop/README.md
  and docs/VERIFICATION.md.
- README "Deferred follow-ups" cleaned: dropped the explanatory
  "Listed here so reviewers don't go looking for them in
  COMPLIANCE.md and find an 'out of scope' entry that sounds
  defensive" sentence — the section header speaks for itself.
- docs/00_index.md "Where the rest of the docs live" table: dropped
  every `../` prefix since the docs are now siblings.
- docs/01_what_is_secs_gem.md PROOFS reference updated to sibling.
- docs/02_the_cast.md INTEGRATION + MES_INTEROP refs updated to
  siblings; dropped the stale "at the repo root" wording.
- interop/README.md: VERIFICATION + PROOFS refs updated to
  ../docs/X.md; stale "~24 + 4 checks" updated to 31 (matches
  PROOFS.md and README).
- examples/pvd_tool/README.md: every doc cross-ref now points at
  ../../docs/X.md.
- Source / data / CI comments mentioning doc names (e.g.
  "INTEGRATION.md §3", "COMPLIANCE.md gap") rewritten to
  "docs/INTEGRATION.md §3" etc. — affects 9 files across
  include/, apps/, tests/, data/, examples/, .gitea/workflows/.

Verified: full build under docker passes, 445/445 test cases pass,
2 753/2 753 assertions pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 19:36:27 +02:00

400 lines
16 KiB
C++

// secs_conformance — runnable conformance harness.
//
// Drives a passive GEM-300 equipment through a fixed sequence of host-initiated
// checks that exercise every claimed E5/E30/E37 + GEM 300 capability,
// 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
// outside the codebase.
//
// Run: secs_conformance --host <addr> --port 5000 --device 0
//
// The check list deliberately mirrors docs/COMPLIANCE.md so that anything
// 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 <chrono>
#include <cstdint>
#include <functional>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
#include "secsgem/endpoint.hpp"
#include "secsgem/gem/messages.hpp"
#include "secsgem/gem/messages_helpers.hpp"
#include "secsgem/hsms/connection.hpp"
#include "secsgem/secs2/codec.hpp"
#include "secsgem/secs2/message.hpp"
using namespace secsgem;
namespace s2 = secsgem::secs2;
namespace gem = secsgem::gem;
using namespace std::chrono_literals;
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;
}
struct Check {
std::string name;
bool passed = false;
std::string detail;
};
void log(const std::string& m) {
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 = "") {
checks.push_back({name, ok, detail});
log((ok ? "PASS " : "FAIL ") + name + (detail.empty() ? "" : " — " + detail));
}
};
// One sequential step: send `req`, expect a reply with (expected_stream,
// expected_function), record pass/fail, advance.
void request_check(asio::io_context& io,
std::shared_ptr<Connection> conn,
Harness* h,
const std::string& name,
s2::Message req,
uint8_t expected_stream,
uint8_t expected_function,
std::function<void()> next) {
conn->send_request(std::move(req),
[h, name, expected_stream, expected_function, next, &io]
(std::error_code ec, const s2::Message& m) {
if (ec) {
h->record(name, false, "no reply / " + ec.message());
} else {
const bool ok = m.stream == expected_stream &&
m.function == expected_function;
h->record(name, ok,
ok ? "" : "got S" +
std::to_string(m.stream) + "F" +
std::to_string(m.function));
}
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
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 = 0ms;
cfg.timers.t3 = 3s;
asio::io_context io;
Client client(io, cfg);
client.on_log([](const std::string& m) { log("hsms: " + m); });
auto h = std::make_shared<Harness>();
asio::steady_timer deadline(io);
deadline.expires_after(5min);
deadline.async_wait([&](std::error_code ec) {
if (!ec) {
log("global 5min deadline reached, halting");
io.stop();
}
});
client.on_connection([&io, h](std::shared_ptr<Connection> conn) {
// Capture every equipment-initiated primary into the mailbox and
// 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(
[h](const s2::Message& msg) -> std::optional<s2::Message> {
h->observed.push_back({msg.stream, msg.function});
if (msg.stream == 1 && msg.function == 13) {
return gem::s1f14_establish_comms_ack(
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;
});
conn->set_selected_handler([&io, h, conn] {
h->record("E37 §7.2 SELECT handshake", true, "selected");
auto seq = std::make_shared<Sequence>();
// Helper closure that pushes a request/reply step.
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");
conn->separate();
asio::post(io, [&io, h] {
std::cout << "\n========================================\n";
std::cout << " conformance summary\n";
std::cout << "========================================\n";
int passed = 0;
for (const auto& c : h->checks) {
std::cout << (c.passed ? " [PASS] " : " [FAIL] ") << c.name;
if (!c.detail.empty()) std::cout << " — " << c.detail;
std::cout << "\n";
if (c.passed) ++passed;
}
std::cout << "\n " << passed << " / " << h->checks.size()
<< " checks passed\n";
io.stop();
});
};
seq->run();
});
});
client.start();
try { io.run(); } catch (const std::exception& e) {
std::cerr << "conformance: " << e.what() << std::endl;
return 2;
}
for (const auto& c : h->checks) if (!c.passed) return 1;
return h->checks.empty() ? 3 : 0;
}