#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
+7 -7
View File
@@ -147,7 +147,7 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (auto svids = root["svids"]) {
for (const auto& sv : svids) {
model.add_status_variable({
model.svids.add({
static_cast<uint32_t>(sv["id"].as<int>()),
sv["name"].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"]) {
for (const auto& ec : ecids) {
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>()),
ec["name"].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"]) {
for (const auto& ce : ceids) {
model.register_event({
model.events.register_event({
static_cast<uint32_t>(ce["id"].as<int>()),
ce["name"].as<std::string>(),
});
@@ -182,7 +182,7 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
if (auto alarms = root["alarms"]) {
for (const auto& a : alarms) {
model.add_alarm({
model.alarms.add({
static_cast<uint32_t>(a["id"].as<int>()),
a["text"].as<std::string>(),
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"]) {
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"]) {
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");
if (auto e = c["emit_ceid"])
spec.emit_ceid = static_cast<uint32_t>(e.as<int>());
if (auto a = c["set_alarm"])
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