diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 9d17d09..ef53546 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -351,6 +351,20 @@ edge cases — not just the happy path. The E30 §6.5 Communication state machine is unit-tested independently of the transport (timer firings simulated via test callbacks). +Live conformance harness: **`build/secs_conformance --host --port

`** +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 diff --git a/apps/secs_conformance.cpp b/apps/secs_conformance.cpp index 28d1dae..400ecf9 100644 --- a/apps/secs_conformance.cpp +++ b/apps/secs_conformance.cpp @@ -1,6 +1,6 @@ // 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, // 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 @@ -9,7 +9,14 @@ // Run: secs_conformance --host --port 5000 --device 0 // // 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 #include @@ -20,12 +27,14 @@ #include #include #include +#include #include #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; @@ -47,43 +56,78 @@ struct Check { std::string detail; }; -std::vector checks; - void log(const std::string& m) { std::cout << "[conformance] " << m << std::endl; } -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)); +// Shared harness state. Built on the io_context heap so async steps +// can capture by raw pointer without lifetime juggling. +struct Harness { + std::vector checks; + // Every unsolicited primary the equipment sends us, in arrival order. + std::vector> 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 conn, + Harness* h, + const std::string& name, + s2::Message req, + uint8_t expected_stream, + uint8_t expected_function, + std::function 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); + }); } -// One check in the async chain: send `req` and dispatch `ok` / -// `fail` based on whether a reply matching (expected_stream, -// expected_function) arrives within T3. -void chain_check(std::shared_ptr conn, - const std::string& name, - s2::Message req, - uint8_t expected_stream, - uint8_t expected_function, - std::function next) { - conn->send_request(std::move(req), - [name, expected_stream, expected_function, next, conn] - (std::error_code ec, const s2::Message& m) { - if (ec) { - record(name, false, "no reply / " + ec.message()); - } else { - const bool ok = m.stream == expected_stream && - m.function == expected_function; - record(name, ok, - ok ? "" : "got S" + - std::to_string(m.stream) + "F" + - std::to_string(m.function)); - } - next(); - }); +// Wait `dt` then run `next` on the io_context. +void pause_then(asio::io_context& io, std::chrono::milliseconds dt, + std::function next) { + auto t = std::make_shared(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 { + using Step = std::function)>; + std::vector steps; + std::size_t i = 0; + std::function 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) { @@ -92,105 +136,255 @@ int main(int argc, char** argv) { cfg.port = static_cast(std::stoi(arg(argc, argv, "--port", "5000"))); cfg.device_id = static_cast(std::stoi(arg(argc, argv, "--device", "0"))); cfg.timers.linktest = 0ms; - cfg.timers.t3 = 5s; + 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(); + asio::steady_timer deadline(io); - deadline.expires_after(60s); + deadline.expires_after(5min); deadline.async_wait([&](std::error_code ec) { if (!ec) { - log("global 60s deadline reached, halting"); + log("global 5min deadline reached, halting"); io.stop(); } }); - client.on_connection([&](std::shared_ptr conn) { - // Reply to equipment-initiated primaries so the equipment's T3 doesn't - // fire on our watch. + client.on_connection([&io, h](std::shared_ptr 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( - [](const s2::Message& msg) -> std::optional { + [h](const s2::Message& msg) -> std::optional { + 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([&, conn] { - record("E37 §7.2 SELECT handshake", true, - "selected against " + cfg.host + ":" + std::to_string(cfg.port)); + conn->set_selected_handler([&io, h, conn] { + h->record("E37 §7.2 SELECT handshake", true, "selected"); - // The chain runs forward through async callbacks: each check's - // reply handler kicks off the next check. Final closure calls - // separate() and posts the summary. - auto finish = [&, conn] { + auto seq = std::make_shared(); + + // 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 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 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, [&] { - std::cout << "\n========================================" << std::endl; - std::cout << " conformance summary (" << cfg.host << ":" - << cfg.port << ")" << std::endl; - std::cout << "========================================" << std::endl; + asio::post(io, [&io, h] { + std::cout << "\n========================================\n"; + std::cout << " conformance summary\n"; + std::cout << "========================================\n"; int passed = 0; - for (const auto& c : checks) { + for (const auto& c : h->checks) { std::cout << (c.passed ? " [PASS] " : " [FAIL] ") << c.name; if (!c.detail.empty()) std::cout << " — " << c.detail; - std::cout << std::endl; + std::cout << "\n"; if (c.passed) ++passed; } - std::cout << "\n " << passed << " / " << checks.size() - << " checks passed" << std::endl; + std::cout << "\n " << passed << " / " << h->checks.size() + << " checks passed\n"; io.stop(); }); }; - // E30 §6.10 documentation (last in chain) - 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(); + seq->run(); }); }); @@ -200,6 +394,6 @@ int main(int argc, char** argv) { return 2; } - for (const auto& c : checks) if (!c.passed) return 1; - return checks.empty() ? 3 : 0; + for (const auto& c : h->checks) if (!c.passed) return 1; + return h->checks.empty() ? 3 : 0; }