Files
secs-gem/apps/secs_server.cpp
T
raphael 29db1caedb #6 SxFy codegen from YAML message catalog
The bulk of the per-SxFy boilerplate — ~90 hand-written builders and parsers
across 30+ message pairs — is now generated at build time from a single YAML
catalog. Adding a new SECS-II message becomes a YAML edit; the C++ code is
generated, not maintained.

What changed
------------

data/messages.yaml
  The catalog. Describes every SxFy currently supported: stream, function,
  W-bit, builder name, optional parser name, and a recursive body shape
  grammar (scalar / list / list_of).  Shapes carry SECS-II item types
  (ASCII, BINARY_BYTE, U4, F8, ITEM, ...) and optional C++ enum types for
  typed ack codes.  Inner-most fields can be marked external_struct: true
  so structs already defined elsewhere (ReportData, CommandParameter) are
  referenced rather than redefined.

tools/gen_messages.py
  Python codegen.  Reads the catalog and emits one inline header.  Handles
  nested shapes via depth-unique variable names in the generated IIFEs, so
  S6F11's three-level nesting compiles without lambda capture conflicts.
  Post-order traversal ensures inner structs are emitted before outer ones
  that reference them.  Generates positional and (where applicable) struct
  builder overloads, plus struct-returning parsers for messages with a
  `parser:` entry.

CMakeLists.txt
  Custom command runs gen_messages.py at configure/build time and emits
  ${CMAKE_BINARY_DIR}/generated/secsgem/gem/messages.hpp.  Added to the
  secsgem target's include path so `#include "secsgem/gem/messages.hpp"`
  resolves to the generated file.  Depends on the YAML + the script, so
  edits trigger regen automatically.

Dockerfile
  Added python3 + python3-yaml to the toolchain image.

include/secsgem/gem/messages_helpers.hpp  (new)
  The small set of hand-written helpers the generated header relies on:
  scalar accessors (as_ascii / as_u4_scalar / ...), parse_u4_list_body,
  u4_list_item, ack_byte, ALED byte constants, and the two special-case
  messages whose shape doesn't fit the codegen schema (S1F4 needs
  per-row std::optional<Item> semantics; S5F6 needs a per-row ALCD
  callback).

include/secsgem/gem/messages.hpp  (deleted)
  The hand-written builder/parser file is gone. Its content now flows
  through the catalog + codegen.

include/secsgem/gem/data_model.hpp
  Moved CommandParameter to namespace scope so it can be shared between
  the data model and the messages.yaml's external_struct entry.  Added
  `using CommandParam = CommandParameter` for back-compat.

apps/secs_server.cpp + apps/secs_client.cpp
  Updated the call sites that the codegen renamed or restructured:
  - parse_terminal_display() split into parse_s10f1 / parse_s10f3.
  - s1f14_establish_comms_ack now takes a McAck struct for the nested
    identity (mdln, softrev) — call site uses brace init.
  - S2F33/S2F35 parsers return strongly-typed entries (DefineReportEntry,
    LinkEventEntry); the server adapts these to the model's pair-based
    API at the call site.
  - S2F15 parser returns vector<EcSet>; iterate by .ecid/.value.
  - S5F3 parser returns EnableAlarmRequest{aled, alid}; bool comes from
    (aled & 0x80) != 0.
  - AlarmReport's is_set()/category() methods removed; callers use the
    raw alcd byte with bit math (alcd & 0x80, alcd & 0x7F).
  - s2f42_host_command_ack and s2f41_host_command always take their
    second list argument explicitly (no defaulted arg from codegen).

tests/test_messages.cpp
  Updated to construct the generated typed structs (EcSet, StatusName,
  EnableAlarmRequest, CommandParameter, CommandParameterAck) and to read
  the new field names (.ecid/.value, .rptid/.vids, .ceid/.rptids,
  .name/.code).

Coverage
--------

Generated by codegen (44 SxFy in catalog):

  S1F1, S1F2, S1F3, S1F11, S1F12, S1F13, S1F14, S1F15, S1F16, S1F17, S1F18
  S2F13, S2F14, S2F15, S2F16, S2F17, S2F18, S2F29, S2F30, S2F31, S2F32
  S2F33, S2F34, S2F35, S2F36, S2F37, S2F38, S2F41, S2F42
  S5F1, S5F2, S5F3, S5F4, S5F5
  S6F11, S6F12
  S7F3, S7F4, S7F5, S7F6, S7F19, S7F20
  S10F1, S10F2, S10F3, S10F4

Hand-written (in messages_helpers.hpp):

  S1F4   list-of-optional-items shape (nullopt -> <L,0>)
  S5F6   per-row ALCD via callback

Adding a new SxFy
-----------------

Append a single entry to data/messages.yaml describing the body shape.
The builder + parser appear in messages.hpp after the next build.  The
host command above for S2F41 (or any other added SxFy) requires no C++
changes if the body fits the recursive scalar/list/list_of grammar.

Tests: 67 cases / 384 assertions still passing.
Demo: byte-for-byte identical behaviour (Select, Establish, Online,
S1F11/F3 namelist+values, S2F29 EC namelist, S2F33/F35/F37 dynamic event
subscription, S2F41 START -> S6F11 emission, S5F5/F3 alarm directory +
enable, S2F41 FAULT -> S5F1 alarm + S6F11, S7F19/F5 recipe ops, S10F1
terminal, S1F15 offline, Separate).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:43:36 +02:00

324 lines
13 KiB
C++

// Passive SECS/GEM equipment. Capabilities (SVIDs, ECIDs, CEIDs, alarms,
// recipes, host commands) come from data/equipment.yaml; the E30 control
// state machine comes from data/control_state.yaml. Dispatch is a Router
// table. No imperative if-ladder; no in-code device data dictionary.
#include <asio.hpp>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/config/loader.hpp"
#include "secsgem/endpoint.hpp"
#include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp"
#include "secsgem/gem/messages.hpp"
#include "secsgem/gem/router.hpp"
#include "secsgem/secs2/message.hpp"
using namespace secsgem;
namespace s2 = secsgem::secs2;
namespace gem = secsgem::gem;
namespace {
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
for (int i = 1; i + 1 < argc; ++i)
if (key == argv[i]) return argv[i + 1];
return def;
}
constexpr uint32_t kSvidControlState = 1;
constexpr uint32_t kSvidClock = 2;
void refresh(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) {
m.set_status_value(kSvidControlState, s2::Item::ascii(gem::control_state_name(sm.state())));
m.set_status_value(kSvidClock, s2::Item::ascii(m.current_time_string()));
}
} // namespace
int main(int argc, char** argv) {
const auto port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000")));
const auto equipment_yaml = arg(argc, argv, "--config", "/app/data/equipment.yaml");
const auto state_yaml = arg(argc, argv, "--state-table", "/app/data/control_state.yaml");
auto logfn = [](const std::string& m) { std::cout << "[equip] " << m << std::endl; };
auto model = std::make_shared<gem::EquipmentDataModel>();
config::EquipmentDescriptor desc;
config::ControlStateConfig sm_cfg;
try {
desc = config::load_equipment(equipment_yaml, *model);
sm_cfg = config::load_control_state(state_yaml);
} catch (const std::exception& e) {
std::cerr << "[equip] config error: " << e.what() << std::endl;
return 1;
}
logfn("loaded " + std::to_string(model->all_status_variables().size()) + " SVIDs, " +
std::to_string(model->all_equipment_constants().size()) + " ECIDs, " +
std::to_string(model->all_events().size()) + " CEIDs, " +
std::to_string(model->all_alarms().size()) + " alarms");
auto sm = std::make_shared<gem::ControlStateMachine>(sm_cfg.table, sm_cfg.initial);
asio::io_context io;
Server::Config server_cfg{port, desc.device_id, {}};
Server server(io, server_cfg);
server.on_log(logfn);
auto active_conn = std::make_shared<std::weak_ptr<Connection>>();
auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) {
asio::post(io, [active_conn, model, logfn, ceid]() {
auto conn = active_conn->lock();
if (!conn) return;
if (!model->is_event_enabled(ceid)) {
logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed");
return;
}
auto reports = model->compose_reports_for(ceid);
logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" +
std::to_string(reports.size()) + " reports)");
conn->send_request(gem::s6f11_event_report(0, ceid, reports),
[](std::error_code, const s2::Message&) {});
});
};
auto emit_alarm_set = [&io, active_conn, model, logfn, emit_event](uint32_t alid) {
asio::post(io, [active_conn, model, logfn, emit_event, alid]() {
auto conn = active_conn->lock();
if (!conn) return;
auto alarm = model->alarm(alid);
if (!alarm) return;
auto alcd = model->alarm_set(alid);
if (!alcd) return;
if (model->alarm_enabled(alid)) {
logfn("emit S5F1 alarm set ALID=" + std::to_string(alid));
conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text),
[](std::error_code, const s2::Message&) {});
} else {
logfn("alarm " + std::to_string(alid) + " not enabled; suppressed");
}
// E30: an AlarmSetEvent CEID also fires (if linked + enabled).
});
};
sm->set_state_change_handler(
[logfn, emit_event, desc](gem::ControlState from, gem::ControlState to, gem::ControlEvent ev) {
logfn(std::string("control: ") + gem::control_state_name(from) + " -> " +
gem::control_state_name(to) + " (" + gem::control_event_name(ev) + ")");
if (desc.emit_on_control_change) emit_event(*desc.emit_on_control_change);
});
// ---- Build the SECS dispatch table once -------------------------------
gem::Router router;
router.on(1, 1, [desc, logfn](const s2::Message&) {
logfn("S1F1 -> S1F2");
return gem::s1f2_on_line_data(desc.model_name, desc.software_rev);
});
router.on(1, 3, [model, sm, logfn](const s2::Message& msg) -> std::optional<s2::Message> {
refresh(*model, *sm);
auto svids = gem::parse_s1f3(msg);
if (!svids) return s2::Message(1, 0, false);
std::vector<std::optional<s2::Item>> values;
if (svids->empty()) {
for (const auto& sv : model->all_status_variables()) values.push_back(sv.value);
} else {
for (auto id : *svids) {
auto sv = model->status_variable(id);
values.push_back(sv ? std::optional<s2::Item>(sv->value) : std::nullopt);
}
}
logfn("S1F3 -> S1F4 (" + std::to_string(values.size()) + " values)");
return gem::s1f4_selected_status_data(values);
});
router.on(1, 11, [model, logfn](const s2::Message&) {
std::vector<gem::StatusName> rows;
for (const auto& sv : model->all_status_variables())
rows.push_back({sv.id, sv.name, sv.units});
logfn("S1F11 -> S1F12 (namelist, " + std::to_string(rows.size()) + ")");
return gem::s1f12_status_namelist_data(rows);
});
router.on(1, 13, [desc, logfn](const s2::Message&) {
logfn("S1F13 -> S1F14");
return gem::s1f14_establish_comms_ack(gem::CommAck::Accept,
{desc.model_name, desc.software_rev});
});
router.on(1, 15, [sm, logfn](const s2::Message&) {
auto ack = sm->on_host_request_offline();
logfn("S1F15 -> S1F16 OFLACK=" + std::to_string(static_cast<int>(ack)));
return gem::s1f16_offline_ack(ack);
});
router.on(1, 17, [sm, logfn](const s2::Message&) {
auto ack = sm->on_host_request_online();
logfn("S1F17 -> S1F18 ONLACK=" + std::to_string(static_cast<int>(ack)));
return gem::s1f18_online_ack(ack);
});
router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional<s2::Message> {
auto ids = gem::parse_u4_list_body(msg);
if (!ids) return s2::Message(2, 0, false);
std::vector<s2::Item> values;
for (auto id : *ids) {
auto ec = model->equipment_constant(id);
values.push_back(ec ? ec->value : s2::Item::list({}));
}
logfn("S2F13 -> S2F14 (" + std::to_string(values.size()) + " values)");
return gem::s2f14_ec_data(values);
});
router.on(2, 15, [model, logfn](const s2::Message& msg) {
auto sets = gem::parse_s2f15(msg);
auto eac = gem::EquipmentAck::Accept;
if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange;
else
for (const auto& s : *sets) {
auto r = model->set_equipment_constant_value(s.ecid, s.value);
if (r != gem::EquipmentAck::Accept) eac = r;
}
logfn("S2F15 -> S2F16 EAC=" + std::to_string(static_cast<int>(eac)));
return gem::s2f16_ec_ack(eac);
});
router.on(2, 17, [model, logfn](const s2::Message&) {
logfn("S2F17 -> S2F18 (clock)");
return gem::s2f18_date_time_data(model->current_time_string());
});
router.on(2, 29, [model, logfn](const s2::Message& msg) {
auto ids = gem::parse_u4_list_body(msg);
std::vector<gem::EquipmentConstant> ecs;
if (ids && ids->empty()) ecs = model->all_equipment_constants();
else if (ids)
for (auto id : *ids) {
auto ec = model->equipment_constant(id);
if (ec) ecs.push_back(*ec);
}
std::vector<gem::EcNameRow> rows;
for (const auto& ec : ecs)
rows.push_back({ec.id, ec.name, ec.min_str, ec.max_str, "", ec.units});
logfn("S2F29 -> S2F30 (" + std::to_string(rows.size()) + " ECs)");
return gem::s2f30_ec_namelist_data(rows);
});
router.on(2, 31, [model, logfn](const s2::Message& msg) {
auto t = gem::parse_s2f31(msg);
auto ack = t ? model->set_time_string(*t) : gem::TimeAck::Error;
logfn("S2F31 -> S2F32 TIACK=" + std::to_string(static_cast<int>(ack)));
return gem::s2f32_date_time_ack(ack);
});
router.on(2, 33, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s2f33(msg);
auto ack = gem::DefineReportAck::InvalidFormat;
if (req) {
std::vector<std::pair<uint32_t, std::vector<uint32_t>>> rows;
rows.reserve(req->reports.size());
for (const auto& r : req->reports) rows.emplace_back(r.rptid, r.vids);
ack = model->define_reports(rows);
}
logfn("S2F33 -> S2F34 DRACK=" + std::to_string(static_cast<int>(ack)));
return gem::s2f34_define_report_ack(ack);
});
router.on(2, 35, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s2f35(msg);
auto ack = gem::LinkEventAck::InvalidFormat;
if (req) {
std::vector<std::pair<uint32_t, std::vector<uint32_t>>> rows;
rows.reserve(req->links.size());
for (const auto& l : req->links) rows.emplace_back(l.ceid, l.rptids);
ack = model->link_event_reports(rows);
}
logfn("S2F35 -> S2F36 LRACK=" + std::to_string(static_cast<int>(ack)));
return gem::s2f36_link_event_report_ack(ack);
});
router.on(2, 37, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s2f37(msg);
auto ack = req ? model->enable_events(req->enable, req->ceids)
: gem::EnableEventAck::UnknownCeid;
logfn(std::string("S2F37 ") + (req && req->enable ? "enable" : "disable") +
" -> S2F38 ERACK=" + std::to_string(static_cast<int>(ack)));
return gem::s2f38_enable_event_ack(ack);
});
router.on(2, 41, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) {
auto cmd = gem::parse_s2f41(msg);
if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {});
auto result = model->dispatch_command(cmd->rcmd, cmd->params);
logfn("S2F41 RCMD=" + cmd->rcmd + " -> S2F42 HCACK=" +
std::to_string(static_cast<int>(result.ack)));
if (result.ack == gem::HostCmdAck::Accept) {
if (result.emit_ceid) emit_event(*result.emit_ceid);
if (result.set_alarm) emit_alarm_set(*result.set_alarm);
}
return gem::s2f42_host_command_ack(result.ack, {});
});
router.on(5, 3, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s5f3(msg);
auto ack = req ? model->set_alarm_enabled(req->alid, (req->aled & 0x80) != 0)
: gem::AlarmAck::Error;
logfn(std::string("S5F3 -> S5F4 ACKC5=") + std::to_string(static_cast<int>(ack)));
return gem::s5f4_enable_alarm_ack(ack);
});
router.on(5, 5, [model, logfn](const s2::Message& msg) {
auto ids = gem::parse_u4_list_body(msg);
std::vector<gem::Alarm> alarms;
if (ids && ids->empty()) alarms = model->all_alarms();
else if (ids)
for (auto id : *ids) {
auto a = model->alarm(id);
if (a) alarms.push_back(*a);
}
logfn("S5F5 -> S5F6 (" + std::to_string(alarms.size()) + " alarms)");
return gem::s5f6_list_alarms_data(
alarms, [model](uint32_t id) { return model->alarm_active(id); });
});
router.on(7, 3, [model, logfn](const s2::Message& msg) {
auto pp = gem::parse_s7f3(msg);
if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError);
model->add_process_program(pp->ppid, pp->ppbody);
logfn("S7F3 PPID=" + pp->ppid + " -> S7F4 (Accept)");
return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept);
});
router.on(7, 5, [model, logfn](const s2::Message& msg) {
auto ppid = gem::parse_s7f5(msg);
if (!ppid) return gem::s7f6_process_program_data("", "");
auto body = model->process_program(*ppid);
logfn("S7F5 PPID=" + *ppid + " -> S7F6");
return gem::s7f6_process_program_data(*ppid, body ? *body : "");
});
router.on(7, 19, [model, logfn](const s2::Message&) {
auto list = model->process_program_list();
logfn("S7F19 -> S7F20 (" + std::to_string(list.size()) + " PPIDs)");
return gem::s7f20_current_eppd_data(list);
});
router.on(10, 1, [logfn](const s2::Message& msg) {
auto td = gem::parse_s10f1(msg);
if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text);
return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted);
});
logfn("registered " + std::to_string(router.size()) + " (stream,function) handlers");
// ---- Wire the router into accepted connections -----------------------
server.on_connection([sm, model, logfn, active_conn, &router, desc](
std::shared_ptr<Connection> conn) {
*active_conn = conn;
conn->set_closed_handler([active_conn](const std::string&) { active_conn->reset(); });
conn->set_selected_handler([logfn, sm]() {
logfn(std::string("host is online; control=") + gem::control_state_name(sm->state()));
});
conn->set_message_handler(
[&router](const s2::Message& msg) { return router.dispatch(msg); });
});
server.start();
io.run();
return 0;
}