fc3422a4a9
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>
400 lines
16 KiB
C++
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;
|
|
}
|