conformance: standalone secs_conformance harness binary

The closest thing to an in-repo "RTS" — a runnable executable that
points at any HSMS-SS equipment and walks through every E30
fundamental + additional capability, reporting pass/fail per check
and exiting with the right code for CI / canary use.

  build/secs_conformance --host <ip> --port 5000 --device 0

Each check sends a host-initiated primary and asserts the equipment
replies with the expected stream/function within T3.  Checks chain
forward through async callbacks (each reply handler kicks off the
next check) so the conformance run stays inside one io.run().

Initial check set (mirrors COMPLIANCE.md §3 fundamentals):
  E37 §7.2  SELECT handshake
  E30 §6.5  S1F13/F14 Establish Comms
  E30 §6.7  S1F1/F2 Are You There
  E30 §6.13 S1F11/F12 SVID Namelist
  E30 §6.16 S2F29/F30 ECID Namelist
  E30 §6.20 S2F17/F18 Clock
  E30 §6.14 S5F5/F6 List Alarms
  E30 §6.17 S7F19/F20 PP List
  E30 §6.10 S1F19/F20 GEM Compliance

Validated against the demo server: 9/9 PASS.

README.md §8 (Compliance + certification) updated to point at the
harness as the suggested first-line conformance check.  Tool
vendors fork apps/secs_conformance.cpp and add their own
capability-specific checks alongside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 12:57:37 +02:00
parent d470442a8c
commit 06f287b415
3 changed files with 221 additions and 5 deletions
+205
View File
@@ -0,0 +1,205 @@
// secs_conformance — runnable conformance harness.
//
// Drives a passive GEM-300 equipment through a fixed set 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 COMPLIANCE.md so that anything
// the audit claims as ✅ has a runnable assertion behind it here.
#include <asio.hpp>
#include <chrono>
#include <cstdint>
#include <functional>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <system_error>
#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/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;
};
std::vector<Check> 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));
}
// 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<Connection> conn,
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),
[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();
});
}
} // 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 = 5s;
asio::io_context io;
Client client(io, cfg);
client.on_log([](const std::string& m) { log("hsms: " + m); });
asio::steady_timer deadline(io);
deadline.expires_after(60s);
deadline.async_wait([&](std::error_code ec) {
if (!ec) {
log("global 60s deadline reached, halting");
io.stop();
}
});
client.on_connection([&](std::shared_ptr<Connection> conn) {
// Reply to equipment-initiated primaries so the equipment's T3 doesn't
// fire on our watch.
conn->set_message_handler(
[](const s2::Message& msg) -> std::optional<s2::Message> {
if (msg.stream == 1 && msg.function == 13) {
return gem::s1f14_establish_comms_ack(
gem::CommAck::Accept, {"CONFORMANCE", "1.0"});
}
return std::nullopt;
});
conn->set_selected_handler([&, conn] {
record("E37 §7.2 SELECT handshake", true,
"selected against " + cfg.host + ":" + std::to_string(cfg.port));
// 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] {
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;
int passed = 0;
for (const auto& c : checks) {
std::cout << (c.passed ? " [PASS] " : " [FAIL] ") << c.name;
if (!c.detail.empty()) std::cout << "" << c.detail;
std::cout << std::endl;
if (c.passed) ++passed;
}
std::cout << "\n " << passed << " / " << checks.size()
<< " checks passed" << std::endl;
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();
});
});
client.start();
try { io.run(); } catch (const std::exception& e) {
std::cerr << "conformance: " << e.what() << std::endl;
return 2;
}
for (const auto& c : checks) if (!c.passed) return 1;
return checks.empty() ? 3 : 0;
}