1da56f973f
A2 — alarms: optional 'name:' on alarm config (a LOCAL key — SEMI only
defines numeric ALID + freetext ALTX; field appended last so existing
{id, text, category} brace-inits compile unchanged), parsed by the loader,
checked by the validator, shipped in equipment.yaml. SetAlarm/ClearAlarm
RPCs resolve config name OR stringified ALID via a constructor snapshot.
A3 — control state + health: RequestControlState fires operator events on
the io thread (read_sync) and reports what the E30 table actually did —
ACCEPT iff the equipment landed in the requested state, CANNOT_DO_NOW naming
the actual state otherwise (the shipped table has no operator path to
EquipmentOffline; the test pins that honesty). ATTEMPT_ONLINE is rejected as
transient. WatchHealth streams an immediate snapshot then pushes on link/
control-state changes via service observers (add_link_observer +
add_control_state_observer — the HandlerSlot work paying off), spool depth
sampled at the 500ms poll; ends on cancel or engine stop.
Tests: daemon suite 61 -> 101 assertions (alarm lifecycle by name/id/unknown,
WatchHealth initial + change push, all four RequestControlState semantics);
loader test for the alarm name (present + absent fallback); core 467/3055.
Interop now 15 checks incl. gRPC SetAlarm -> host receives S5F1 ALCD=0x84
ALID=1, and RequestControlState(HOST_OFFLINE) -> GetControlState confirms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
522 lines
18 KiB
C++
522 lines
18 KiB
C++
#include "secsgem/config/validate.hpp"
|
|
|
|
#include <yaml-cpp/yaml.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <set>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "secsgem/gem/control_job_state.hpp"
|
|
#include "secsgem/gem/control_state.hpp"
|
|
#include "secsgem/gem/process_job_state.hpp"
|
|
|
|
namespace secsgem::config {
|
|
|
|
namespace {
|
|
|
|
namespace gem = secsgem::gem;
|
|
|
|
// Map yaml-cpp's Mark to a 1-based line number when available.
|
|
std::optional<int> line_of(const YAML::Node& n) {
|
|
const auto m = n.Mark();
|
|
if (m.line < 0) return std::nullopt;
|
|
return m.line + 1; // yaml-cpp marks are 0-based
|
|
}
|
|
|
|
class Sink {
|
|
public:
|
|
Sink(std::vector<ConfigIssue>& out, std::string file)
|
|
: out_(out), file_(std::move(file)) {}
|
|
|
|
void error(const std::string& field_path, const std::string& msg,
|
|
const YAML::Node& at = {}) {
|
|
out_.push_back({file_, line_of(at), field_path, msg,
|
|
ConfigIssue::Severity::Error});
|
|
}
|
|
|
|
void warn(const std::string& field_path, const std::string& msg,
|
|
const YAML::Node& at = {}) {
|
|
out_.push_back({file_, line_of(at), field_path, msg,
|
|
ConfigIssue::Severity::Warning});
|
|
}
|
|
|
|
private:
|
|
std::vector<ConfigIssue>& out_;
|
|
std::string file_;
|
|
};
|
|
|
|
bool valid_secs_type(const std::string& t) {
|
|
static const std::set<std::string_view> ok = {
|
|
"ASCII", "BOOLEAN", "BINARY",
|
|
"U1", "U2", "U4", "U8",
|
|
"I1", "I2", "I4", "I8",
|
|
"F4", "F8",
|
|
};
|
|
return ok.count(t) > 0;
|
|
}
|
|
|
|
bool valid_hcack(const std::string& s) {
|
|
static const std::set<std::string_view> ok = {
|
|
"Accept", "InvalidCommand", "CannotDoNow", "ParameterInvalid",
|
|
"AcceptedWillFinishLater", "Rejected", "InvalidObject",
|
|
};
|
|
return ok.count(s) > 0;
|
|
}
|
|
|
|
// Range-checked integer extraction. Returns nullopt and emits an
|
|
// error if the node is missing, non-scalar, or out of range.
|
|
template <typename T>
|
|
std::optional<T> as_int_in_range(const YAML::Node& n, Sink& sink,
|
|
const std::string& path,
|
|
int64_t min, int64_t max) {
|
|
if (!n) {
|
|
sink.error(path, "missing required integer");
|
|
return std::nullopt;
|
|
}
|
|
if (!n.IsScalar()) {
|
|
sink.error(path, "expected an integer", n);
|
|
return std::nullopt;
|
|
}
|
|
int64_t v = 0;
|
|
try {
|
|
v = n.as<int64_t>();
|
|
} catch (const YAML::Exception&) {
|
|
sink.error(path, "value `" + n.Scalar() + "` is not a valid integer", n);
|
|
return std::nullopt;
|
|
}
|
|
if (v < min || v > max) {
|
|
sink.error(path, "value " + std::to_string(v) + " out of range [" +
|
|
std::to_string(min) + ", " + std::to_string(max) +
|
|
"]",
|
|
n);
|
|
return std::nullopt;
|
|
}
|
|
return static_cast<T>(v);
|
|
}
|
|
|
|
std::optional<std::string> as_nonempty_string(const YAML::Node& n,
|
|
Sink& sink,
|
|
const std::string& path) {
|
|
if (!n) {
|
|
sink.error(path, "missing required string");
|
|
return std::nullopt;
|
|
}
|
|
if (!n.IsScalar()) {
|
|
sink.error(path, "expected a string", n);
|
|
return std::nullopt;
|
|
}
|
|
auto s = n.as<std::string>();
|
|
if (s.empty()) {
|
|
sink.error(path, "string must not be empty", n);
|
|
return std::nullopt;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// Validate one entry that has the same (id, name, units, type, value)
|
|
// shape as SVID/DVID/ECID. Returns the parsed id on success so the
|
|
// caller can dedupe.
|
|
std::optional<uint32_t> validate_typed_entry(
|
|
const YAML::Node& entry, Sink& sink, const std::string& path) {
|
|
auto id = as_int_in_range<uint32_t>(entry["id"], sink, path + ".id",
|
|
0, UINT32_MAX);
|
|
as_nonempty_string(entry["name"], sink, path + ".name");
|
|
if (auto tn = entry["type"]) {
|
|
auto t = tn.as<std::string>();
|
|
if (!valid_secs_type(t)) {
|
|
sink.error(path + ".type",
|
|
"unknown SECS-II type `" + t +
|
|
"` (expected one of ASCII/BOOLEAN/BINARY/U1-U8/I1-I8/F4/F8)",
|
|
tn);
|
|
}
|
|
} else {
|
|
sink.error(path + ".type", "missing required field `type`");
|
|
}
|
|
return id;
|
|
}
|
|
|
|
void validate_equipment_block(YAML::Node& root, Sink& sink) {
|
|
std::set<uint32_t> ceids;
|
|
std::set<uint32_t> alarm_ids;
|
|
|
|
if (!root["device"]) {
|
|
sink.error("device", "missing required `device` block");
|
|
} else {
|
|
auto dev = root["device"];
|
|
as_int_in_range<uint16_t>(dev["id"], sink, "device.id", 0, UINT16_MAX);
|
|
as_nonempty_string(dev["model_name"], sink, "device.model_name");
|
|
as_nonempty_string(dev["software_rev"], sink, "device.software_rev");
|
|
}
|
|
|
|
if (auto caps = root["capabilities"]; caps && caps.IsSequence()) {
|
|
for (std::size_t i = 0; i < caps.size(); ++i) {
|
|
const auto path = "capabilities[" + std::to_string(i) + "]";
|
|
const auto& c = caps[i];
|
|
as_int_in_range<uint8_t>(c["code"], sink, path + ".code", 0, 255);
|
|
as_nonempty_string(c["name"], sink, path + ".name");
|
|
}
|
|
}
|
|
|
|
auto validate_entry_list = [&](const YAML::Node& seq, const char* key,
|
|
std::set<uint32_t>* dedupe = nullptr) {
|
|
if (!seq) return;
|
|
if (!seq.IsSequence()) {
|
|
sink.error(key, "expected a sequence", seq);
|
|
return;
|
|
}
|
|
std::set<uint32_t> local;
|
|
for (std::size_t i = 0; i < seq.size(); ++i) {
|
|
const auto path = std::string(key) + "[" + std::to_string(i) + "]";
|
|
auto id = validate_typed_entry(seq[i], sink, path);
|
|
if (id) {
|
|
if (!local.insert(*id).second) {
|
|
sink.error(path + ".id",
|
|
"duplicate id " + std::to_string(*id) + " in " + key,
|
|
seq[i]);
|
|
}
|
|
if (dedupe) dedupe->insert(*id);
|
|
}
|
|
}
|
|
};
|
|
validate_entry_list(root["svids"], "svids");
|
|
validate_entry_list(root["dvids"], "dvids");
|
|
validate_entry_list(root["ecids"], "ecids");
|
|
|
|
// CEIDs have a simpler shape (id + name).
|
|
if (auto ces = root["ceids"]) {
|
|
if (!ces.IsSequence()) {
|
|
sink.error("ceids", "expected a sequence", ces);
|
|
} else {
|
|
for (std::size_t i = 0; i < ces.size(); ++i) {
|
|
const auto path = "ceids[" + std::to_string(i) + "]";
|
|
auto id = as_int_in_range<uint32_t>(ces[i]["id"], sink, path + ".id",
|
|
0, UINT32_MAX);
|
|
as_nonempty_string(ces[i]["name"], sink, path + ".name");
|
|
if (id && !ceids.insert(*id).second) {
|
|
sink.error(path + ".id",
|
|
"duplicate CEID " + std::to_string(*id),
|
|
ces[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (auto als = root["alarms"]) {
|
|
if (!als.IsSequence()) {
|
|
sink.error("alarms", "expected a sequence", als);
|
|
} else {
|
|
for (std::size_t i = 0; i < als.size(); ++i) {
|
|
const auto path = "alarms[" + std::to_string(i) + "]";
|
|
auto id = as_int_in_range<uint32_t>(als[i]["id"], sink, path + ".id",
|
|
0, UINT32_MAX);
|
|
as_nonempty_string(als[i]["text"], sink, path + ".text");
|
|
// ALCD lower-7 bits are the category bitmap (E5 §10.3). Bit 7
|
|
// is the set/clear flag, set at emit time — must not be in YAML.
|
|
as_int_in_range<uint8_t>(als[i]["category"], sink,
|
|
path + ".category", 0, 127);
|
|
// Optional local key for name-based APIs (not on the wire). If
|
|
// present it must be non-empty.
|
|
if (als[i]["name"])
|
|
as_nonempty_string(als[i]["name"], sink, path + ".name");
|
|
if (id && !alarm_ids.insert(*id).second) {
|
|
sink.error(path + ".id",
|
|
"duplicate ALID " + std::to_string(*id), als[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (auto recs = root["recipes"]) {
|
|
if (!recs.IsSequence()) {
|
|
sink.error("recipes", "expected a sequence", recs);
|
|
} else {
|
|
std::set<std::string> seen;
|
|
for (std::size_t i = 0; i < recs.size(); ++i) {
|
|
const auto path = "recipes[" + std::to_string(i) + "]";
|
|
auto id = as_nonempty_string(recs[i]["id"], sink, path + ".id");
|
|
if (!recs[i]["body"]) {
|
|
sink.error(path + ".body", "missing required `body`");
|
|
}
|
|
if (id && !seen.insert(*id).second) {
|
|
sink.error(path + ".id", "duplicate PPID `" + *id + "`", recs[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (auto cmds = root["host_commands"]) {
|
|
if (!cmds.IsSequence()) {
|
|
sink.error("host_commands", "expected a sequence", cmds);
|
|
} else {
|
|
std::set<std::string> names;
|
|
for (std::size_t i = 0; i < cmds.size(); ++i) {
|
|
const auto path = "host_commands[" + std::to_string(i) + "]";
|
|
auto name = as_nonempty_string(cmds[i]["name"], sink, path + ".name");
|
|
if (auto ack = cmds[i]["ack"]) {
|
|
auto s = ack.as<std::string>();
|
|
if (!valid_hcack(s))
|
|
sink.error(path + ".ack", "unknown HCACK `" + s + "`", ack);
|
|
} else {
|
|
sink.error(path + ".ack", "missing required `ack`");
|
|
}
|
|
if (auto e = cmds[i]["emit_ceid"]) {
|
|
auto v = as_int_in_range<uint32_t>(e, sink, path + ".emit_ceid",
|
|
0, UINT32_MAX);
|
|
if (v && !ceids.count(*v)) {
|
|
sink.error(path + ".emit_ceid",
|
|
"CEID " + std::to_string(*v) +
|
|
" not declared in `ceids` section",
|
|
e);
|
|
}
|
|
}
|
|
if (auto a = cmds[i]["set_alarm"]) {
|
|
auto v = as_int_in_range<uint32_t>(a, sink, path + ".set_alarm",
|
|
0, UINT32_MAX);
|
|
if (v && !alarm_ids.count(*v)) {
|
|
sink.error(path + ".set_alarm",
|
|
"ALID " + std::to_string(*v) +
|
|
" not declared in `alarms` section",
|
|
a);
|
|
}
|
|
}
|
|
if (auto f = cmds[i]["force_spool"]) {
|
|
if (!f.IsScalar()) sink.error(path + ".force_spool",
|
|
"expected a boolean", f);
|
|
}
|
|
if (name && !names.insert(*name).second) {
|
|
sink.error(path + ".name", "duplicate command name `" + *name + "`",
|
|
cmds[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (auto sp = root["spool"]) {
|
|
if (auto s = sp["spoolable_streams"]) {
|
|
if (!s.IsSequence()) {
|
|
sink.error("spool.spoolable_streams", "expected a sequence", s);
|
|
} else {
|
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
|
// SECS-II streams are 1..127 (top bit is the W flag).
|
|
as_int_in_range<uint8_t>(
|
|
s[i], sink,
|
|
"spool.spoolable_streams[" + std::to_string(i) + "]",
|
|
1, 127);
|
|
}
|
|
}
|
|
}
|
|
if (auto m = sp["max_size"]) {
|
|
as_int_in_range<uint32_t>(m, sink, "spool.max_size", 1, INT32_MAX);
|
|
}
|
|
}
|
|
|
|
if (auto e = root["emit_on_control_change"]) {
|
|
if (!e.IsNull()) {
|
|
auto v = as_int_in_range<uint32_t>(e, sink, "emit_on_control_change",
|
|
0, UINT32_MAX);
|
|
if (v && !ceids.count(*v)) {
|
|
sink.error("emit_on_control_change",
|
|
"CEID " + std::to_string(*v) +
|
|
" not declared in `ceids` section",
|
|
e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
YAML::Node try_load(const std::string& path, Sink& sink) {
|
|
try {
|
|
return YAML::LoadFile(path);
|
|
} catch (const YAML::Exception& e) {
|
|
sink.error("", std::string("YAML parse error: ") + e.what());
|
|
return YAML::Node{};
|
|
}
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool ConfigValidator::has_errors() const { return error_count() > 0; }
|
|
|
|
std::size_t ConfigValidator::error_count() const {
|
|
return static_cast<std::size_t>(std::count_if(
|
|
issues_.begin(), issues_.end(),
|
|
[](const auto& i) { return i.severity == ConfigIssue::Severity::Error; }));
|
|
}
|
|
|
|
std::size_t ConfigValidator::warning_count() const {
|
|
return issues_.size() - error_count();
|
|
}
|
|
|
|
bool ConfigValidator::validate_equipment(const std::string& path) {
|
|
const auto baseline = error_count();
|
|
Sink sink(issues_, path);
|
|
YAML::Node root = try_load(path, sink);
|
|
if (root) validate_equipment_block(root, sink);
|
|
return error_count() == baseline;
|
|
}
|
|
|
|
bool ConfigValidator::validate_control_state(const std::string& path) {
|
|
const auto baseline = error_count();
|
|
Sink sink(issues_, path);
|
|
YAML::Node root = try_load(path, sink);
|
|
if (!root) return false;
|
|
|
|
if (auto in = root["initial"]) {
|
|
if (!gem::parse_control_state(in.as<std::string>())) {
|
|
sink.error("initial", "unknown control state `" + in.as<std::string>() + "`",
|
|
in);
|
|
}
|
|
}
|
|
|
|
const auto ts = root["transitions"];
|
|
if (!ts || !ts.IsSequence()) {
|
|
sink.error("transitions", "missing or non-sequence");
|
|
return false;
|
|
}
|
|
for (std::size_t i = 0; i < ts.size(); ++i) {
|
|
const auto path_i = "transitions[" + std::to_string(i) + "]";
|
|
const auto& row = ts[i];
|
|
auto from_n = row["from"];
|
|
auto on_n = row["on"];
|
|
if (!from_n) sink.error(path_i + ".from", "missing required `from`");
|
|
if (!on_n) sink.error(path_i + ".on", "missing required `on`");
|
|
if (from_n && !gem::parse_control_state(from_n.as<std::string>())) {
|
|
sink.error(path_i + ".from",
|
|
"unknown control state `" + from_n.as<std::string>() + "`",
|
|
from_n);
|
|
}
|
|
if (on_n && !gem::parse_control_event(on_n.as<std::string>())) {
|
|
sink.error(path_i + ".on",
|
|
"unknown control event `" + on_n.as<std::string>() + "`",
|
|
on_n);
|
|
}
|
|
if (auto to = row["to"]) {
|
|
if (!gem::parse_control_state(to.as<std::string>())) {
|
|
sink.error(path_i + ".to",
|
|
"unknown control state `" + to.as<std::string>() + "`",
|
|
to);
|
|
}
|
|
}
|
|
if (auto th = row["then"]) {
|
|
if (!gem::parse_control_state(th.as<std::string>())) {
|
|
sink.error(path_i + ".then",
|
|
"unknown control state `" + th.as<std::string>() + "`",
|
|
th);
|
|
}
|
|
}
|
|
}
|
|
return error_count() == baseline;
|
|
}
|
|
|
|
bool ConfigValidator::validate_process_job_state(const std::string& path) {
|
|
const auto baseline = error_count();
|
|
Sink sink(issues_, path);
|
|
YAML::Node root = try_load(path, sink);
|
|
if (!root) return false;
|
|
|
|
if (auto in = root["initial"]) {
|
|
auto parsed = gem::parse_process_job_state(in.as<std::string>());
|
|
if (!parsed) {
|
|
sink.error("initial",
|
|
"unknown PJ state `" + in.as<std::string>() + "`", in);
|
|
} else if (*parsed == gem::ProcessJobState::NoState) {
|
|
sink.error("initial",
|
|
"`NoState` is a sentinel and cannot be the initial state",
|
|
in);
|
|
}
|
|
}
|
|
|
|
const auto ts = root["transitions"];
|
|
if (!ts || !ts.IsSequence()) {
|
|
sink.error("transitions", "missing or non-sequence");
|
|
return false;
|
|
}
|
|
for (std::size_t i = 0; i < ts.size(); ++i) {
|
|
const auto path_i = "transitions[" + std::to_string(i) + "]";
|
|
const auto& row = ts[i];
|
|
if (auto from = row["from"]) {
|
|
auto parsed = gem::parse_process_job_state(from.as<std::string>());
|
|
if (!parsed)
|
|
sink.error(path_i + ".from", "unknown PJ state", from);
|
|
else if (*parsed == gem::ProcessJobState::NoState)
|
|
sink.error(path_i + ".from",
|
|
"`NoState` is a sentinel and cannot appear as `from`", from);
|
|
} else {
|
|
sink.error(path_i + ".from", "missing required `from`");
|
|
}
|
|
if (auto on = row["on"]) {
|
|
if (!gem::parse_process_job_event(on.as<std::string>()))
|
|
sink.error(path_i + ".on", "unknown PJ event", on);
|
|
} else {
|
|
sink.error(path_i + ".on", "missing required `on`");
|
|
}
|
|
if (auto to = row["to"]) {
|
|
auto parsed = gem::parse_process_job_state(to.as<std::string>());
|
|
if (!parsed)
|
|
sink.error(path_i + ".to", "unknown PJ state", to);
|
|
else if (*parsed == gem::ProcessJobState::NoState)
|
|
sink.error(path_i + ".to",
|
|
"`NoState` is a sentinel and cannot appear as `to`", to);
|
|
}
|
|
}
|
|
return error_count() == baseline;
|
|
}
|
|
|
|
bool ConfigValidator::validate_control_job_state(const std::string& path) {
|
|
const auto baseline = error_count();
|
|
Sink sink(issues_, path);
|
|
YAML::Node root = try_load(path, sink);
|
|
if (!root) return false;
|
|
|
|
if (auto in = root["initial"]) {
|
|
if (!gem::parse_control_job_state(in.as<std::string>()))
|
|
sink.error("initial",
|
|
"unknown CJ state `" + in.as<std::string>() + "`", in);
|
|
}
|
|
|
|
const auto ts = root["transitions"];
|
|
if (!ts || !ts.IsSequence()) {
|
|
sink.error("transitions", "missing or non-sequence");
|
|
return false;
|
|
}
|
|
for (std::size_t i = 0; i < ts.size(); ++i) {
|
|
const auto path_i = "transitions[" + std::to_string(i) + "]";
|
|
const auto& row = ts[i];
|
|
if (auto from = row["from"]) {
|
|
if (!gem::parse_control_job_state(from.as<std::string>()))
|
|
sink.error(path_i + ".from", "unknown CJ state", from);
|
|
} else {
|
|
sink.error(path_i + ".from", "missing required `from`");
|
|
}
|
|
if (auto on = row["on"]) {
|
|
if (!gem::parse_control_job_event(on.as<std::string>()))
|
|
sink.error(path_i + ".on", "unknown CJ event", on);
|
|
} else {
|
|
sink.error(path_i + ".on", "missing required `on`");
|
|
}
|
|
if (auto to = row["to"]) {
|
|
if (!gem::parse_control_job_state(to.as<std::string>()))
|
|
sink.error(path_i + ".to", "unknown CJ state", to);
|
|
}
|
|
}
|
|
return error_count() == baseline;
|
|
}
|
|
|
|
void format_issues_to(std::ostream& os,
|
|
const std::vector<ConfigIssue>& issues) {
|
|
for (const auto& i : issues) {
|
|
os << (i.severity == ConfigIssue::Severity::Error ? "[error] "
|
|
: "[warn] ")
|
|
<< i.file;
|
|
if (i.line) os << ":" << *i.line;
|
|
if (!i.field_path.empty()) os << " " << i.field_path;
|
|
os << " — " << i.message << "\n";
|
|
}
|
|
}
|
|
|
|
} // namespace secsgem::config
|