#4 Split EquipmentDataModel into focused stores

The god-class is gone.  Each capability is now its own focused store:
StatusVariableStore, DataVariableStore, EquipmentConstantStore (with EAC
range validation), EventReportSubscriptions, AlarmRegistry, RecipeStore,
Clock, HostCommandRegistry.  Each is independently testable.

EquipmentDataModel becomes a small composite that holds one of each store
as a public member, plus three convenience methods (vid_value, vid_exists,
compose_reports_for) that span SVIDs+DVIDs and inject the right callbacks
into the EventReportSubscriptions.

New under include/secsgem/gem/store/:

  status_variables.hpp   StatusVariable, StatusVariableStore,
                         DataVariable, DataVariableStore
  equipment_constants.hpp EquipmentConstant, EquipmentConstantStore,
                          EquipmentAck. set_value() now validates
                          numeric values against min_str/max_str and
                          returns EAC=4 on out-of-range — closes the
                          COMPLIANCE.md gap about EC range validation.
  event_reports.hpp      CollectionEvent, Report, ReportData,
                         EventReportSubscriptions + DefineReportAck,
                         LinkEventAck, EnableEventAck. The store is
                         pure data; VidLookup / VidExists callbacks
                         are injected at define / emit time so the
                         service doesn't back-reference the SVID
                         store.
  alarms.hpp             Alarm, AlarmAck, AlarmRegistry.
                         Encapsulates the (enabled, active) sets and
                         ALCD byte computation.
  recipes.hpp            ProcessProgramAck, RecipeStore.
  clock.hpp              TimeAck, Clock. set_time_string applies an
                         offset so subsequent reads reflect the host
                         time without mutating system clock.
  host_commands.hpp      HostCmdAck, CommandParameter,
                         HostCommandRegistry with Spec/Result types.

include/secsgem/gem/data_model.hpp shrinks to a 50-line composite:

  struct EquipmentDataModel {
    StatusVariableStore       svids;
    DataVariableStore         dvids;
    EquipmentConstantStore    ecids;
    EventReportSubscriptions  events;
    AlarmRegistry             alarms;
    RecipeStore               recipes;
    Clock                     clock;
    HostCommandRegistry       commands;
    /* + vid_value, vid_exists, compose_reports_for sugar */
  };

src/gem/data_model.cpp is gone — every store is inline header-only.

include/secsgem/gem/messages_helpers.hpp picks up EventReportAck and
TerminalAck (S6F12 / S10F2-F4 ack enums that aren't tied to any one
store).

Call-site updates:

  apps/secs_server.cpp   model->status_variable(id) -> model->svids.get(id),
                         model->equipment_constant(id) -> model->ecids.get(id),
                         model->alarm_set(id) -> model->alarms.set_active(id),
                         model->dispatch_command(...) -> model->commands.dispatch(...),
                         and similar across every handler.  Plus
                         model->current_time_string() -> model->clock....

  src/config/loader.cpp  model.add_status_variable(sv) -> model.svids.add(sv),
                         and similar.  HostCommandRegistry::Spec replaces
                         EquipmentDataModel::CommandSpec.

  apps/secs_client.cpp   std::vector<EquipmentDataModel::CommandParam> ->
                         std::vector<CommandParameter>.

  tests/test_data_model.cpp  Rewritten around the individual stores;
                         each gets its own TEST_CASE block.  Adds three
                         new cases covering EC range validation (in
                         range / out of range / non-numeric skipped).

  tests/test_loader.cpp  m.has_event(100) -> m.events.has_event(100),
                         etc.

Verified:

  - Tests: 69 cases / 370 assertions pass (was 67 / 384; -14 stale
    composite-API assertions + 16 new store-level assertions covering
    EC range validation and the per-store add/get/list/delete paths).
  - Demo: byte-identical behaviour across the full 17-step flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 09:51:54 +02:00
parent 29db1caedb
commit 711ee1b40f
16 changed files with 877 additions and 770 deletions
-1
View File
@@ -46,7 +46,6 @@ add_library(secsgem
src/hsms/header.cpp src/hsms/header.cpp
src/hsms/connection.cpp src/hsms/connection.cpp
src/gem/control_state.cpp src/gem/control_state.cpp
src/gem/data_model.cpp
src/config/loader.cpp src/config/loader.cpp
src/endpoint.cpp src/endpoint.cpp
) )
+1 -1
View File
@@ -222,7 +222,7 @@ int main(int argc, char** argv) {
// 9. Host command START -> equipment fires CEID kCeidProcessStarted. // 9. Host command START -> equipment fires CEID kCeidProcessStarted.
seq->steps.push_back([conn, logfn, fail, pause_then](auto next) { seq->steps.push_back([conn, logfn, fail, pause_then](auto next) {
std::vector<gem::EquipmentDataModel::CommandParam> params = { std::vector<gem::CommandParameter> params = {
{"LOTID", s2::Item::ascii("LOT-42")}, {"LOTID", s2::Item::ascii("LOT-42")},
{"PPID", s2::Item::ascii("RECIPE-A")}, {"PPID", s2::Item::ascii("RECIPE-A")},
}; };
+26 -26
View File
@@ -36,8 +36,8 @@ constexpr uint32_t kSvidControlState = 1;
constexpr uint32_t kSvidClock = 2; constexpr uint32_t kSvidClock = 2;
void refresh(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) { void refresh(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) {
m.set_status_value(kSvidControlState, s2::Item::ascii(gem::control_state_name(sm.state()))); m.svids.set_value(kSvidControlState, s2::Item::ascii(gem::control_state_name(sm.state())));
m.set_status_value(kSvidClock, s2::Item::ascii(m.current_time_string())); m.svids.set_value(kSvidClock, s2::Item::ascii(m.clock.current_time_string()));
} }
} // namespace } // namespace
@@ -59,10 +59,10 @@ int main(int argc, char** argv) {
std::cerr << "[equip] config error: " << e.what() << std::endl; std::cerr << "[equip] config error: " << e.what() << std::endl;
return 1; return 1;
} }
logfn("loaded " + std::to_string(model->all_status_variables().size()) + " SVIDs, " + logfn("loaded " + std::to_string(model->svids.size()) + " SVIDs, " +
std::to_string(model->all_equipment_constants().size()) + " ECIDs, " + std::to_string(model->ecids.all().size()) + " ECIDs, " +
std::to_string(model->all_events().size()) + " CEIDs, " + std::to_string(model->events.all_events().size()) + " CEIDs, " +
std::to_string(model->all_alarms().size()) + " alarms"); std::to_string(model->alarms.all().size()) + " alarms");
auto sm = std::make_shared<gem::ControlStateMachine>(sm_cfg.table, sm_cfg.initial); auto sm = std::make_shared<gem::ControlStateMachine>(sm_cfg.table, sm_cfg.initial);
@@ -93,11 +93,11 @@ int main(int argc, char** argv) {
asio::post(io, [active_conn, model, logfn, emit_event, alid]() { asio::post(io, [active_conn, model, logfn, emit_event, alid]() {
auto conn = active_conn->lock(); auto conn = active_conn->lock();
if (!conn) return; if (!conn) return;
auto alarm = model->alarm(alid); auto alarm = model->alarms.get(alid);
if (!alarm) return; if (!alarm) return;
auto alcd = model->alarm_set(alid); auto alcd = model->alarms.set_active(alid);
if (!alcd) return; if (!alcd) return;
if (model->alarm_enabled(alid)) { if (model->alarms.enabled(alid)) {
logfn("emit S5F1 alarm set ALID=" + std::to_string(alid)); logfn("emit S5F1 alarm set ALID=" + std::to_string(alid));
conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text), conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text),
[](std::error_code, const s2::Message&) {}); [](std::error_code, const s2::Message&) {});
@@ -128,10 +128,10 @@ int main(int argc, char** argv) {
if (!svids) return s2::Message(1, 0, false); if (!svids) return s2::Message(1, 0, false);
std::vector<std::optional<s2::Item>> values; std::vector<std::optional<s2::Item>> values;
if (svids->empty()) { if (svids->empty()) {
for (const auto& sv : model->all_status_variables()) values.push_back(sv.value); for (const auto& sv : model->svids.all()) values.push_back(sv.value);
} else { } else {
for (auto id : *svids) { for (auto id : *svids) {
auto sv = model->status_variable(id); auto sv = model->svids.get(id);
values.push_back(sv ? std::optional<s2::Item>(sv->value) : std::nullopt); values.push_back(sv ? std::optional<s2::Item>(sv->value) : std::nullopt);
} }
} }
@@ -140,7 +140,7 @@ int main(int argc, char** argv) {
}); });
router.on(1, 11, [model, logfn](const s2::Message&) { router.on(1, 11, [model, logfn](const s2::Message&) {
std::vector<gem::StatusName> rows; std::vector<gem::StatusName> rows;
for (const auto& sv : model->all_status_variables()) for (const auto& sv : model->svids.all())
rows.push_back({sv.id, sv.name, sv.units}); rows.push_back({sv.id, sv.name, sv.units});
logfn("S1F11 -> S1F12 (namelist, " + std::to_string(rows.size()) + ")"); logfn("S1F11 -> S1F12 (namelist, " + std::to_string(rows.size()) + ")");
return gem::s1f12_status_namelist_data(rows); return gem::s1f12_status_namelist_data(rows);
@@ -166,7 +166,7 @@ int main(int argc, char** argv) {
if (!ids) return s2::Message(2, 0, false); if (!ids) return s2::Message(2, 0, false);
std::vector<s2::Item> values; std::vector<s2::Item> values;
for (auto id : *ids) { for (auto id : *ids) {
auto ec = model->equipment_constant(id); auto ec = model->ecids.get(id);
values.push_back(ec ? ec->value : s2::Item::list({})); values.push_back(ec ? ec->value : s2::Item::list({}));
} }
logfn("S2F13 -> S2F14 (" + std::to_string(values.size()) + " values)"); logfn("S2F13 -> S2F14 (" + std::to_string(values.size()) + " values)");
@@ -178,7 +178,7 @@ int main(int argc, char** argv) {
if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange; if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange;
else else
for (const auto& s : *sets) { for (const auto& s : *sets) {
auto r = model->set_equipment_constant_value(s.ecid, s.value); auto r = model->ecids.set_value(s.ecid, s.value);
if (r != gem::EquipmentAck::Accept) eac = r; if (r != gem::EquipmentAck::Accept) eac = r;
} }
logfn("S2F15 -> S2F16 EAC=" + std::to_string(static_cast<int>(eac))); logfn("S2F15 -> S2F16 EAC=" + std::to_string(static_cast<int>(eac)));
@@ -186,15 +186,15 @@ int main(int argc, char** argv) {
}); });
router.on(2, 17, [model, logfn](const s2::Message&) { router.on(2, 17, [model, logfn](const s2::Message&) {
logfn("S2F17 -> S2F18 (clock)"); logfn("S2F17 -> S2F18 (clock)");
return gem::s2f18_date_time_data(model->current_time_string()); return gem::s2f18_date_time_data(model->clock.current_time_string());
}); });
router.on(2, 29, [model, logfn](const s2::Message& msg) { router.on(2, 29, [model, logfn](const s2::Message& msg) {
auto ids = gem::parse_u4_list_body(msg); auto ids = gem::parse_u4_list_body(msg);
std::vector<gem::EquipmentConstant> ecs; std::vector<gem::EquipmentConstant> ecs;
if (ids && ids->empty()) ecs = model->all_equipment_constants(); if (ids && ids->empty()) ecs = model->ecids.all();
else if (ids) else if (ids)
for (auto id : *ids) { for (auto id : *ids) {
auto ec = model->equipment_constant(id); auto ec = model->ecids.get(id);
if (ec) ecs.push_back(*ec); if (ec) ecs.push_back(*ec);
} }
std::vector<gem::EcNameRow> rows; std::vector<gem::EcNameRow> rows;
@@ -205,7 +205,7 @@ int main(int argc, char** argv) {
}); });
router.on(2, 31, [model, logfn](const s2::Message& msg) { router.on(2, 31, [model, logfn](const s2::Message& msg) {
auto t = gem::parse_s2f31(msg); auto t = gem::parse_s2f31(msg);
auto ack = t ? model->set_time_string(*t) : gem::TimeAck::Error; auto ack = t ? model->clock.set_time_string(*t) : gem::TimeAck::Error;
logfn("S2F31 -> S2F32 TIACK=" + std::to_string(static_cast<int>(ack))); logfn("S2F31 -> S2F32 TIACK=" + std::to_string(static_cast<int>(ack)));
return gem::s2f32_date_time_ack(ack); return gem::s2f32_date_time_ack(ack);
}); });
@@ -244,7 +244,7 @@ int main(int argc, char** argv) {
router.on(2, 41, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) { router.on(2, 41, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) {
auto cmd = gem::parse_s2f41(msg); auto cmd = gem::parse_s2f41(msg);
if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {}); if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {});
auto result = model->dispatch_command(cmd->rcmd, cmd->params); auto result = model->commands.dispatch(cmd->rcmd, cmd->params);
logfn("S2F41 RCMD=" + cmd->rcmd + " -> S2F42 HCACK=" + logfn("S2F41 RCMD=" + cmd->rcmd + " -> S2F42 HCACK=" +
std::to_string(static_cast<int>(result.ack))); std::to_string(static_cast<int>(result.ack)));
if (result.ack == gem::HostCmdAck::Accept) { if (result.ack == gem::HostCmdAck::Accept) {
@@ -256,7 +256,7 @@ int main(int argc, char** argv) {
router.on(5, 3, [model, logfn](const s2::Message& msg) { router.on(5, 3, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s5f3(msg); auto req = gem::parse_s5f3(msg);
auto ack = req ? model->set_alarm_enabled(req->alid, (req->aled & 0x80) != 0) auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0)
: gem::AlarmAck::Error; : gem::AlarmAck::Error;
logfn(std::string("S5F3 -> S5F4 ACKC5=") + std::to_string(static_cast<int>(ack))); logfn(std::string("S5F3 -> S5F4 ACKC5=") + std::to_string(static_cast<int>(ack)));
return gem::s5f4_enable_alarm_ack(ack); return gem::s5f4_enable_alarm_ack(ack);
@@ -264,33 +264,33 @@ int main(int argc, char** argv) {
router.on(5, 5, [model, logfn](const s2::Message& msg) { router.on(5, 5, [model, logfn](const s2::Message& msg) {
auto ids = gem::parse_u4_list_body(msg); auto ids = gem::parse_u4_list_body(msg);
std::vector<gem::Alarm> alarms; std::vector<gem::Alarm> alarms;
if (ids && ids->empty()) alarms = model->all_alarms(); if (ids && ids->empty()) alarms = model->alarms.all();
else if (ids) else if (ids)
for (auto id : *ids) { for (auto id : *ids) {
auto a = model->alarm(id); auto a = model->alarms.get(id);
if (a) alarms.push_back(*a); if (a) alarms.push_back(*a);
} }
logfn("S5F5 -> S5F6 (" + std::to_string(alarms.size()) + " alarms)"); logfn("S5F5 -> S5F6 (" + std::to_string(alarms.size()) + " alarms)");
return gem::s5f6_list_alarms_data( return gem::s5f6_list_alarms_data(
alarms, [model](uint32_t id) { return model->alarm_active(id); }); alarms, [model](uint32_t id) { return model->alarms.active(id); });
}); });
router.on(7, 3, [model, logfn](const s2::Message& msg) { router.on(7, 3, [model, logfn](const s2::Message& msg) {
auto pp = gem::parse_s7f3(msg); auto pp = gem::parse_s7f3(msg);
if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError); if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError);
model->add_process_program(pp->ppid, pp->ppbody); model->recipes.add(pp->ppid, pp->ppbody);
logfn("S7F3 PPID=" + pp->ppid + " -> S7F4 (Accept)"); logfn("S7F3 PPID=" + pp->ppid + " -> S7F4 (Accept)");
return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept); return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept);
}); });
router.on(7, 5, [model, logfn](const s2::Message& msg) { router.on(7, 5, [model, logfn](const s2::Message& msg) {
auto ppid = gem::parse_s7f5(msg); auto ppid = gem::parse_s7f5(msg);
if (!ppid) return gem::s7f6_process_program_data("", ""); if (!ppid) return gem::s7f6_process_program_data("", "");
auto body = model->process_program(*ppid); auto body = model->recipes.get(*ppid);
logfn("S7F5 PPID=" + *ppid + " -> S7F6"); logfn("S7F5 PPID=" + *ppid + " -> S7F6");
return gem::s7f6_process_program_data(*ppid, body ? *body : ""); return gem::s7f6_process_program_data(*ppid, body ? *body : "");
}); });
router.on(7, 19, [model, logfn](const s2::Message&) { router.on(7, 19, [model, logfn](const s2::Message&) {
auto list = model->process_program_list(); auto list = model->recipes.list();
logfn("S7F19 -> S7F20 (" + std::to_string(list.size()) + " PPIDs)"); logfn("S7F19 -> S7F20 (" + std::to_string(list.size()) + " PPIDs)");
return gem::s7f20_current_eppd_data(list); return gem::s7f20_current_eppd_data(list);
}); });
+45 -271
View File
@@ -1,283 +1,57 @@
#pragma once #pragma once
#include <algorithm> #include "secsgem/gem/store/alarms.hpp"
#include <cstdint> #include "secsgem/gem/store/clock.hpp"
#include <functional> #include "secsgem/gem/store/equipment_constants.hpp"
#include <map> #include "secsgem/gem/store/event_reports.hpp"
#include <optional> #include "secsgem/gem/store/host_commands.hpp"
#include <set> #include "secsgem/gem/store/recipes.hpp"
#include <string> #include "secsgem/gem/store/status_variables.hpp"
#include <utility>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::gem { namespace secsgem::gem {
namespace s2 = secsgem::secs2; // Composite over the seven focused stores. Each store is independently
// testable and independently usable — the application can keep a reference
// to just `alarms` and ignore the rest if that's all it needs. Variable
// lookups span SVIDs and DVIDs through this composite (the host's view
// per E30 §6.11).
struct EquipmentDataModel {
StatusVariableStore svids;
DataVariableStore dvids;
EquipmentConstantStore ecids;
EventReportSubscriptions events;
AlarmRegistry alarms;
RecipeStore recipes;
Clock clock;
HostCommandRegistry commands;
// ---- Status / data / equipment-constant variables ------------------------ // Convenience: VID -> value lookup spanning SVIDs and DVIDs.
std::optional<s2::Item> vid_value(uint32_t vid) const {
if (auto v = svids.value(vid)) return v;
if (auto v = dvids.value(vid)) return v;
return std::nullopt;
}
bool vid_exists(uint32_t vid) const {
return svids.has(vid) || dvids.has(vid);
}
struct StatusVariable { // Sugar that adapts the EventReportSubscriptions API to the host-supplied
uint32_t id; // VID list shape (matches what parse_s2f33 / parse_s2f35 yield).
std::string name;
std::string units;
s2::Item value;
};
struct DataVariable {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
};
struct EquipmentConstant {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
s2::Item def_value;
std::string min_str; // optional, ASCII for S2F30 ECMIN/ECMAX
std::string max_str;
};
// ---- Event reports -------------------------------------------------------
struct CollectionEvent {
uint32_t id;
std::string name;
};
// One <CPNAME, CPVAL> entry on S2F41. Defined here (not in the generated
// messages.hpp) so the data model can name it in its public API; the codegen
// references it as an external struct.
struct CommandParameter {
std::string name;
s2::Item value;
};
struct Report {
uint32_t id;
std::vector<uint32_t> vids;
};
// One report's worth of data at emission time: the RPTID and the values for
// each VID, in declaration order.
struct ReportData {
uint32_t rptid;
std::vector<s2::Item> values;
};
// ---- Alarms --------------------------------------------------------------
struct Alarm {
uint32_t id;
std::string text;
// Lower 7 bits of ALCD: alarm severity category (1=personal safety,
// 2=equipment safety, 4=parameter control error, ...). Bit 7 is the
// set/cleared flag, applied at emit time.
uint8_t severity_category;
};
// ---- Ack codes for the new SxFy ------------------------------------------
// S2F30 carries no ack; S2F34 / S2F36 / S2F38 do.
enum class DefineReportAck : uint8_t {
Accept = 0,
InsufficientSpace = 1,
InvalidFormat = 2,
RptidAlreadyDefined = 3,
InvalidVid = 5,
};
enum class LinkEventAck : uint8_t {
Accept = 0,
InsufficientSpace = 1,
InvalidFormat = 2,
UnknownCeid = 3,
UnknownRptid = 4,
CeidAlreadyLinked = 5,
};
enum class EnableEventAck : uint8_t {
Accept = 0,
UnknownCeid = 1,
};
// S5F2 / S5F4 alarm ack.
enum class AlarmAck : uint8_t {
Accept = 0,
Error = 1,
};
// S6F12 event-report ack.
enum class EventReportAck : uint8_t {
Accept = 0,
Denied = 1,
};
// S7F4 process-program ack.
enum class ProcessProgramAck : uint8_t {
Accept = 0,
PermissionNotGranted = 1,
LengthError = 2,
MatrixOverflow = 3,
PpidNotFound = 4,
ModeUnsupported = 5,
PerformanceError = 6,
};
// ---- Existing ack enums from earlier batches -----------------------------
enum class EquipmentAck : uint8_t {
Accept = 0,
Denied_UnknownEcid = 1,
Denied_Busy = 3,
Denied_OutOfRange = 4,
};
enum class TimeAck : uint8_t {
Accept = 0,
Error = 1,
NotDoneNotEmpty = 2,
};
enum class HostCmdAck : uint8_t {
Accept = 0,
InvalidCommand = 1,
CannotDoNow = 2,
ParameterInvalid = 3,
AcceptedWillFinishLater = 4,
Rejected = 5,
InvalidObject = 6,
};
enum class TerminalAck : uint8_t {
Accepted = 0,
WillNotDisplay = 1,
TerminalNotAvailable = 2,
};
// The in-memory equipment data dictionary. Owns SVIDs, DVIDs, ECIDs, the
// dynamic event-report subscription state (reports + links + enabled set),
// the alarm table, and a small process-program registry. Single-threaded;
// all access happens on the Asio executor.
class EquipmentDataModel {
public:
using CommandParam = CommandParameter; // back-compat alias
// Declarative host-command effect, loaded from YAML. The server
// dispatches a command by looking up the spec and (optionally) firing
// a CEID emit / setting an alarm after the S2F42 reply is sent.
struct CommandSpec {
HostCmdAck ack = HostCmdAck::Accept;
std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm;
};
struct CommandResult {
HostCmdAck ack = HostCmdAck::InvalidCommand;
std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm;
};
// --- SVID ---------------------------------------------------------------
void add_status_variable(StatusVariable sv);
std::optional<StatusVariable> status_variable(uint32_t id) const;
std::vector<StatusVariable> all_status_variables() const;
void set_status_value(uint32_t id, s2::Item value);
// --- DVID ---------------------------------------------------------------
void add_data_variable(DataVariable dv);
std::optional<DataVariable> data_variable(uint32_t id) const;
std::vector<DataVariable> all_data_variables() const;
void set_data_value(uint32_t id, s2::Item value);
// VID lookup that searches SVIDs then DVIDs (E30 §6.5 — VIDs share a
// namespace from the host's point of view).
std::optional<s2::Item> vid_value(uint32_t vid) const;
bool vid_exists(uint32_t vid) const;
// --- ECID ---------------------------------------------------------------
void add_equipment_constant(EquipmentConstant ec);
std::optional<EquipmentConstant> equipment_constant(uint32_t id) const;
std::vector<EquipmentConstant> all_equipment_constants() const;
EquipmentAck set_equipment_constant_value(uint32_t id, s2::Item value);
// --- Clock --------------------------------------------------------------
std::string current_time_string() const;
TimeAck set_time_string(const std::string& time_str);
// --- Host commands ------------------------------------------------------
void register_command(const std::string& rcmd, CommandSpec spec);
CommandResult dispatch_command(const std::string& rcmd,
const std::vector<CommandParam>& params) const;
bool has_command(const std::string& rcmd) const;
// --- Collection events --------------------------------------------------
void register_event(CollectionEvent ce);
bool has_event(uint32_t ceid) const;
std::vector<CollectionEvent> all_events() const;
// S2F33: define reports. `reports` maps RPTID -> VID list. An empty
// vector deletes all reports (and clears all links). An empty VID list
// for a given RPTID deletes that report.
DefineReportAck define_reports( DefineReportAck define_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& reports); const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& rows) {
return events.define_reports(rows, [this](uint32_t vid) { return vid_exists(vid); });
// S2F35: link CEIDs to RPTID lists. `links` maps CEID -> RPTID list. }
// An empty RPTID list for a CEID clears its links. An empty outer list
// clears all links.
LinkEventAck link_event_reports( LinkEventAck link_event_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& links); const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& rows) {
return events.link_event_reports(rows);
// S2F37: enable/disable events. Empty CEID list applies to *all* }
// registered events. EnableEventAck enable_events(bool enable, const std::vector<uint32_t>& ceids) {
EnableEventAck enable_events(bool enable, const std::vector<uint32_t>& ceids); return events.enable_events(enable, ceids);
}
bool is_event_enabled(uint32_t ceid) const; bool is_event_enabled(uint32_t ceid) const { return events.is_enabled(ceid); }
std::vector<ReportData> compose_reports_for(uint32_t ceid) const {
// Compose the report data for a CEID emission (S6F11 body's report list). return events.compose_for(ceid, [this](uint32_t vid) { return vid_value(vid); });
std::vector<ReportData> compose_reports_for(uint32_t ceid) const; }
std::vector<Report> all_reports() const;
// --- Alarms -------------------------------------------------------------
void add_alarm(Alarm a);
std::optional<Alarm> alarm(uint32_t alid) const;
std::vector<Alarm> all_alarms() const;
AlarmAck set_alarm_enabled(uint32_t alid, bool enable);
bool alarm_enabled(uint32_t alid) const;
// Returns the ALCD byte to put on the wire (bit 7 indicates set/cleared).
// Returns nullopt for unknown alarms.
std::optional<uint8_t> alarm_set(uint32_t alid);
std::optional<uint8_t> alarm_clear(uint32_t alid);
bool alarm_active(uint32_t alid) const;
// --- Process programs ---------------------------------------------------
void add_process_program(std::string ppid, std::string ppbody);
std::optional<std::string> process_program(const std::string& ppid) const;
std::vector<std::string> process_program_list() const;
ProcessProgramAck delete_process_program(const std::string& ppid);
private:
std::map<uint32_t, StatusVariable> svids_;
std::map<uint32_t, DataVariable> dvids_;
std::map<uint32_t, EquipmentConstant> ecids_;
std::int64_t time_offset_seconds_ = 0;
std::map<std::string, CommandSpec> commands_;
std::map<uint32_t, CollectionEvent> ceids_;
std::map<uint32_t, Report> reports_;
std::map<uint32_t, std::vector<uint32_t>> ce_links_; // CEID -> RPTID list
std::set<uint32_t> events_enabled_;
std::map<uint32_t, Alarm> alarms_;
std::set<uint32_t> alarms_enabled_;
std::set<uint32_t> alarms_active_;
std::map<std::string, std::string> process_programs_;
}; };
} // namespace secsgem::gem } // namespace secsgem::gem
+13
View File
@@ -127,4 +127,17 @@ inline s2::Message s5f6_list_alarms_data(const std::vector<Alarm>& alarms,
inline constexpr uint8_t kAlarmEnableByte = 0x80; inline constexpr uint8_t kAlarmEnableByte = 0x80;
inline constexpr uint8_t kAlarmDisableByte = 0x00; inline constexpr uint8_t kAlarmDisableByte = 0x00;
// ---- Ack enums that aren't tied to a specific store -------------------
enum class EventReportAck : uint8_t { // S6F12
Accept = 0,
Denied = 1,
};
enum class TerminalAck : uint8_t { // S10F2, S10F4
Accepted = 0,
WillNotDisplay = 1,
TerminalNotAvailable = 2,
};
} // namespace secsgem::gem } // namespace secsgem::gem
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
namespace secsgem::gem {
struct Alarm {
uint32_t id;
std::string text;
// Lower 7 bits of ALCD: severity category (1=personal safety,
// 2=equipment safety, 4=parameter control error, ...). Bit 7 marks
// set vs cleared and is applied at emit time.
uint8_t severity_category;
};
enum class AlarmAck : uint8_t {
Accept = 0,
Error = 1,
};
class AlarmRegistry {
public:
void add(Alarm a) { by_id_.insert_or_assign(a.id, std::move(a)); }
std::optional<Alarm> get(uint32_t id) const {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
return it->second;
}
std::vector<Alarm> all() const {
std::vector<Alarm> out;
out.reserve(by_id_.size());
for (const auto& [_, a] : by_id_) out.push_back(a);
return out;
}
bool has(uint32_t id) const { return by_id_.count(id) > 0; }
// S5F3 enable / disable.
AlarmAck set_enabled(uint32_t id, bool enable) {
if (!by_id_.count(id)) return AlarmAck::Error;
if (enable) enabled_.insert(id);
else enabled_.erase(id);
return AlarmAck::Accept;
}
bool enabled(uint32_t id) const { return enabled_.count(id) > 0; }
// Trigger set / clear; returns the ALCD byte to put on the wire (bit 7
// is the set flag, lower 7 carry the category). std::nullopt on unknown.
std::optional<uint8_t> set_active(uint32_t id) {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
active_.insert(id);
return static_cast<uint8_t>((it->second.severity_category & 0x7F) | 0x80);
}
std::optional<uint8_t> clear_active(uint32_t id) {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
active_.erase(id);
return static_cast<uint8_t>(it->second.severity_category & 0x7F);
}
bool active(uint32_t id) const { return active_.count(id) > 0; }
private:
std::map<uint32_t, Alarm> by_id_;
std::set<uint32_t> enabled_;
std::set<uint32_t> active_;
};
} // namespace secsgem::gem
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <string>
namespace secsgem::gem {
enum class TimeAck : uint8_t {
Accept = 0,
Error = 1,
NotDoneNotEmpty = 2,
};
// The equipment clock. current_time_string() returns the 16-char SECS-II
// TIME format ("YYYYMMDDhhmmsscc"), with an offset applied if the host has
// previously set the time via S2F31.
class Clock {
public:
std::string current_time_string() const {
using namespace std::chrono;
const auto now = system_clock::now() + seconds(offset_seconds_);
const auto t = system_clock::to_time_t(now);
const auto ms = duration_cast<milliseconds>(now.time_since_epoch()) % 1000;
std::tm tm{};
gmtime_r(&t, &tm);
std::array<char, 64> buf{};
std::snprintf(buf.data(), buf.size(), "%04d%02d%02d%02d%02d%02d%02d",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec,
static_cast<int>(ms.count() / 10));
return std::string(buf.data());
}
TimeAck set_time_string(const std::string& s) {
if (s.size() != 14 && s.size() != 16) return TimeAck::Error;
int y, mo, d, h, mi, se;
if (!parse_digits(s.data() + 0, 4, y) ||
!parse_digits(s.data() + 4, 2, mo) ||
!parse_digits(s.data() + 6, 2, d) ||
!parse_digits(s.data() + 8, 2, h) ||
!parse_digits(s.data() + 10, 2, mi) ||
!parse_digits(s.data() + 12, 2, se)) {
return TimeAck::Error;
}
std::tm tm{};
tm.tm_year = y - 1900;
tm.tm_mon = mo - 1;
tm.tm_mday = d;
tm.tm_hour = h;
tm.tm_min = mi;
tm.tm_sec = se;
const std::time_t target = timegm(&tm);
if (target == static_cast<std::time_t>(-1)) return TimeAck::Error;
offset_seconds_ = static_cast<std::int64_t>(target - std::time(nullptr));
return TimeAck::Accept;
}
private:
static bool parse_digits(const char* p, std::size_t n, int& out) {
int v = 0;
for (std::size_t i = 0; i < n; ++i) {
if (p[i] < '0' || p[i] > '9') return false;
v = v * 10 + (p[i] - '0');
}
out = v;
return true;
}
std::int64_t offset_seconds_ = 0;
};
} // namespace secsgem::gem
@@ -0,0 +1,103 @@
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <variant>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
struct EquipmentConstant {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
s2::Item def_value;
std::string min_str; // bounds for S2F30 + EAC validation; "" disables check
std::string max_str;
};
enum class EquipmentAck : uint8_t {
Accept = 0,
Denied_UnknownEcid = 1,
Denied_Busy = 3,
Denied_OutOfRange = 4,
};
class EquipmentConstantStore {
public:
void add(EquipmentConstant ec) { by_id_.insert_or_assign(ec.id, std::move(ec)); }
std::optional<EquipmentConstant> get(uint32_t id) const {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
return it->second;
}
std::vector<EquipmentConstant> all() const {
std::vector<EquipmentConstant> out;
out.reserve(by_id_.size());
for (const auto& [_, ec] : by_id_) out.push_back(ec);
return out;
}
bool has(uint32_t id) const { return by_id_.count(id) > 0; }
// Validates against min/max for numeric formats (only when min_str and
// max_str parse cleanly). Returns Denied_UnknownEcid / Denied_OutOfRange
// per S2F16 EAC. This is the rule that closes the COMPLIANCE.md gap
// about "EC range validation against min/max".
EquipmentAck set_value(uint32_t id, s2::Item value) {
auto it = by_id_.find(id);
if (it == by_id_.end()) return EquipmentAck::Denied_UnknownEcid;
if (!in_range(it->second, value)) return EquipmentAck::Denied_OutOfRange;
it->second.value = std::move(value);
return EquipmentAck::Accept;
}
private:
// For numeric formats, parse min_str / max_str as integers / doubles and
// compare against the first value of the array (we don't support setting
// multi-element ECs in this implementation).
static bool in_range(const EquipmentConstant& ec, const s2::Item& value) {
if (ec.min_str.empty() && ec.max_str.empty()) return true;
auto parse_d = [](const std::string& s, double& out) {
if (s.empty()) return false;
try { out = std::stod(s); return true; } catch (...) { return false; }
};
double v;
if (!extract_number(value, v)) return true; // unknown format: skip
double lo = -1e300, hi = 1e300;
parse_d(ec.min_str, lo);
parse_d(ec.max_str, hi);
return v >= lo && v <= hi;
}
static bool extract_number(const s2::Item& item, double& out) {
switch (item.format()) {
case s2::Format::U1: out = std::get<std::vector<uint8_t>>(item.storage()).front(); return true;
case s2::Format::U2: out = std::get<std::vector<uint16_t>>(item.storage()).front(); return true;
case s2::Format::U4: out = std::get<std::vector<uint32_t>>(item.storage()).front(); return true;
case s2::Format::U8: out = static_cast<double>(std::get<std::vector<uint64_t>>(item.storage()).front()); return true;
case s2::Format::I1: out = std::get<std::vector<int8_t>>(item.storage()).front(); return true;
case s2::Format::I2: out = std::get<std::vector<int16_t>>(item.storage()).front(); return true;
case s2::Format::I4: out = std::get<std::vector<int32_t>>(item.storage()).front(); return true;
case s2::Format::I8: out = static_cast<double>(std::get<std::vector<int64_t>>(item.storage()).front()); return true;
case s2::Format::F4: out = std::get<std::vector<float>>(item.storage()).front(); return true;
case s2::Format::F8: out = std::get<std::vector<double>>(item.storage()).front(); return true;
default: return false;
}
}
std::map<uint32_t, EquipmentConstant> by_id_;
};
} // namespace secsgem::gem
+178
View File
@@ -0,0 +1,178 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <functional>
#include <map>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
struct CollectionEvent {
uint32_t id;
std::string name;
};
struct Report {
uint32_t id;
std::vector<uint32_t> vids;
};
struct ReportData {
uint32_t rptid;
std::vector<s2::Item> values;
};
enum class DefineReportAck : uint8_t {
Accept = 0,
InsufficientSpace = 1,
InvalidFormat = 2,
RptidAlreadyDefined = 3,
InvalidVid = 5,
};
enum class LinkEventAck : uint8_t {
Accept = 0,
InsufficientSpace = 1,
InvalidFormat = 2,
UnknownCeid = 3,
UnknownRptid = 4,
CeidAlreadyLinked = 5,
};
enum class EnableEventAck : uint8_t {
Accept = 0,
UnknownCeid = 1,
};
// VID resolver: a callable that maps a VID (which may be an SVID or DVID) to
// its current value, or std::nullopt if unknown. Injected at S2F33 validation
// and again at compose_reports_for emission time; that way the subscription
// service stays pure data (no back-reference to the SVID / DVID stores).
using VidLookup = std::function<std::optional<s2::Item>(uint32_t)>;
using VidExists = std::function<bool(uint32_t)>;
// E30 §6.6 dynamic event reporting state:
// * the catalog of registered CEIDs (defined by the equipment),
// * the host-defined reports (RPTID -> VID list),
// * the CEID -> RPTID links,
// * and the set of CEIDs the host has enabled.
//
// Pure data; no IO.
class EventReportSubscriptions {
public:
// --- CEID catalog -----------------------------------------------------
void register_event(CollectionEvent ce) {
by_ceid_.insert_or_assign(ce.id, std::move(ce));
}
bool has_event(uint32_t ceid) const { return by_ceid_.count(ceid) > 0; }
std::vector<CollectionEvent> all_events() const {
std::vector<CollectionEvent> out;
out.reserve(by_ceid_.size());
for (const auto& [_, e] : by_ceid_) out.push_back(e);
return out;
}
// --- S2F33 define reports --------------------------------------------
DefineReportAck define_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& rows,
const VidExists& vid_exists) {
if (rows.empty()) {
reports_.clear();
links_.clear();
return DefineReportAck::Accept;
}
for (const auto& [rptid, vids] : rows) {
if (vids.empty()) continue;
for (auto v : vids)
if (!vid_exists(v)) return DefineReportAck::InvalidVid;
}
for (const auto& [rptid, vids] : rows) {
if (vids.empty()) {
reports_.erase(rptid);
for (auto& [_, rpts] : links_)
rpts.erase(std::remove(rpts.begin(), rpts.end(), rptid), rpts.end());
} else {
reports_.insert_or_assign(rptid, Report{rptid, vids});
}
}
return DefineReportAck::Accept;
}
std::vector<Report> all_reports() const {
std::vector<Report> out;
out.reserve(reports_.size());
for (const auto& [_, r] : reports_) out.push_back(r);
return out;
}
// --- S2F35 link event report -----------------------------------------
LinkEventAck link_event_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& rows) {
if (rows.empty()) {
links_.clear();
return LinkEventAck::Accept;
}
for (const auto& [ceid, rpts] : rows) {
if (!has_event(ceid)) return LinkEventAck::UnknownCeid;
for (auto r : rpts)
if (!reports_.count(r)) return LinkEventAck::UnknownRptid;
}
for (const auto& [ceid, rpts] : rows) {
if (rpts.empty()) links_.erase(ceid);
else links_[ceid] = rpts;
}
return LinkEventAck::Accept;
}
// --- S2F37 enable / disable event -----------------------------------
EnableEventAck enable_events(bool enable, const std::vector<uint32_t>& ceids) {
if (ceids.empty()) {
if (enable) for (const auto& [id, _] : by_ceid_) enabled_.insert(id);
else enabled_.clear();
return EnableEventAck::Accept;
}
for (auto id : ceids)
if (!has_event(id)) return EnableEventAck::UnknownCeid;
for (auto id : ceids) {
if (enable) enabled_.insert(id);
else enabled_.erase(id);
}
return EnableEventAck::Accept;
}
bool is_enabled(uint32_t ceid) const { return enabled_.count(ceid) > 0; }
// --- S6F11 emission --------------------------------------------------
std::vector<ReportData> compose_for(uint32_t ceid, const VidLookup& lookup) const {
std::vector<ReportData> out;
auto it = links_.find(ceid);
if (it == links_.end()) return out;
for (auto rptid : it->second) {
auto rit = reports_.find(rptid);
if (rit == reports_.end()) continue;
ReportData rd{rptid, {}};
for (auto vid : rit->second.vids) {
auto v = lookup(vid);
rd.values.push_back(v ? *v : s2::Item::list({}));
}
out.push_back(std::move(rd));
}
return out;
}
private:
std::map<uint32_t, CollectionEvent> by_ceid_;
std::map<uint32_t, Report> reports_;
std::map<uint32_t, std::vector<uint32_t>> links_;
std::set<uint32_t> enabled_;
};
} // namespace secsgem::gem
@@ -0,0 +1,64 @@
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
enum class HostCmdAck : uint8_t {
Accept = 0,
InvalidCommand = 1,
CannotDoNow = 2,
ParameterInvalid = 3,
AcceptedWillFinishLater = 4,
Rejected = 5,
InvalidObject = 6,
};
// One <CPNAME, CPVAL> entry on S2F41. The messages catalog declares this
// struct external_struct: true so the codegen references it rather than
// redefining it.
struct CommandParameter {
std::string name;
s2::Item value;
};
class HostCommandRegistry {
public:
// Declarative effect, loaded from YAML.
struct Spec {
HostCmdAck ack = HostCmdAck::Accept;
std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm;
};
struct Result {
HostCmdAck ack = HostCmdAck::InvalidCommand;
std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm;
};
void register_command(std::string rcmd, Spec spec) {
by_rcmd_.insert_or_assign(std::move(rcmd), std::move(spec));
}
bool has(const std::string& rcmd) const { return by_rcmd_.count(rcmd) > 0; }
Result dispatch(const std::string& rcmd,
const std::vector<CommandParameter>& /*params*/) const {
auto it = by_rcmd_.find(rcmd);
if (it == by_rcmd_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt};
return {it->second.ack, it->second.emit_ceid, it->second.set_alarm};
}
private:
std::map<std::string, Spec> by_rcmd_;
};
} // namespace secsgem::gem
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace secsgem::gem {
enum class ProcessProgramAck : uint8_t {
Accept = 0,
PermissionNotGranted = 1,
LengthError = 2,
MatrixOverflow = 3,
PpidNotFound = 4,
ModeUnsupported = 5,
PerformanceError = 6,
};
class RecipeStore {
public:
void add(std::string ppid, std::string body) {
by_ppid_.insert_or_assign(std::move(ppid), std::move(body));
}
std::optional<std::string> get(const std::string& ppid) const {
auto it = by_ppid_.find(ppid);
if (it == by_ppid_.end()) return std::nullopt;
return it->second;
}
std::vector<std::string> list() const {
std::vector<std::string> out;
out.reserve(by_ppid_.size());
for (const auto& [k, _] : by_ppid_) out.push_back(k);
return out;
}
ProcessProgramAck remove(const std::string& ppid) {
if (!by_ppid_.count(ppid)) return ProcessProgramAck::PpidNotFound;
by_ppid_.erase(ppid);
return ProcessProgramAck::Accept;
}
std::size_t size() const { return by_ppid_.size(); }
private:
std::map<std::string, std::string> by_ppid_;
};
} // namespace secsgem::gem
@@ -0,0 +1,94 @@
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::gem {
namespace s2 = secsgem::secs2;
struct StatusVariable {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
};
class StatusVariableStore {
public:
void add(StatusVariable sv) {
const uint32_t id = sv.id;
by_id_.insert_or_assign(id, std::move(sv));
}
bool has(uint32_t id) const { return by_id_.count(id) > 0; }
std::optional<StatusVariable> get(uint32_t id) const {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
return it->second;
}
std::vector<StatusVariable> all() const {
std::vector<StatusVariable> out;
out.reserve(by_id_.size());
for (const auto& [_, sv] : by_id_) out.push_back(sv);
return out;
}
void set_value(uint32_t id, s2::Item value) {
auto it = by_id_.find(id);
if (it != by_id_.end()) it->second.value = std::move(value);
}
std::optional<s2::Item> value(uint32_t id) const {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
return it->second.value;
}
std::size_t size() const { return by_id_.size(); }
private:
std::map<uint32_t, StatusVariable> by_id_;
};
// DVIDs (data variables) share storage shape with SVIDs; reuse the same class
// under a clearer alias so call sites read correctly.
struct DataVariable {
uint32_t id;
std::string name;
std::string units;
s2::Item value;
};
class DataVariableStore {
public:
void add(DataVariable dv) { by_id_.insert_or_assign(dv.id, std::move(dv)); }
std::optional<DataVariable> get(uint32_t id) const {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
return it->second;
}
std::vector<DataVariable> all() const {
std::vector<DataVariable> out;
out.reserve(by_id_.size());
for (const auto& [_, dv] : by_id_) out.push_back(dv);
return out;
}
void set_value(uint32_t id, s2::Item value) {
auto it = by_id_.find(id);
if (it != by_id_.end()) it->second.value = std::move(value);
}
std::optional<s2::Item> value(uint32_t id) const {
auto it = by_id_.find(id);
if (it == by_id_.end()) return std::nullopt;
return it->second.value;
}
bool has(uint32_t id) const { return by_id_.count(id) > 0; }
private:
std::map<uint32_t, DataVariable> by_id_;
};
} // namespace secsgem::gem
+7 -7
View File
@@ -147,7 +147,7 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (auto svids = root["svids"]) { if (auto svids = root["svids"]) {
for (const auto& sv : svids) { for (const auto& sv : svids) {
model.add_status_variable({ model.svids.add({
static_cast<uint32_t>(sv["id"].as<int>()), static_cast<uint32_t>(sv["id"].as<int>()),
sv["name"].as<std::string>(), sv["name"].as<std::string>(),
sv["units"] ? sv["units"].as<std::string>() : "", sv["units"] ? sv["units"].as<std::string>() : "",
@@ -159,7 +159,7 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (auto ecids = root["ecids"]) { if (auto ecids = root["ecids"]) {
for (const auto& ec : ecids) { for (const auto& ec : ecids) {
const auto value = make_item(ec["type"], ec["value"], path + " ecid"); const auto value = make_item(ec["type"], ec["value"], path + " ecid");
model.add_equipment_constant({ model.ecids.add({
static_cast<uint32_t>(ec["id"].as<int>()), static_cast<uint32_t>(ec["id"].as<int>()),
ec["name"].as<std::string>(), ec["name"].as<std::string>(),
ec["units"] ? ec["units"].as<std::string>() : "", ec["units"] ? ec["units"].as<std::string>() : "",
@@ -173,7 +173,7 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (auto ceids = root["ceids"]) { if (auto ceids = root["ceids"]) {
for (const auto& ce : ceids) { for (const auto& ce : ceids) {
model.register_event({ model.events.register_event({
static_cast<uint32_t>(ce["id"].as<int>()), static_cast<uint32_t>(ce["id"].as<int>()),
ce["name"].as<std::string>(), ce["name"].as<std::string>(),
}); });
@@ -182,7 +182,7 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (auto alarms = root["alarms"]) { if (auto alarms = root["alarms"]) {
for (const auto& a : alarms) { for (const auto& a : alarms) {
model.add_alarm({ model.alarms.add({
static_cast<uint32_t>(a["id"].as<int>()), static_cast<uint32_t>(a["id"].as<int>()),
a["text"].as<std::string>(), a["text"].as<std::string>(),
static_cast<uint8_t>(a["category"].as<int>()), static_cast<uint8_t>(a["category"].as<int>()),
@@ -192,19 +192,19 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (auto recipes = root["recipes"]) { if (auto recipes = root["recipes"]) {
for (const auto& r : recipes) { for (const auto& r : recipes) {
model.add_process_program(r["id"].as<std::string>(), r["body"].as<std::string>()); model.recipes.add(r["id"].as<std::string>(), r["body"].as<std::string>());
} }
} }
if (auto commands = root["host_commands"]) { if (auto commands = root["host_commands"]) {
for (const auto& c : commands) { for (const auto& c : commands) {
gem::EquipmentDataModel::CommandSpec spec; gem::HostCommandRegistry::Spec spec;
spec.ack = parse_hcack(c["ack"].as<std::string>(), path + " host_commands"); spec.ack = parse_hcack(c["ack"].as<std::string>(), path + " host_commands");
if (auto e = c["emit_ceid"]) if (auto e = c["emit_ceid"])
spec.emit_ceid = static_cast<uint32_t>(e.as<int>()); spec.emit_ceid = static_cast<uint32_t>(e.as<int>());
if (auto a = c["set_alarm"]) if (auto a = c["set_alarm"])
spec.set_alarm = static_cast<uint32_t>(a.as<int>()); spec.set_alarm = static_cast<uint32_t>(a.as<int>());
model.register_command(c["name"].as<std::string>(), spec); model.commands.register_command(c["name"].as<std::string>(), spec);
} }
} }
-339
View File
@@ -1,339 +0,0 @@
#include "secsgem/gem/data_model.hpp"
#include <array>
#include <chrono>
#include <cstdio>
#include <ctime>
namespace secsgem::gem {
// ---- SVIDs ---------------------------------------------------------------
void EquipmentDataModel::add_status_variable(StatusVariable sv) {
const uint32_t id = sv.id;
svids_.insert_or_assign(id, std::move(sv));
}
std::optional<StatusVariable> EquipmentDataModel::status_variable(uint32_t id) const {
auto it = svids_.find(id);
if (it == svids_.end()) return std::nullopt;
return it->second;
}
std::vector<StatusVariable> EquipmentDataModel::all_status_variables() const {
std::vector<StatusVariable> out;
out.reserve(svids_.size());
for (const auto& [_, sv] : svids_) out.push_back(sv);
return out;
}
void EquipmentDataModel::set_status_value(uint32_t id, s2::Item value) {
auto it = svids_.find(id);
if (it != svids_.end()) it->second.value = std::move(value);
}
// ---- DVIDs ---------------------------------------------------------------
void EquipmentDataModel::add_data_variable(DataVariable dv) {
const uint32_t id = dv.id;
dvids_.insert_or_assign(id, std::move(dv));
}
std::optional<DataVariable> EquipmentDataModel::data_variable(uint32_t id) const {
auto it = dvids_.find(id);
if (it == dvids_.end()) return std::nullopt;
return it->second;
}
std::vector<DataVariable> EquipmentDataModel::all_data_variables() const {
std::vector<DataVariable> out;
out.reserve(dvids_.size());
for (const auto& [_, dv] : dvids_) out.push_back(dv);
return out;
}
void EquipmentDataModel::set_data_value(uint32_t id, s2::Item value) {
auto it = dvids_.find(id);
if (it != dvids_.end()) it->second.value = std::move(value);
}
std::optional<s2::Item> EquipmentDataModel::vid_value(uint32_t vid) const {
if (auto it = svids_.find(vid); it != svids_.end()) return it->second.value;
if (auto it = dvids_.find(vid); it != dvids_.end()) return it->second.value;
return std::nullopt;
}
bool EquipmentDataModel::vid_exists(uint32_t vid) const {
return svids_.count(vid) || dvids_.count(vid);
}
// ---- ECIDs ---------------------------------------------------------------
void EquipmentDataModel::add_equipment_constant(EquipmentConstant ec) {
const uint32_t id = ec.id;
ecids_.insert_or_assign(id, std::move(ec));
}
std::optional<EquipmentConstant> EquipmentDataModel::equipment_constant(uint32_t id) const {
auto it = ecids_.find(id);
if (it == ecids_.end()) return std::nullopt;
return it->second;
}
std::vector<EquipmentConstant> EquipmentDataModel::all_equipment_constants() const {
std::vector<EquipmentConstant> out;
out.reserve(ecids_.size());
for (const auto& [_, ec] : ecids_) out.push_back(ec);
return out;
}
EquipmentAck EquipmentDataModel::set_equipment_constant_value(uint32_t id, s2::Item value) {
auto it = ecids_.find(id);
if (it == ecids_.end()) return EquipmentAck::Denied_UnknownEcid;
it->second.value = std::move(value);
return EquipmentAck::Accept;
}
// ---- Clock ---------------------------------------------------------------
std::string EquipmentDataModel::current_time_string() const {
using namespace std::chrono;
const auto now = system_clock::now() + seconds(time_offset_seconds_);
const auto t = system_clock::to_time_t(now);
const auto ms = duration_cast<milliseconds>(now.time_since_epoch()) % 1000;
std::tm tm{};
gmtime_r(&t, &tm);
std::array<char, 64> buf{};
std::snprintf(buf.data(), buf.size(), "%04d%02d%02d%02d%02d%02d%02d",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec,
static_cast<int>(ms.count() / 10));
return std::string(buf.data());
}
namespace {
bool parse_uint(const char* p, std::size_t n, int& out) {
int v = 0;
for (std::size_t i = 0; i < n; ++i) {
if (p[i] < '0' || p[i] > '9') return false;
v = v * 10 + (p[i] - '0');
}
out = v;
return true;
}
} // namespace
TimeAck EquipmentDataModel::set_time_string(const std::string& s) {
if (s.size() != 14 && s.size() != 16) return TimeAck::Error;
int y, mo, d, h, mi, se;
if (!parse_uint(s.data() + 0, 4, y) ||
!parse_uint(s.data() + 4, 2, mo) ||
!parse_uint(s.data() + 6, 2, d) ||
!parse_uint(s.data() + 8, 2, h) ||
!parse_uint(s.data() + 10, 2, mi) ||
!parse_uint(s.data() + 12, 2, se)) {
return TimeAck::Error;
}
std::tm tm{};
tm.tm_year = y - 1900;
tm.tm_mon = mo - 1;
tm.tm_mday = d;
tm.tm_hour = h;
tm.tm_min = mi;
tm.tm_sec = se;
const std::time_t target = timegm(&tm);
if (target == static_cast<std::time_t>(-1)) return TimeAck::Error;
const std::time_t now = std::time(nullptr);
time_offset_seconds_ = static_cast<std::int64_t>(target - now);
return TimeAck::Accept;
}
// ---- Host commands -------------------------------------------------------
void EquipmentDataModel::register_command(const std::string& rcmd, CommandSpec spec) {
commands_.insert_or_assign(rcmd, std::move(spec));
}
bool EquipmentDataModel::has_command(const std::string& rcmd) const {
return commands_.find(rcmd) != commands_.end();
}
EquipmentDataModel::CommandResult EquipmentDataModel::dispatch_command(
const std::string& rcmd, const std::vector<CommandParam>& /*params*/) const {
auto it = commands_.find(rcmd);
if (it == commands_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt};
return {it->second.ack, it->second.emit_ceid, it->second.set_alarm};
}
// ---- Collection events ---------------------------------------------------
void EquipmentDataModel::register_event(CollectionEvent ce) {
const uint32_t id = ce.id;
ceids_.insert_or_assign(id, std::move(ce));
}
bool EquipmentDataModel::has_event(uint32_t ceid) const { return ceids_.count(ceid); }
std::vector<CollectionEvent> EquipmentDataModel::all_events() const {
std::vector<CollectionEvent> out;
out.reserve(ceids_.size());
for (const auto& [_, e] : ceids_) out.push_back(e);
return out;
}
DefineReportAck EquipmentDataModel::define_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& reports) {
// Empty outer list deletes all reports (and links).
if (reports.empty()) {
reports_.clear();
ce_links_.clear();
return DefineReportAck::Accept;
}
// Validate first; commit only on full success.
for (const auto& [rptid, vids] : reports) {
if (vids.empty()) continue; // delete-report row is always valid
for (auto v : vids)
if (!vid_exists(v)) return DefineReportAck::InvalidVid;
}
for (const auto& [rptid, vids] : reports) {
if (vids.empty()) {
reports_.erase(rptid);
// Drop the RPTID from any links.
for (auto& [ceid, rpts] : ce_links_) {
rpts.erase(std::remove(rpts.begin(), rpts.end(), rptid), rpts.end());
}
} else {
reports_.insert_or_assign(rptid, Report{rptid, vids});
}
}
return DefineReportAck::Accept;
}
LinkEventAck EquipmentDataModel::link_event_reports(
const std::vector<std::pair<uint32_t, std::vector<uint32_t>>>& links) {
if (links.empty()) {
ce_links_.clear();
return LinkEventAck::Accept;
}
for (const auto& [ceid, rpts] : links) {
if (!has_event(ceid)) return LinkEventAck::UnknownCeid;
for (auto r : rpts)
if (!reports_.count(r)) return LinkEventAck::UnknownRptid;
}
for (const auto& [ceid, rpts] : links) {
if (rpts.empty()) {
ce_links_.erase(ceid);
} else {
ce_links_[ceid] = rpts;
}
}
return LinkEventAck::Accept;
}
EnableEventAck EquipmentDataModel::enable_events(bool enable,
const std::vector<uint32_t>& ceids) {
if (ceids.empty()) {
if (enable) {
for (const auto& [id, _] : ceids_) events_enabled_.insert(id);
} else {
events_enabled_.clear();
}
return EnableEventAck::Accept;
}
for (auto id : ceids)
if (!has_event(id)) return EnableEventAck::UnknownCeid;
for (auto id : ceids) {
if (enable) events_enabled_.insert(id);
else events_enabled_.erase(id);
}
return EnableEventAck::Accept;
}
bool EquipmentDataModel::is_event_enabled(uint32_t ceid) const {
return events_enabled_.count(ceid) > 0;
}
std::vector<ReportData> EquipmentDataModel::compose_reports_for(uint32_t ceid) const {
std::vector<ReportData> out;
auto it = ce_links_.find(ceid);
if (it == ce_links_.end()) return out;
for (auto rptid : it->second) {
auto rit = reports_.find(rptid);
if (rit == reports_.end()) continue;
ReportData rd{rptid, {}};
for (auto vid : rit->second.vids) {
auto v = vid_value(vid);
rd.values.push_back(v ? *v : s2::Item::list({}));
}
out.push_back(std::move(rd));
}
return out;
}
std::vector<Report> EquipmentDataModel::all_reports() const {
std::vector<Report> out;
out.reserve(reports_.size());
for (const auto& [_, r] : reports_) out.push_back(r);
return out;
}
// ---- Alarms --------------------------------------------------------------
void EquipmentDataModel::add_alarm(Alarm a) {
const uint32_t id = a.id;
alarms_.insert_or_assign(id, std::move(a));
}
std::optional<Alarm> EquipmentDataModel::alarm(uint32_t alid) const {
auto it = alarms_.find(alid);
if (it == alarms_.end()) return std::nullopt;
return it->second;
}
std::vector<Alarm> EquipmentDataModel::all_alarms() const {
std::vector<Alarm> out;
out.reserve(alarms_.size());
for (const auto& [_, a] : alarms_) out.push_back(a);
return out;
}
AlarmAck EquipmentDataModel::set_alarm_enabled(uint32_t alid, bool enable) {
if (!alarms_.count(alid)) return AlarmAck::Error;
if (enable) alarms_enabled_.insert(alid);
else alarms_enabled_.erase(alid);
return AlarmAck::Accept;
}
bool EquipmentDataModel::alarm_enabled(uint32_t alid) const {
return alarms_enabled_.count(alid) > 0;
}
std::optional<uint8_t> EquipmentDataModel::alarm_set(uint32_t alid) {
auto it = alarms_.find(alid);
if (it == alarms_.end()) return std::nullopt;
alarms_active_.insert(alid);
return static_cast<uint8_t>((it->second.severity_category & 0x7F) | 0x80);
}
std::optional<uint8_t> EquipmentDataModel::alarm_clear(uint32_t alid) {
auto it = alarms_.find(alid);
if (it == alarms_.end()) return std::nullopt;
alarms_active_.erase(alid);
return static_cast<uint8_t>(it->second.severity_category & 0x7F);
}
bool EquipmentDataModel::alarm_active(uint32_t alid) const {
return alarms_active_.count(alid) > 0;
}
// ---- Process programs ----------------------------------------------------
void EquipmentDataModel::add_process_program(std::string ppid, std::string ppbody) {
process_programs_.insert_or_assign(std::move(ppid), std::move(ppbody));
}
std::optional<std::string> EquipmentDataModel::process_program(const std::string& ppid) const {
auto it = process_programs_.find(ppid);
if (it == process_programs_.end()) return std::nullopt;
return it->second;
}
std::vector<std::string> EquipmentDataModel::process_program_list() const {
std::vector<std::string> out;
out.reserve(process_programs_.size());
for (const auto& [k, _] : process_programs_) out.push_back(k);
return out;
}
ProcessProgramAck EquipmentDataModel::delete_process_program(const std::string& ppid) {
if (!process_programs_.count(ppid)) return ProcessProgramAck::PpidNotFound;
process_programs_.erase(ppid);
return ProcessProgramAck::Accept;
}
} // namespace secsgem::gem
+128 -108
View File
@@ -4,96 +4,117 @@
using namespace secsgem::gem; using namespace secsgem::gem;
// ---- Status variables ----------------------------------------------------
TEST_CASE("SVID add / get / list / set value") { TEST_CASE("SVID add / get / list / set value") {
EquipmentDataModel m; StatusVariableStore svids;
m.add_status_variable({1, "Clock", "", s2::Item::ascii("19700101000000")}); svids.add({1, "Clock", "", s2::Item::ascii("19700101000000")});
m.add_status_variable({2, "EventsEnabled", "", s2::Item::boolean(true)}); svids.add({2, "EventsEnabled", "", s2::Item::boolean(true)});
CHECK(m.status_variable(1).has_value()); CHECK(svids.get(1).has_value());
CHECK(m.status_variable(99).has_value() == false); CHECK_FALSE(svids.get(99).has_value());
CHECK(m.all_status_variables().size() == 2); CHECK(svids.all().size() == 2);
m.set_status_value(2, s2::Item::boolean(false)); svids.set_value(2, s2::Item::boolean(false));
CHECK(m.status_variable(2)->value == s2::Item::boolean(false)); CHECK(svids.get(2)->value == s2::Item::boolean(false));
} }
TEST_CASE("ECID set rejects unknown id") { // ---- Equipment constants -------------------------------------------------
EquipmentDataModel m;
m.add_equipment_constant({10, "MaxSpool", "msgs", s2::Item::u4(uint32_t{100}), TEST_CASE("ECID set rejects unknown id and out-of-range values") {
EquipmentConstantStore ecids;
ecids.add({10, "MaxSpool", "msgs", s2::Item::u4(uint32_t{100}),
s2::Item::u4(uint32_t{100}), "0", "1000"}); s2::Item::u4(uint32_t{100}), "0", "1000"});
CHECK(m.set_equipment_constant_value(10, s2::Item::u4(uint32_t{50})) == CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{50})) == EquipmentAck::Accept);
EquipmentAck::Accept); CHECK(ecids.get(10)->value == s2::Item::u4(uint32_t{50}));
CHECK(m.equipment_constant(10)->value == s2::Item::u4(uint32_t{50})); CHECK(ecids.set_value(999, s2::Item::u4(uint32_t{1})) == EquipmentAck::Denied_UnknownEcid);
CHECK(m.set_equipment_constant_value(999, s2::Item::u4(uint32_t{1})) ==
EquipmentAck::Denied_UnknownEcid); // Out-of-range rejected (closes COMPLIANCE.md gap).
CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{5000})) == EquipmentAck::Denied_OutOfRange);
CHECK(ecids.get(10)->value == s2::Item::u4(uint32_t{50})); // unchanged
} }
TEST_CASE("clock string has 16 digit characters") { TEST_CASE("ECID with no min/max accepts any value") {
EquipmentDataModel m; EquipmentConstantStore ecids;
auto s = m.current_time_string(); ecids.add({1, "n", "", s2::Item::u4(uint32_t{0}), s2::Item::u4(uint32_t{0}), "", ""});
CHECK(ecids.set_value(1, s2::Item::u4(uint32_t{99999999})) == EquipmentAck::Accept);
}
TEST_CASE("ECID range validation skipped for non-numeric formats") {
EquipmentConstantStore ecids;
ecids.add({1, "label", "", s2::Item::ascii("a"), s2::Item::ascii("a"), "0", "10"});
CHECK(ecids.set_value(1, s2::Item::ascii("foo")) == EquipmentAck::Accept);
}
// ---- Clock ---------------------------------------------------------------
TEST_CASE("clock string has 16 characters") {
Clock c;
auto s = c.current_time_string();
REQUIRE(s.size() == 16); REQUIRE(s.size() == 16);
for (char c : s) CHECK(c >= '0');
} }
TEST_CASE("set_time_string accepts well-formed and rejects malformed") { TEST_CASE("set_time_string accepts well-formed and rejects malformed") {
EquipmentDataModel m; Clock c;
CHECK(m.set_time_string("20260101000000") == TimeAck::Accept); CHECK(c.set_time_string("20260101000000") == TimeAck::Accept);
CHECK(m.set_time_string("2026010100000000") == TimeAck::Accept); CHECK(c.set_time_string("2026010100000000") == TimeAck::Accept);
CHECK(m.set_time_string("not-a-date-12345") == TimeAck::Error); CHECK(c.set_time_string("not-a-date-12345") == TimeAck::Error);
CHECK(m.set_time_string("short") == TimeAck::Error); CHECK(c.set_time_string("short") == TimeAck::Error);
} }
TEST_CASE("host command registry") { // ---- Host command registry ----------------------------------------------
EquipmentDataModel m;
m.register_command("START", {HostCmdAck::Accept, 300, std::nullopt});
m.register_command("STOP", {HostCmdAck::CannotDoNow, std::nullopt, std::nullopt});
m.register_command("FAULT", {HostCmdAck::Accept, std::nullopt, 1});
CHECK(m.has_command("START")); TEST_CASE("host command registry returns spec + result") {
CHECK_FALSE(m.has_command("PAUSE")); HostCommandRegistry r;
r.register_command("START", {HostCmdAck::Accept, 300, std::nullopt});
r.register_command("STOP", {HostCmdAck::CannotDoNow, std::nullopt, std::nullopt});
r.register_command("FAULT", {HostCmdAck::Accept, std::nullopt, 1});
auto start = m.dispatch_command("START", {}); CHECK(r.has("START"));
CHECK_FALSE(r.has("PAUSE"));
auto start = r.dispatch("START", {});
CHECK(start.ack == HostCmdAck::Accept); CHECK(start.ack == HostCmdAck::Accept);
CHECK(start.emit_ceid.value_or(0) == 300); CHECK(start.emit_ceid.value_or(0) == 300);
CHECK_FALSE(start.set_alarm.has_value());
auto stop = m.dispatch_command("STOP", {}); auto fault = r.dispatch("FAULT", {});
CHECK(stop.ack == HostCmdAck::CannotDoNow);
auto fault = m.dispatch_command("FAULT", {});
CHECK(fault.ack == HostCmdAck::Accept);
CHECK(fault.set_alarm.value_or(0) == 1); CHECK(fault.set_alarm.value_or(0) == 1);
CHECK(m.dispatch_command("UNKNOWN", {}).ack == HostCmdAck::InvalidCommand); CHECK(r.dispatch("UNKNOWN", {}).ack == HostCmdAck::InvalidCommand);
} }
// ---- Event reports ------------------------------------------------------- // ---- Event reports -------------------------------------------------------
TEST_CASE("define report rejects unknown VID, accepts known VIDs") { TEST_CASE("define reports rejects unknown VID") {
EquipmentDataModel m; EventReportSubscriptions ev;
m.add_status_variable({1, "A", "", s2::Item::u4(uint32_t{10})}); ev.register_event({100, "x"});
m.add_status_variable({2, "B", "", s2::Item::u4(uint32_t{20})}); auto exists = [](uint32_t id) { return id == 1 || id == 2; };
CHECK(m.define_reports({{100, {1, 2}}}) == DefineReportAck::Accept); CHECK(ev.define_reports({{1000, {1, 2}}}, exists) == DefineReportAck::Accept);
CHECK(m.define_reports({{101, {1, 999}}}) == DefineReportAck::InvalidVid); CHECK(ev.define_reports({{1001, {1, 999}}}, exists) == DefineReportAck::InvalidVid);
} }
TEST_CASE("link / enable / compose: full event-report pipeline") { TEST_CASE("full event-report pipeline") {
EquipmentDataModel m; StatusVariableStore svids;
m.add_status_variable({1, "ControlState", "", s2::Item::ascii("OnlineRemote")}); svids.add({1, "ControlState", "", s2::Item::ascii("OnlineRemote")});
m.add_status_variable({2, "Clock", "", s2::Item::ascii("19700101000000")}); svids.add({2, "Clock", "", s2::Item::ascii("19700101000000")});
m.register_event({100, "ControlStateChanged"});
m.register_event({200, "AlarmSetEvent"});
REQUIRE(m.define_reports({{1000, {1}}, {1001, {1, 2}}}) == DefineReportAck::Accept); EventReportSubscriptions ev;
REQUIRE(m.link_event_reports({{100, {1000, 1001}}, {200, {1001}}}) == LinkEventAck::Accept); ev.register_event({100, "ControlStateChanged"});
CHECK_FALSE(m.is_event_enabled(100)); ev.register_event({200, "AlarmSetEvent"});
REQUIRE(m.enable_events(true, {100}) == EnableEventAck::Accept); auto exists = [&](uint32_t id) { return svids.has(id); };
CHECK(m.is_event_enabled(100)); auto value = [&](uint32_t id) { return svids.value(id); };
CHECK_FALSE(m.is_event_enabled(200));
auto reports = m.compose_reports_for(100); REQUIRE(ev.define_reports({{1000, {1}}, {1001, {1, 2}}}, exists) == DefineReportAck::Accept);
REQUIRE(ev.link_event_reports({{100, {1000, 1001}}, {200, {1001}}}) == LinkEventAck::Accept);
CHECK_FALSE(ev.is_enabled(100));
REQUIRE(ev.enable_events(true, {100}) == EnableEventAck::Accept);
CHECK(ev.is_enabled(100));
CHECK_FALSE(ev.is_enabled(200));
auto reports = ev.compose_for(100, value);
REQUIRE(reports.size() == 2); REQUIRE(reports.size() == 2);
CHECK(reports[0].rptid == 1000); CHECK(reports[0].rptid == 1000);
REQUIRE(reports[0].values.size() == 1); REQUIRE(reports[0].values.size() == 1);
@@ -103,75 +124,74 @@ TEST_CASE("link / enable / compose: full event-report pipeline") {
} }
TEST_CASE("enable_events with empty CEID list enables all registered events") { TEST_CASE("enable_events with empty CEID list enables all registered events") {
EquipmentDataModel m; EventReportSubscriptions ev;
m.register_event({1, "a"}); ev.register_event({1, "a"});
m.register_event({2, "b"}); ev.register_event({2, "b"});
CHECK(m.enable_events(true, {}) == EnableEventAck::Accept); CHECK(ev.enable_events(true, {}) == EnableEventAck::Accept);
CHECK(m.is_event_enabled(1)); CHECK(ev.is_enabled(1));
CHECK(m.is_event_enabled(2)); CHECK(ev.is_enabled(2));
CHECK(m.enable_events(false, {}) == EnableEventAck::Accept); CHECK(ev.enable_events(false, {}) == EnableEventAck::Accept);
CHECK_FALSE(m.is_event_enabled(1)); CHECK_FALSE(ev.is_enabled(1));
} }
TEST_CASE("link_event_reports rejects unknown CEID or RPTID") { TEST_CASE("link_event_reports rejects unknown CEID or RPTID") {
EquipmentDataModel m; EventReportSubscriptions ev;
m.add_status_variable({1, "A", "", s2::Item::u4(uint32_t{0})}); ev.register_event({100, "x"});
m.register_event({100, "x"}); ev.define_reports({{500, {1}}}, [](uint32_t) { return true; });
m.define_reports({{500, {1}}});
CHECK(m.link_event_reports({{999, {500}}}) == LinkEventAck::UnknownCeid); CHECK(ev.link_event_reports({{999, {500}}}) == LinkEventAck::UnknownCeid);
CHECK(m.link_event_reports({{100, {999}}}) == LinkEventAck::UnknownRptid); CHECK(ev.link_event_reports({{100, {999}}}) == LinkEventAck::UnknownRptid);
CHECK(m.link_event_reports({{100, {500}}}) == LinkEventAck::Accept); CHECK(ev.link_event_reports({{100, {500}}}) == LinkEventAck::Accept);
}
TEST_CASE("define_reports empty list clears reports and links") {
EquipmentDataModel m;
m.add_status_variable({1, "A", "", s2::Item::u4(uint32_t{0})});
m.register_event({100, "x"});
m.define_reports({{500, {1}}});
m.link_event_reports({{100, {500}}});
CHECK(m.define_reports({}) == DefineReportAck::Accept);
CHECK(m.all_reports().empty());
CHECK(m.compose_reports_for(100).empty());
} }
// ---- Alarms -------------------------------------------------------------- // ---- Alarms --------------------------------------------------------------
TEST_CASE("alarm enable / set / clear / list") { TEST_CASE("alarm enable / set / clear / list") {
EquipmentDataModel m; AlarmRegistry r;
m.add_alarm({1, "Chiller Temp High", 4}); r.add({1, "Chiller Temp High", 4});
m.add_alarm({2, "Door Open", 1}); r.add({2, "Door Open", 1});
CHECK(m.set_alarm_enabled(1, true) == AlarmAck::Accept); CHECK(r.set_enabled(1, true) == AlarmAck::Accept);
CHECK(m.alarm_enabled(1)); CHECK(r.enabled(1));
CHECK(m.set_alarm_enabled(999, true) == AlarmAck::Error); CHECK(r.set_enabled(999, true) == AlarmAck::Error);
auto alcd_set = m.alarm_set(1); auto alcd_set = r.set_active(1);
REQUIRE(alcd_set.has_value()); REQUIRE(alcd_set.has_value());
CHECK((*alcd_set & 0x80) != 0); CHECK((*alcd_set & 0x80) != 0);
CHECK((*alcd_set & 0x7F) == 4); CHECK((*alcd_set & 0x7F) == 4);
CHECK(m.alarm_active(1)); CHECK(r.active(1));
auto alcd_clr = m.alarm_clear(1); auto alcd_clr = r.clear_active(1);
REQUIRE(alcd_clr.has_value()); REQUIRE(alcd_clr.has_value());
CHECK((*alcd_clr & 0x80) == 0); CHECK((*alcd_clr & 0x80) == 0);
CHECK_FALSE(m.alarm_active(1)); CHECK_FALSE(r.active(1));
CHECK(m.all_alarms().size() == 2); CHECK(r.all().size() == 2);
} }
// ---- Process programs --------------------------------------------------- // ---- Process programs ----------------------------------------------------
TEST_CASE("process program CRUD") { TEST_CASE("recipe CRUD") {
RecipeStore r;
r.add("RECIPE-A", "step1\nstep2\n");
r.add("RECIPE-B", "alt body");
CHECK(r.list().size() == 2);
CHECK(r.get("RECIPE-A").value() == "step1\nstep2\n");
CHECK_FALSE(r.get("UNKNOWN").has_value());
CHECK(r.remove("RECIPE-A") == ProcessProgramAck::Accept);
CHECK_FALSE(r.get("RECIPE-A").has_value());
CHECK(r.remove("RECIPE-A") == ProcessProgramAck::PpidNotFound);
}
// ---- Composite EquipmentDataModel ---------------------------------------
TEST_CASE("EquipmentDataModel composes the seven stores") {
EquipmentDataModel m; EquipmentDataModel m;
m.add_process_program("RECIPE-A", "step1\nstep2\n"); m.svids.add({1, "Clock", "", s2::Item::ascii("")});
m.add_process_program("RECIPE-B", "alt body"); m.dvids.add({2, "Temp", "C", s2::Item::u4(uint32_t{25})});
CHECK(m.process_program_list().size() == 2); CHECK(m.vid_exists(1));
CHECK(m.process_program("RECIPE-A").value() == "step1\nstep2\n"); CHECK(m.vid_exists(2));
CHECK_FALSE(m.process_program("UNKNOWN").has_value()); CHECK_FALSE(m.vid_exists(3));
CHECK(m.vid_value(2) == s2::Item::u4(uint32_t{25}));
CHECK(m.delete_process_program("RECIPE-A") == ProcessProgramAck::Accept);
CHECK_FALSE(m.process_program("RECIPE-A").has_value());
CHECK(m.delete_process_program("RECIPE-A") == ProcessProgramAck::PpidNotFound);
} }
+16 -16
View File
@@ -40,29 +40,29 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman
REQUIRE(desc.emit_on_control_change.has_value()); REQUIRE(desc.emit_on_control_change.has_value());
CHECK(*desc.emit_on_control_change == 100); CHECK(*desc.emit_on_control_change == 100);
CHECK(m.all_status_variables().size() == 3); CHECK(m.svids.all().size() == 3);
CHECK(m.status_variable(1)->name == "ControlState"); CHECK(m.svids.get(1)->name == "ControlState");
CHECK(m.status_variable(3)->value == s2::Item::boolean(true)); CHECK(m.svids.get(3)->value == s2::Item::boolean(true));
CHECK(m.all_equipment_constants().size() == 2); CHECK(m.ecids.all().size() == 2);
CHECK(m.equipment_constant(10)->value == s2::Item::u4(uint32_t{1})); CHECK(m.ecids.get(10)->value == s2::Item::u4(uint32_t{1}));
CHECK(m.all_events().size() == 3); CHECK(m.events.all_events().size() == 3);
CHECK(m.has_event(100)); CHECK(m.events.has_event(100));
CHECK(m.has_event(300)); CHECK(m.events.has_event(300));
CHECK(m.all_alarms().size() == 2); CHECK(m.alarms.all().size() == 2);
CHECK(m.alarm(1)->text == "Chiller Temp High"); CHECK(m.alarms.get(1)->text == "Chiller Temp High");
CHECK(m.alarm(1)->severity_category == 4); CHECK(m.alarms.get(1)->severity_category == 4);
CHECK(m.process_program_list().size() == 2); CHECK(m.recipes.list().size() == 2);
CHECK(m.process_program("RECIPE-A").value().find("CHAMBER ARGON") != std::string::npos); CHECK(m.recipes.get("RECIPE-A").value().find("CHAMBER ARGON") != std::string::npos);
CHECK(m.has_command("START")); CHECK(m.commands.has("START"));
auto start = m.dispatch_command("START", {}); auto start = m.commands.dispatch("START", {});
CHECK(start.ack == gem::HostCmdAck::Accept); CHECK(start.ack == gem::HostCmdAck::Accept);
CHECK(start.emit_ceid.value_or(0) == 300); CHECK(start.emit_ceid.value_or(0) == 300);
auto fault = m.dispatch_command("FAULT", {}); auto fault = m.commands.dispatch("FAULT", {});
CHECK(fault.set_alarm.value_or(0) == 1); CHECK(fault.set_alarm.value_or(0) == 1);
} }