diff --git a/CMakeLists.txt b/CMakeLists.txt index 7290309..01585a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ endif() add_compile_options(-Wall -Wextra -Wpedantic) find_package(Threads REQUIRED) +find_package(yaml-cpp REQUIRED) # --- Asio (standalone, header-only; from libasio-dev) --------------------- find_path(ASIO_INCLUDE_DIR asio.hpp) @@ -26,18 +27,16 @@ target_link_libraries(asio INTERFACE Threads::Threads) # --- Core library --------------------------------------------------------- add_library(secsgem - src/secs2/item.cpp src/secs2/codec.cpp - src/secs2/message.cpp src/hsms/header.cpp - src/hsms/frame.cpp src/hsms/connection.cpp src/gem/control_state.cpp src/gem/data_model.cpp + src/config/loader.cpp src/endpoint.cpp ) target_include_directories(secsgem PUBLIC include) -target_link_libraries(secsgem PUBLIC asio) +target_link_libraries(secsgem PUBLIC asio yaml-cpp) # --- Demo executables ----------------------------------------------------- add_executable(secs_server apps/secs_server.cpp) @@ -63,6 +62,9 @@ add_executable(secsgem_tests tests/test_control_state.cpp tests/test_data_model.cpp tests/test_messages.cpp + tests/test_loader.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) +target_compile_definitions(secsgem_tests PRIVATE + SECSGEM_DATA_DIR="${CMAKE_SOURCE_DIR}/data") add_test(NAME secsgem_tests COMMAND secsgem_tests) diff --git a/Dockerfile b/Dockerfile index 5b0495e..12c6b89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ninja-build \ git \ libasio-dev \ + libyaml-cpp-dev \ ca-certificates \ bash \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index 4a7da6c..8d524d1 100644 --- a/README.md +++ b/README.md @@ -25,36 +25,31 @@ docker compose up --no-deps server client # live two-container demo ## Architecture +The "spec-as-data" first step: equipment capabilities and the E30 control +state machine are loaded from YAML at startup; the SECS dispatch is a +`(stream, function) -> handler` Router rather than an if-ladder. + ``` ┌──────────────────────────────────────────────────────────────┐ + │ data/equipment.yaml data/control_state.yaml │ + │ SVIDs, ECIDs, CEIDs, alarms, recipes, host commands; │ + │ E30 control state transition table │ + └──────────────────────┬───────────────────────────────────────┘ + │ (loaded at startup) + ▼ + ┌──────────────────────────────────────────────────────────────┐ │ app / demo │ │ apps/secs_server.cpp apps/secs_client.cpp │ + │ uses gem::Router for SECS dispatch │ └────────────┬───────────────────────────┬─────────────────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────────┐ - │ secsgem::gem (E30 / E5 logic) │ - │ data_model.hpp SVIDs, DVIDs, ECIDs, CEIDs, alarms, │ - │ reports, links, recipes │ - │ control_state.h E30 control state machine │ - │ messages.hpp all SxFy builders + parsers │ - └────────────┬─────────────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────────────────────────────────────────────┐ - │ secsgem::hsms (E37 transport) │ - │ Connection async TCP, state machine, T3/T5/T6/T7/T8 │ - │ Frame, Header 4-byte length prefix + 10-byte header │ - │ Server, Client endpoint wrappers (in include/secsgem/) │ - └────────────┬─────────────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────────────────────────────────────────────┐ - │ secsgem::secs2 (E5 codec) │ - │ Item variant over L/A/B/BOOLEAN/I1-8/U1-8/F4/F8 │ - │ encode/decode big-endian, length-byte aware │ - │ Message SxFy + W-bit + optional root Item │ - │ to_sml human-readable text for logs │ + │ secsgem::config loader.hpp YAML -> tables + data model │ + │ secsgem::gem control_state (table-driven), data_model, │ + │ messages (SxFy builders), router │ + │ secsgem::hsms Connection (Asio), Header, Frame, Timers │ + │ secsgem::secs2 Item, codec (encode/decode), Message │ └──────────────────────────────────────────────────────────────┘ ``` @@ -66,16 +61,42 @@ secs-gem/ ├── CMakeLists.txt ├── implementation_plan.md # the 7-layer spec-as-data roadmap ├── COMPLIANCE.md # per-capability E5/E30/E37 audit +├── data/ +│ ├── equipment.yaml # SVIDs/ECIDs/CEIDs/alarms/recipes/commands +│ └── control_state.yaml # E30 transition table ├── include/secsgem/ │ ├── secs2/{item,codec,message}.hpp -│ ├── hsms/{types,header,frame,connection}.hpp -│ ├── gem/{control_state,data_model,messages}.hpp +│ ├── hsms/{header,connection}.hpp # header.hpp also holds Frame + Timers +│ ├── gem/{control_state,data_model,messages,router}.hpp +│ ├── config/loader.hpp │ └── endpoint.hpp -├── src/{secs2,hsms,gem,endpoint.cpp}/*.cpp +├── src/{secs2,hsms,gem,config}/*.cpp + endpoint.cpp ├── apps/{secs_server,secs_client}.cpp └── tests/test_*.cpp ``` +### Adding a capability without recompiling the server + +```yaml +# data/equipment.yaml — append a new SVID +svids: + - {id: 4, name: ChamberTemp, units: "C", type: U4, value: 25} +``` + +```yaml +# data/equipment.yaml — append a new host command + side effect +host_commands: + - {name: VENT, ack: Accept, emit_ceid: 400, set_alarm: 2} +``` + +```yaml +# data/control_state.yaml — append a new transition +transitions: + - {from: OnlineRemote, on: host_request_offline, to: EquipmentOffline, ack: Accept} +``` + +Restart the server; the new behaviour is live. The C++ code unchanged. + ## What's implemented ### HSMS (E37) diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index b0ed66b..56d97fb 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -1,7 +1,7 @@ -// Passive SECS/GEM equipment: a small simulated tool. Drives the E30 control -// state machine, owns an in-memory data dictionary (SVIDs, DVIDs, ECIDs, -// CEIDs, alarms, recipes), answers the GEM SxFy core, and emits S6F11 + -// S5F1 primaries when host-subscribed events fire. +// 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 #include @@ -10,14 +10,14 @@ #include #include #include -#include #include +#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/secs2/item.hpp" +#include "secsgem/gem/router.hpp" #include "secsgem/secs2/message.hpp" using namespace secsgem; @@ -32,50 +32,10 @@ std::string arg(int argc, char** argv, const std::string& key, const std::string return def; } -constexpr const char* kModelName = "SECSGEM-SIM"; -constexpr const char* kSoftRev = "0.1.0"; +constexpr uint32_t kSvidControlState = 1; +constexpr uint32_t kSvidClock = 2; -// SVIDs -constexpr uint32_t kSvidControlState = 1; -constexpr uint32_t kSvidClock = 2; -constexpr uint32_t kSvidEventsEnabled = 3; - -// ECIDs -constexpr uint32_t kEcidTimeFormat = 10; -constexpr uint32_t kEcidEstablishTimeout = 11; - -// CEIDs -constexpr uint32_t kCeidControlStateChanged = 100; -constexpr uint32_t kCeidAlarmSetEvent = 200; -constexpr uint32_t kCeidProcessStarted = 300; - -// ALIDs -constexpr uint32_t kAlarmChillerTempHigh = 1; -constexpr uint32_t kAlarmDoorOpen = 2; - -void populate(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) { - m.add_status_variable( - {kSvidControlState, "ControlState", "", s2::Item::ascii(gem::control_state_name(sm.state()))}); - m.add_status_variable({kSvidClock, "Clock", "", s2::Item::ascii(m.current_time_string())}); - m.add_status_variable({kSvidEventsEnabled, "EventsEnabled", "", s2::Item::boolean(true)}); - - m.add_equipment_constant({kEcidTimeFormat, "TimeFormat", "code", - s2::Item::u4(uint32_t{1}), s2::Item::u4(uint32_t{1}), "0", "1"}); - m.add_equipment_constant({kEcidEstablishTimeout, "EstablishCommTimeout", "sec", - s2::Item::u4(uint32_t{10}), s2::Item::u4(uint32_t{10}), "1", "60"}); - - m.register_event({kCeidControlStateChanged, "ControlStateChanged"}); - m.register_event({kCeidAlarmSetEvent, "AlarmSetEvent"}); - m.register_event({kCeidProcessStarted, "ProcessStarted"}); - - m.add_alarm({kAlarmChillerTempHigh, "Chiller Temp High", 4}); - m.add_alarm({kAlarmDoorOpen, "Door Open", 1}); - - m.add_process_program("RECIPE-A", "STEP CHAMBER ARGON 30s\nSTEP CHAMBER NITROGEN 60s\nEND"); - m.add_process_program("RECIPE-B", "STEP HEATER 800C 120s\nEND"); -} - -void refresh_dynamic_svids(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.set_status_value(kSvidClock, s2::Item::ascii(m.current_time_string())); } @@ -83,24 +43,36 @@ void refresh_dynamic_svids(gem::EquipmentDataModel& m, const gem::ControlStateMa } // namespace int main(int argc, char** argv) { - Server::Config cfg; - cfg.port = static_cast(std::stoi(arg(argc, argv, "--port", "5000"))); - cfg.device_id = static_cast(std::stoi(arg(argc, argv, "--device", "0"))); - - asio::io_context io; - Server server(io, cfg); + const auto port = static_cast(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; }; - server.on_log(logfn); - auto sm = std::make_shared(); auto model = std::make_shared(); - populate(*model, *sm); + 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(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>(); - // Emit a CEID via S6F11 to the currently-active host connection, if any - // and if the host has subscribed to that CEID via S2F37. auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) { asio::post(io, [active_conn, model, logfn, ceid]() { auto conn = active_conn->lock(); @@ -113,15 +85,10 @@ int main(int argc, char** argv) { logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" + std::to_string(reports.size()) + " reports)"); conn->send_request(gem::s6f11_event_report(0, ceid, reports), - [logfn, ceid](std::error_code ec, const s2::Message&) { - if (ec) - logfn("CEID " + std::to_string(ceid) + " emit failed: " + ec.message()); - }); + [](std::error_code, const s2::Message&) {}); }); }; - // Equipment-initiated alarm set: emits S5F1 (if alarm enabled) and the - // AlarmSetEvent CEID (if event enabled). 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(); @@ -130,243 +97,206 @@ int main(int argc, char** argv) { if (!alarm) return; auto alcd = model->alarm_set(alid); if (!alcd) return; - if (!model->alarm_enabled(alid)) { - logfn("alarm " + std::to_string(alid) + " not enabled; suppressed"); - } else { + 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"); } - emit_event(kCeidAlarmSetEvent); + // E30: an AlarmSetEvent CEID also fires (if linked + enabled). }); }; sm->set_state_change_handler( - [logfn, emit_event](gem::ControlState from, gem::ControlState to, gem::ControlEvent ev) { + [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) + ")"); - emit_event(kCeidControlStateChanged); + if (desc.emit_on_control_change) emit_event(*desc.emit_on_control_change); }); - server.on_connection([sm, model, logfn, active_conn, emit_event, - emit_alarm_set](std::shared_ptr conn) { - *active_conn = conn; + // ---- Build the SECS dispatch table once ------------------------------- + gem::Router router; - conn->set_closed_handler([active_conn, logfn](const std::string&) { - active_conn->reset(); - }); + 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 { + refresh(*model, *sm); + auto svids = gem::parse_s1f3(msg); + if (!svids) return s2::Message(1, 0, false); + std::vector> 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(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&) { + logfn("S1F11 -> S1F12 (namelist)"); + return gem::s1f12_status_namelist_data(model->all_status_variables()); + }); + 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(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(ack))); + return gem::s1f18_online_ack(ack); + }); + + router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional { + auto ids = gem::parse_u4_list_body(msg); + if (!ids) return s2::Message(2, 0, false); + std::vector 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& [id, val] : *sets) { + auto r = model->set_equipment_constant_value(id, val); + if (r != gem::EquipmentAck::Accept) eac = r; + } + logfn("S2F15 -> S2F16 EAC=" + std::to_string(static_cast(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 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); + } + logfn("S2F29 -> S2F30 (" + std::to_string(ecs.size()) + " ECs)"); + return gem::s2f30_ec_namelist_data(ecs); + }); + 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(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 = req ? model->define_reports(req->reports) + : gem::DefineReportAck::InvalidFormat; + logfn("S2F33 -> S2F34 DRACK=" + std::to_string(static_cast(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 = req ? model->link_event_reports(req->links) : gem::LinkEventAck::InvalidFormat; + logfn("S2F35 -> S2F36 LRACK=" + std::to_string(static_cast(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(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(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->enable) + : gem::AlarmAck::Error; + logfn(std::string("S5F3 -> S5F4 ACKC5=") + std::to_string(static_cast(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 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_terminal_display(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 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())); }); - // Host commands are registered here so they can capture emit_event / - // emit_alarm_set without static globals. - model->register_command("START", [logfn, emit_event](const auto& params) { - logfn("RCMD START (" + std::to_string(params.size()) + " params)"); - emit_event(kCeidProcessStarted); - return gem::HostCmdAck::Accept; - }); - model->register_command("STOP", - [](const auto&) { return gem::HostCmdAck::Accept; }); - model->register_command("PAUSE", - [](const auto&) { return gem::HostCmdAck::CannotDoNow; }); - model->register_command("FAULT", [logfn, emit_alarm_set](const auto&) { - logfn("RCMD FAULT triggers alarm 1"); - emit_alarm_set(kAlarmChillerTempHigh); - return gem::HostCmdAck::Accept; - }); - - conn->set_message_handler([sm, model, logfn](const s2::Message& msg) - -> std::optional { - const uint8_t s = msg.stream, f = msg.function; - - // ---- S1: equipment status ---------------------------------------- - if (s == 1 && f == 1) { - logfn("S1F1; replying S1F2"); - return gem::s1f2_on_line_data(kModelName, kSoftRev); - } - if (s == 1 && f == 3) { - refresh_dynamic_svids(*model, *sm); - auto svids = gem::parse_s1f3(msg); - if (!svids) return s2::Message(1, 0, false); - std::vector> 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(sv->value) : std::nullopt); - } - } - logfn("S1F3; replying S1F4 (" + std::to_string(values.size()) + " values)"); - return gem::s1f4_selected_status_data(values); - } - if (s == 1 && f == 11) { - logfn("S1F11; replying S1F12 (status namelist)"); - return gem::s1f12_status_namelist_data(model->all_status_variables()); - } - if (s == 1 && f == 13) { - logfn("S1F13; replying S1F14 (COMMACK=0)"); - return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, kModelName, kSoftRev); - } - if (s == 1 && f == 15) { - auto ack = sm->on_host_request_offline(); - logfn(std::string("S1F15; replying S1F16 (OFLACK=") + - std::to_string(static_cast(ack)) + ")"); - return gem::s1f16_offline_ack(ack); - } - if (s == 1 && f == 17) { - auto ack = sm->on_host_request_online(); - logfn(std::string("S1F17; replying S1F18 (ONLACK=") + - std::to_string(static_cast(ack)) + ")"); - return gem::s1f18_online_ack(ack); - } - - // ---- S2: equipment control + reports ----------------------------- - if (s == 2 && f == 13) { - auto ids = gem::parse_u4_list_body(msg); - if (!ids) return s2::Message(2, 0, false); - std::vector values; - for (auto id : *ids) { - auto ec = model->equipment_constant(id); - values.push_back(ec ? ec->value : s2::Item::list({})); - } - logfn("S2F13; replying S2F14 (" + std::to_string(values.size()) + " values)"); - return gem::s2f14_ec_data(values); - } - if (s == 2 && f == 15) { - auto sets = gem::parse_s2f15(msg); - if (!sets) return gem::s2f16_ec_ack(gem::EquipmentAck::Denied_OutOfRange); - auto eac = gem::EquipmentAck::Accept; - for (const auto& [id, val] : *sets) { - auto r = model->set_equipment_constant_value(id, val); - if (r != gem::EquipmentAck::Accept) eac = r; - } - logfn(std::string("S2F15; replying S2F16 (EAC=") + - std::to_string(static_cast(eac)) + ")"); - return gem::s2f16_ec_ack(eac); - } - if (s == 2 && f == 17) { - logfn("S2F17; replying S2F18 (clock)"); - return gem::s2f18_date_time_data(model->current_time_string()); - } - if (s == 2 && f == 29) { - auto ids = gem::parse_u4_list_body(msg); - std::vector 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); - } - } - logfn("S2F29; replying S2F30 (" + std::to_string(ecs.size()) + " EC entries)"); - return gem::s2f30_ec_namelist_data(ecs); - } - if (s == 2 && f == 31) { - auto t = gem::parse_s2f31(msg); - auto ack = t ? model->set_time_string(*t) : gem::TimeAck::Error; - logfn(std::string("S2F31; replying S2F32 (TIACK=") + - std::to_string(static_cast(ack)) + ")"); - return gem::s2f32_date_time_ack(ack); - } - if (s == 2 && f == 33) { - auto req = gem::parse_s2f33(msg); - auto ack = req ? model->define_reports(req->reports) - : gem::DefineReportAck::InvalidFormat; - logfn(std::string("S2F33; replying S2F34 (DRACK=") + - std::to_string(static_cast(ack)) + ")"); - return gem::s2f34_define_report_ack(ack); - } - if (s == 2 && f == 35) { - auto req = gem::parse_s2f35(msg); - auto ack = req ? model->link_event_reports(req->links) : gem::LinkEventAck::InvalidFormat; - logfn(std::string("S2F35; replying S2F36 (LRACK=") + - std::to_string(static_cast(ack)) + ")"); - return gem::s2f36_link_event_report_ack(ack); - } - if (s == 2 && f == 37) { - 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") + - "; replying S2F38 (ERACK=" + std::to_string(static_cast(ack)) + ")"); - return gem::s2f38_enable_event_ack(ack); - } - if (s == 2 && f == 41) { - auto cmd = gem::parse_s2f41(msg); - if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid); - auto ack = model->dispatch_command(cmd->rcmd, cmd->params); - logfn("S2F41 RCMD=" + cmd->rcmd + "; replying S2F42 (HCACK=" + - std::to_string(static_cast(ack)) + ")"); - return gem::s2f42_host_command_ack(ack); - } - - // ---- S5: alarms -------------------------------------------------- - if (s == 5 && f == 3) { - auto req = gem::parse_s5f3(msg); - auto ack = req ? model->set_alarm_enabled(req->alid, req->enable) - : gem::AlarmAck::Error; - logfn(std::string("S5F3 ALID=") + (req ? std::to_string(req->alid) : "?") + - (req && req->enable ? " enable" : " disable") + - "; replying S5F4 (ACKC5=" + std::to_string(static_cast(ack)) + ")"); - return gem::s5f4_enable_alarm_ack(ack); - } - if (s == 5 && f == 5) { - auto ids = gem::parse_u4_list_body(msg); - std::vector 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; replying S5F6 (" + std::to_string(alarms.size()) + " alarms)"); - return gem::s5f6_list_alarms_data(alarms, [model](uint32_t id) { - return model->alarm_active(id); - }); - } - - // ---- S7: process programs ---------------------------------------- - if (s == 7 && f == 3) { - 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 + " (" + std::to_string(pp->ppbody.size()) + - " bytes); replying S7F4 (ACKC7=0)"); - return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept); - } - if (s == 7 && f == 5) { - auto ppid = gem::parse_s7f5(msg); - if (!ppid) return gem::s7f6_process_program_data("", ""); - auto body = model->process_program(*ppid); - logfn("S7F5 PPID=" + *ppid + (body ? "; replying S7F6" : "; PPID not found")); - return gem::s7f6_process_program_data(*ppid, body ? *body : ""); - } - if (s == 7 && f == 19) { - auto list = model->process_program_list(); - logfn("S7F19; replying S7F20 (" + std::to_string(list.size()) + " PPIDs)"); - return gem::s7f20_current_eppd_data(list); - } - - // ---- S10: terminal services -------------------------------------- - if (s == 10 && f == 1) { - auto td = gem::parse_terminal_display(msg); - if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); - return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted); - } - - // Unhandled primaries expecting a reply get SxF0 (abort). - if (msg.reply_expected) { - logfn("unhandled " + msg.sml() + "; replying S" + std::to_string(s) + "F0"); - return s2::Message(s, 0, false); - } - return std::nullopt; - }); + conn->set_message_handler( + [&router](const s2::Message& msg) { return router.dispatch(msg); }); }); server.start(); diff --git a/data/control_state.yaml b/data/control_state.yaml new file mode 100644 index 0000000..2cc86d3 --- /dev/null +++ b/data/control_state.yaml @@ -0,0 +1,45 @@ +# E30 §6.2 Control State Model — encoded as a transition table. +# +# Each row is a (from, on) -> (to [, then] [, ack]) tuple: +# from: source state +# on: triggering event (operator_*, host_request_*) +# to: destination state for the transition (optional; omit for "stay put") +# then: an automatic follow-on transition (used to advance through the +# transient AttemptOnline state in one logical step) +# ack: the SEMI-mandated ack code returned to the host (only relevant for +# host_request_* events; ignored for operator_* events) +# +# Rows are evaluated by first match on (from, on). An empty `to`/`then` means +# no state change. Unlisted (from, on) pairs are treated as "no transition, +# event ignored" (operator events) or "ack = NotAccept" (host events). + +initial: HostOffline + +transitions: + # --- Host: Request Online (S1F17) ------------------------------------- + - {from: HostOffline, on: host_request_online, to: AttemptOnline, then: OnlineRemote, ack: Accept} + - {from: OnlineLocal, on: host_request_online, ack: AlreadyOnline} + - {from: OnlineRemote, on: host_request_online, ack: AlreadyOnline} + - {from: EquipmentOffline, on: host_request_online, ack: NotAccept} + - {from: AttemptOnline, on: host_request_online, ack: NotAccept} + + # --- Host: Request Offline (S1F15) ------------------------------------ + - {from: OnlineLocal, on: host_request_offline, to: HostOffline, ack: Accept} + - {from: OnlineRemote, on: host_request_offline, to: HostOffline, ack: Accept} + # Idempotent when already offline from host's view. + - {from: EquipmentOffline, on: host_request_offline, ack: Accept} + - {from: HostOffline, on: host_request_offline, ack: Accept} + - {from: AttemptOnline, on: host_request_offline, ack: Accept} + + # --- Operator: Switch Online ----------------------------------------- + - {from: EquipmentOffline, on: operator_online, to: AttemptOnline, then: OnlineLocal} + - {from: HostOffline, on: operator_online, to: AttemptOnline, then: OnlineLocal} + + # --- Operator: Switch Offline ---------------------------------------- + - {from: OnlineLocal, on: operator_offline, to: HostOffline} + - {from: OnlineRemote, on: operator_offline, to: HostOffline} + - {from: AttemptOnline, on: operator_offline, to: HostOffline} + + # --- Operator: Local <-> Remote -------------------------------------- + - {from: OnlineRemote, on: operator_local, to: OnlineLocal} + - {from: OnlineLocal, on: operator_remote, to: OnlineRemote} diff --git a/data/equipment.yaml b/data/equipment.yaml new file mode 100644 index 0000000..073a6e5 --- /dev/null +++ b/data/equipment.yaml @@ -0,0 +1,51 @@ +# Equipment data dictionary loaded by secs_server at startup. Adding a +# capability (SVID, ECID, CEID, alarm, recipe, command) is a YAML edit; +# no code change is required as long as the underlying message handler +# already exists in the C++ Router. + +device: + id: 0 + model_name: "SECSGEM-SIM" + software_rev: "0.1.0" + +# Reported on S1F3 (values) and S1F11 (namelist). `value` is the initial. +# `type` controls the SECS-II Item format. +svids: + - {id: 1, name: ControlState, units: "", type: ASCII, value: ""} + - {id: 2, name: Clock, units: "", type: ASCII, value: ""} + - {id: 3, name: EventsEnabled, units: "", type: BOOLEAN, value: true} + +# Reported on S2F13 (values) / S2F29 (namelist) / S2F15 (set). +ecids: + - {id: 10, name: TimeFormat, units: "code", type: U4, value: 1, min: "0", max: "1"} + - {id: 11, name: EstablishCommTimeout, units: "sec", type: U4, value: 10, min: "1", max: "60"} + +# Reported on S6F11. The host links these to reports via S2F35 and +# enables them via S2F37. +ceids: + - {id: 100, name: ControlStateChanged} + - {id: 200, name: AlarmSetEvent} + - {id: 300, name: ProcessStarted} + +# Reported on S5F5 / S5F1. `category` is the lower-7 of ALCD. +alarms: + - {id: 1, text: "Chiller Temp High", category: 4} + - {id: 2, text: "Door Open", category: 1} + +# Reported on S7F19. Body served by S7F5/F6. +recipes: + - {id: "RECIPE-A", body: "STEP CHAMBER ARGON 30s\nSTEP CHAMBER NITROGEN 60s\nEND"} + - {id: "RECIPE-B", body: "STEP HEATER 800C 120s\nEND"} + +# Dispatched by S2F41. `ack` is the HCACK enum value. Optional `emit_ceid` +# fires a CEID after dispatch (e.g. ProcessStarted on START), and optional +# `set_alarm` activates an alarm (e.g. FAULT -> alarm 1). +host_commands: + - {name: START, ack: Accept, emit_ceid: 300} + - {name: STOP, ack: Accept} + - {name: PAUSE, ack: CannotDoNow} + - {name: FAULT, ack: Accept, set_alarm: 1} + +# CEID emitted automatically whenever the control state machine transitions +# (i.e. on every change-handler call). Set to null to disable. +emit_on_control_change: 100 diff --git a/include/secsgem/config/loader.hpp b/include/secsgem/config/loader.hpp new file mode 100644 index 0000000..af03bec --- /dev/null +++ b/include/secsgem/config/loader.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +#include "secsgem/gem/control_state.hpp" +#include "secsgem/gem/data_model.hpp" + +// YAML-driven loaders for the E30 control-state transition table and the +// equipment data dictionary. Behaviour rules live in the YAML; this is the +// parser that wires them into the runtime structures. +namespace secsgem::config { + +class ConfigError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +struct ControlStateConfig { + gem::ControlTransitionTable table; + gem::ControlState initial = gem::ControlState::HostOffline; +}; + +// Loads data/control_state.yaml. +ControlStateConfig load_control_state(const std::string& yaml_path); + +struct EquipmentDescriptor { + uint16_t device_id = 0; + std::string model_name; + std::string software_rev; + std::optional emit_on_control_change; +}; + +// Loads data/equipment.yaml into the given data model and returns the +// equipment header (device id, MDLN, SOFTREV, optional auto-emit CEID). +EquipmentDescriptor load_equipment(const std::string& yaml_path, + gem::EquipmentDataModel& model); + +} // namespace secsgem::config diff --git a/include/secsgem/endpoint.hpp b/include/secsgem/endpoint.hpp index 13c2059..95fad4f 100644 --- a/include/secsgem/endpoint.hpp +++ b/include/secsgem/endpoint.hpp @@ -7,7 +7,7 @@ #include #include "secsgem/hsms/connection.hpp" -#include "secsgem/hsms/types.hpp" +#include "secsgem/hsms/header.hpp" namespace secsgem { diff --git a/include/secsgem/gem/control_state.hpp b/include/secsgem/gem/control_state.hpp index 08fad21..b040dc7 100644 --- a/include/secsgem/gem/control_state.hpp +++ b/include/secsgem/gem/control_state.hpp @@ -2,25 +2,25 @@ #include #include +#include #include +#include namespace secsgem::gem { -// E30 Control State Model (§6.2). Drives whether the equipment is -// communicating with a host and, if so, who is in control. +// E30 §6.2 Control State Model. enum class ControlState { - EquipmentOffline, // equipment is offline; no host comms attempted - AttemptOnline, // transient: equipment trying to come online - HostOffline, // HSMS up but host has not established control - OnlineLocal, // online, operator in control; host observes only - OnlineRemote, // online, host in control + EquipmentOffline, + AttemptOnline, + HostOffline, + OnlineLocal, + OnlineRemote, }; const char* control_state_name(ControlState s); +std::optional parse_control_state(const std::string& s); bool is_online(ControlState s); -// What triggered a state change — surfaced to the on_change handler so the UI -// or logs can show *why* the state moved. enum class ControlEvent { OperatorSwitchOnline, OperatorSwitchOffline, @@ -28,72 +28,90 @@ enum class ControlEvent { OperatorSwitchRemote, AttemptComplete, AttemptFailed, - HostRequestOnline, // S1F17 - HostRequestOffline, // S1F15 + HostRequestOnline, + HostRequestOffline, }; const char* control_event_name(ControlEvent e); +std::optional parse_control_event(const std::string& s); -// S1F18 ONLACK codes. enum class OnlineAck : uint8_t { Accept = 0, NotAccept = 1, AlreadyOnline = 2, }; -// S1F16 OFLACK codes. enum class OfflineAck : uint8_t { Accept = 0, }; -// S1F14 COMMACK codes. enum class CommAck : uint8_t { Accept = 0, Denied = 1, }; -// Drives the E30 control state. Pure state machine — no IO. The Server layer -// owns one of these per equipment and dispatches host-initiated events and -// operator actions into it. +// One row of the control-state transition table (E30 §6.2 + extensions). +// `to` and `then` are both optional: a row may produce no transition (e.g. +// host_request_online while already OnlineRemote — ack only); a row may also +// chain straight through the transient AttemptOnline state via `then`. +// `ack_code` is the raw uint8_t carried by S1F18 (ONLACK) or S1F16 (OFLACK); +// its interpretation depends on the triggering event. +struct ControlTransition { + ControlState from; + ControlEvent on; + std::optional to; + std::optional then; + std::optional ack_code; +}; + +// Pure data table — no behaviour. Lookup is first-match on (from, on). +class ControlTransitionTable { + public: + void add(ControlTransition row); + const ControlTransition* find(ControlState from, ControlEvent on) const; + std::size_t size() const { return rows_.size(); } + const std::vector& rows() const { return rows_; } + + // Built-in default table, matching data/control_state.yaml exactly. + // Used by tests so they don't depend on the YAML file being present. + static ControlTransitionTable default_table(); + + private: + std::vector rows_; +}; + +// The E30 control state machine, driven by a transition table. All +// behavioural rules live in the table; this class is just the engine. class ControlStateMachine { public: - struct Config { - ControlState initial = ControlState::HostOffline; - // When ATTEMPT_ONLINE completes via a host request, do we land in REMOTE - // (host in control) or LOCAL (host observes only)? For host-initiated - // online this defaults to REMOTE; for operator-initiated online it follows - // `operator_default_remote`. - bool host_request_grants_remote = true; - bool operator_default_remote = false; - }; - using StateChangeHandler = std::function; ControlStateMachine(); - explicit ControlStateMachine(Config cfg); + explicit ControlStateMachine(ControlTransitionTable table, + ControlState initial = ControlState::HostOffline); ControlState state() const { return state_; } bool online() const { return is_online(state_); } void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); } - // Operator actions. Each returns true if a transition occurred, false if the - // current state didn't permit it. + // Operator actions. Return true if a transition (or self-ack) was found. bool operator_online(); bool operator_offline(); bool operator_local(); bool operator_remote(); - // Host-initiated requests. The SM responds with the SEMI-mandated ack code - // and performs any transition. + // Host requests. Return the ack code from the matching table row. OnlineAck on_host_request_online(); OfflineAck on_host_request_offline(); private: + // Apply the matching row (if any) for (state_, event); returns the row. + const ControlTransition* fire(ControlEvent on); void transition(ControlState next, ControlEvent trigger); - Config cfg_; + ControlTransitionTable table_; ControlState state_; StateChangeHandler on_change_; }; diff --git a/include/secsgem/gem/data_model.hpp b/include/secsgem/gem/data_model.hpp index 9d67258..2e458d5 100644 --- a/include/secsgem/gem/data_model.hpp +++ b/include/secsgem/gem/data_model.hpp @@ -161,7 +161,21 @@ class EquipmentDataModel { std::string name; s2::Item value; }; - using HostCommandHandler = std::function&)>; + + // 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 emit_ceid; + std::optional set_alarm; + }; + + struct CommandResult { + HostCmdAck ack = HostCmdAck::InvalidCommand; + std::optional emit_ceid; + std::optional set_alarm; + }; // --- SVID --------------------------------------------------------------- void add_status_variable(StatusVariable sv); @@ -191,9 +205,9 @@ class EquipmentDataModel { TimeAck set_time_string(const std::string& time_str); // --- Host commands ------------------------------------------------------ - void register_command(const std::string& rcmd, HostCommandHandler handler); - HostCmdAck dispatch_command(const std::string& rcmd, - const std::vector& params) const; + void register_command(const std::string& rcmd, CommandSpec spec); + CommandResult dispatch_command(const std::string& rcmd, + const std::vector& params) const; bool has_command(const std::string& rcmd) const; // --- Collection events -------------------------------------------------- @@ -247,7 +261,7 @@ class EquipmentDataModel { std::map dvids_; std::map ecids_; std::int64_t time_offset_seconds_ = 0; - std::map commands_; + std::map commands_; std::map ceids_; std::map reports_; diff --git a/include/secsgem/gem/router.hpp b/include/secsgem/gem/router.hpp new file mode 100644 index 0000000..125f56a --- /dev/null +++ b/include/secsgem/gem/router.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "secsgem/secs2/message.hpp" + +namespace secsgem::gem { + +namespace s2 = secsgem::secs2; + +// A small (stream, function) dispatch table. The Server registers one +// handler per primary SxFy and calls `dispatch` from the Connection's +// message handler. Replaces the imperative if-ladder; behaviour stays in +// the handlers (since each SxFy reply shape is unique), but routing is +// data. +// +// Default behaviour for unregistered primaries: +// - If a `fallback` is installed, it runs. +// - Otherwise, if the inbound message has W set, reply with SxF0 +// (Abort) per E5 convention. +// - Otherwise, do nothing. +class Router { + public: + using Handler = std::function(const s2::Message&)>; + + Router& on(uint8_t stream, uint8_t function, Handler h) { + handlers_[{stream, function}] = std::move(h); + return *this; + } + + Router& fallback(Handler h) { + fallback_ = std::move(h); + return *this; + } + + std::optional dispatch(const s2::Message& msg) const { + auto it = handlers_.find({msg.stream, msg.function}); + if (it != handlers_.end()) return it->second(msg); + if (fallback_) return fallback_(msg); + if (msg.reply_expected) return s2::Message(msg.stream, 0, false); + return std::nullopt; + } + + std::size_t size() const { return handlers_.size(); } + + private: + std::map, Handler> handlers_; + Handler fallback_; +}; + +} // namespace secsgem::gem diff --git a/include/secsgem/hsms/connection.hpp b/include/secsgem/hsms/connection.hpp index eaab59a..511d42b 100644 --- a/include/secsgem/hsms/connection.hpp +++ b/include/secsgem/hsms/connection.hpp @@ -11,8 +11,7 @@ #include #include -#include "secsgem/hsms/frame.hpp" -#include "secsgem/hsms/types.hpp" +#include "secsgem/hsms/header.hpp" #include "secsgem/secs2/message.hpp" namespace secsgem::hsms { diff --git a/include/secsgem/hsms/frame.hpp b/include/secsgem/hsms/frame.hpp deleted file mode 100644 index fc2ca3e..0000000 --- a/include/secsgem/hsms/frame.hpp +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "secsgem/hsms/header.hpp" - -namespace secsgem::hsms { - -class FrameError : public std::runtime_error { - public: - using std::runtime_error::runtime_error; -}; - -// One HSMS message: a header plus an optional SECS-II body (control messages -// have no body). On the wire it is prefixed by a 4-byte big-endian length that -// counts the header (10) plus the body. -struct Frame { - Header header; - std::vector body; - - Frame() = default; - explicit Frame(Header h, std::vector b = {}) - : header(h), body(std::move(b)) {} - - // Full wire bytes including the 4-byte length prefix. - std::vector encode() const; - - // Decode a message from its payload (header + body, i.e. the bytes that - // follow the length prefix). `len` must be >= 10. - static Frame decode(const uint8_t* payload, std::size_t len); -}; - -// HSMS message length prefix is 4 bytes; payload must be at least the 10-byte -// header. -inline constexpr std::size_t kLengthPrefixSize = 4; -inline constexpr std::size_t kHeaderSize = 10; - -} // namespace secsgem::hsms diff --git a/include/secsgem/hsms/header.hpp b/include/secsgem/hsms/header.hpp index 2807864..5f85980 100644 --- a/include/secsgem/hsms/header.hpp +++ b/include/secsgem/hsms/header.hpp @@ -1,16 +1,64 @@ #pragma once #include +#include #include +#include #include +#include -#include "secsgem/hsms/types.hpp" - +// HSMS wire-format primitives (SEMI E37): SType + status enums, the 10-byte +// Header, the Frame (header + body, length-prefixed on the wire), and the +// protocol timer defaults. One header keeps everything that touches the wire +// format in one place. namespace secsgem::hsms { -// The fixed 10-byte HSMS message header (SEMI E37). The interpretation of -// byte2/byte3 depends on `stype`: for data messages byte2 = (W<<7)|stream and -// byte3 = function; for control messages they carry status / reason codes. +// ---- Session type + status / reason enums -------------------------------- + +enum class SType : uint8_t { + Data = 0, + SelectReq = 1, + SelectRsp = 2, + DeselectReq = 3, + DeselectRsp = 4, + LinktestReq = 5, + LinktestRsp = 6, + RejectReq = 7, + SeparateReq = 9, +}; + +const char* stype_name(SType s); + +enum class SelectStatus : uint8_t { + Ok = 0, AlreadyActive = 1, NotReady = 2, ConnectExhaust = 3, +}; + +enum class DeselectStatus : uint8_t { + Ok = 0, NotEstablished = 1, Busy = 2, +}; + +enum class RejectReason : uint8_t { + StypeNotSupported = 1, + PtypeNotSupported = 2, + TransactionNotOpen = 3, + EntityNotSelected = 4, +}; + +inline constexpr uint8_t kPTypeSecsII = 0; +inline constexpr uint16_t kControlSessionId = 0xFFFF; + +// HSMS protocol timer defaults (SEMI E37 §10). +struct Timers { + std::chrono::milliseconds t3{45000}; // reply + std::chrono::milliseconds t5{10000}; // connect separation + std::chrono::milliseconds t6{5000}; // control transaction + std::chrono::milliseconds t7{10000}; // not-selected + std::chrono::milliseconds t8{5000}; // intercharacter + std::chrono::milliseconds linktest{0}; // 0 disables +}; + +// ---- Header (10 bytes) --------------------------------------------------- + struct Header { uint16_t session_id = kControlSessionId; uint8_t byte2 = 0; @@ -19,7 +67,6 @@ struct Header { SType stype = SType::Data; uint32_t system_bytes = 0; - // Data-message field views. bool w_bit() const { return (byte2 & 0x80) != 0; } uint8_t stream() const { return byte2 & 0x7F; } uint8_t function() const { return byte3; } @@ -51,10 +98,35 @@ struct Header { std::array encode() const; static Header decode(const uint8_t* data); // reads exactly 10 bytes - std::string describe() const; bool operator==(const Header&) const = default; }; +// ---- Frame (header + body, length-prefixed) ------------------------------ + +class FrameError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +inline constexpr std::size_t kLengthPrefixSize = 4; +inline constexpr std::size_t kHeaderSize = 10; + +struct Frame { + Header header; + std::vector body; + + Frame() = default; + explicit Frame(Header h, std::vector b = {}) + : header(h), body(std::move(b)) {} + + // Full wire bytes including the 4-byte length prefix. + std::vector encode() const; + + // Decode a message from its payload (header + body, i.e. the bytes that + // follow the length prefix). `len` must be >= kHeaderSize. + static Frame decode(const uint8_t* payload, std::size_t len); +}; + } // namespace secsgem::hsms diff --git a/include/secsgem/hsms/types.hpp b/include/secsgem/hsms/types.hpp deleted file mode 100644 index 1c08304..0000000 --- a/include/secsgem/hsms/types.hpp +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include -#include - -namespace secsgem::hsms { - -// HSMS session type (byte 5 of the message header) — SEMI E37. -enum class SType : uint8_t { - Data = 0, - SelectReq = 1, - SelectRsp = 2, - DeselectReq = 3, - DeselectRsp = 4, - LinktestReq = 5, - LinktestRsp = 6, - RejectReq = 7, - SeparateReq = 9, -}; - -const char* stype_name(SType s); - -// Select.rsp status (header byte 3). -enum class SelectStatus : uint8_t { - Ok = 0, - AlreadyActive = 1, - NotReady = 2, - ConnectExhaust = 3, -}; - -// Deselect.rsp status (header byte 3). -enum class DeselectStatus : uint8_t { - Ok = 0, - NotEstablished = 1, - Busy = 2, -}; - -// Reject.req reason code (header byte 3). -enum class RejectReason : uint8_t { - StypeNotSupported = 1, - PtypeNotSupported = 2, - TransactionNotOpen = 3, - EntityNotSelected = 4, -}; - -// Presentation type 0 == SECS-II message encoding. -inline constexpr uint8_t kPTypeSecsII = 0; - -// Control messages carry no device id; E37 recommends 0xFFFF. -inline constexpr uint16_t kControlSessionId = 0xFFFF; - -// HSMS protocol timers (SEMI E37 defaults). -struct Timers { - std::chrono::milliseconds t3{45000}; // reply timeout - std::chrono::milliseconds t5{10000}; // connect separation timeout - std::chrono::milliseconds t6{5000}; // control transaction timeout - std::chrono::milliseconds t7{10000}; // not-selected timeout - std::chrono::milliseconds t8{5000}; // network intercharacter timeout - std::chrono::milliseconds linktest{0}; // linktest interval; 0 disables -}; - -} // namespace secsgem::hsms diff --git a/include/secsgem/secs2/item.hpp b/include/secsgem/secs2/item.hpp index 2e19d5d..a562ac1 100644 --- a/include/secsgem/secs2/item.hpp +++ b/include/secsgem/secs2/item.hpp @@ -28,12 +28,48 @@ enum class Format : uint8_t { U4 = 054, // 44 }; -const char* format_name(Format f); +inline const char* format_name(Format f) { + switch (f) { + case Format::List: return "L"; + case Format::Binary: return "B"; + case Format::Boolean: return "BOOLEAN"; + case Format::ASCII: return "A"; + case Format::I8: return "I8"; + case Format::I1: return "I1"; + case Format::I2: return "I2"; + case Format::I4: return "I4"; + case Format::F8: return "F8"; + case Format::F4: return "F4"; + case Format::U8: return "U8"; + case Format::U1: return "U1"; + case Format::U2: return "U2"; + case Format::U4: return "U4"; + } + return "?"; +} // Number of bytes one element of the given format occupies on the wire. // Lists are special (their length is an element count, not a byte count) and // return 0 here. -std::size_t element_size(Format f); +inline std::size_t element_size(Format f) { + switch (f) { + case Format::List: return 0; + case Format::ASCII: + case Format::Binary: + case Format::Boolean: + case Format::U1: + case Format::I1: return 1; + case Format::U2: + case Format::I2: return 2; + case Format::U4: + case Format::I4: + case Format::F4: return 4; + case Format::U8: + case Format::I8: + case Format::F8: return 8; + } + return 0; +} // A SECS-II data item: a typed, possibly nested value. Lists hold child items; // every other format holds a homogeneous array of scalars (a single scalar is @@ -64,7 +100,9 @@ class Item { // Number of elements: child count for lists, character count for ASCII, // array length for numeric/binary formats. - std::size_t size() const; + std::size_t size() const { + return std::visit([](const auto& v) { return v.size(); }, data_); + } // --- Factory functions ------------------------------------------------- static Item list(List items) { return Item(Format::List, std::move(items)); } diff --git a/include/secsgem/secs2/message.hpp b/include/secsgem/secs2/message.hpp index 7588e2c..5fd9d18 100644 --- a/include/secsgem/secs2/message.hpp +++ b/include/secsgem/secs2/message.hpp @@ -5,6 +5,7 @@ #include #include +#include "secsgem/secs2/codec.hpp" #include "secsgem/secs2/item.hpp" namespace secsgem::secs2 { @@ -22,16 +23,30 @@ struct Message { Message(uint8_t s, uint8_t f, bool w, std::optional b = std::nullopt) : stream(s), function(f), reply_expected(w), body(std::move(b)) {} - // Encode the body item to bytes (empty if there is no body). - std::vector encode_body() const; + std::vector encode_body() const { + if (!body) return {}; + return encode(*body); + } - // Build a Message from stream/function/W and raw body bytes (empty -> no body). static Message from_body(uint8_t stream, uint8_t function, bool reply_expected, - const std::vector& body_bytes); + const std::vector& body_bytes) { + Message m(stream, function, reply_expected); + if (!body_bytes.empty()) m.body = decode(body_bytes); + return m; + } - // e.g. S1F2 W - // > - std::string sml() const; + // e.g. S1F2 + // > . + std::string sml() const { + std::string out = "S" + std::to_string(stream) + "F" + std::to_string(function); + if (reply_expected) out += " W"; + if (body) { + out += "\n "; + out += to_sml(*body); + } + out += " ."; + return out; + } }; } // namespace secsgem::secs2 diff --git a/src/config/loader.cpp b/src/config/loader.cpp new file mode 100644 index 0000000..65e63cf --- /dev/null +++ b/src/config/loader.cpp @@ -0,0 +1,214 @@ +#include "secsgem/config/loader.hpp" + +#include + +#include +#include +#include + +#include "secsgem/secs2/item.hpp" + +namespace secsgem::config { + +namespace s2 = secsgem::secs2; +namespace gem = secsgem::gem; + +namespace { + +[[noreturn]] void fail(const std::string& path, const std::string& what) { + throw ConfigError(path + ": " + what); +} + +template +T req_as(const YAML::Node& n, const std::string& path, const char* field) { + if (!n) fail(path, std::string("missing required field `") + field + "`"); + try { + return n.as(); + } catch (const YAML::Exception& e) { + fail(path, std::string("field `") + field + "`: " + e.what()); + } +} + +// Build a SECS-II Item from a (type, value) pair in YAML. +s2::Item make_item(const YAML::Node& type_n, const YAML::Node& value_n, + const std::string& path) { + if (!type_n) fail(path, "missing `type` for value"); + const auto type = type_n.as(); + if (type == "ASCII") return s2::Item::ascii(value_n ? value_n.as() : ""); + if (type == "BOOLEAN") return s2::Item::boolean(value_n && value_n.as()); + if (type == "BINARY") { + std::vector b; + if (value_n && value_n.IsSequence()) + for (const auto& e : value_n) b.push_back(static_cast(e.as())); + return s2::Item::binary(std::move(b)); + } + if (type == "U1") return s2::Item::u1(static_cast(value_n.as())); + if (type == "U2") return s2::Item::u2(static_cast(value_n.as())); + if (type == "U4") return s2::Item::u4(static_cast(value_n.as())); + if (type == "U8") return s2::Item::u8(value_n.as()); + if (type == "I1") return s2::Item::i1(static_cast(value_n.as())); + if (type == "I2") return s2::Item::i2(static_cast(value_n.as())); + if (type == "I4") return s2::Item::i4(static_cast(value_n.as())); + if (type == "I8") return s2::Item::i8(value_n.as()); + if (type == "F4") return s2::Item::f4(value_n.as()); + if (type == "F8") return s2::Item::f8(value_n.as()); + fail(path, "unknown SECS-II type `" + type + "`"); +} + +gem::OnlineAck parse_ack(const std::string& s, const std::string& path) { + if (s == "Accept") return gem::OnlineAck::Accept; + if (s == "NotAccept") return gem::OnlineAck::NotAccept; + if (s == "AlreadyOnline") return gem::OnlineAck::AlreadyOnline; + fail(path, "unknown ack `" + s + "` (expected Accept/NotAccept/AlreadyOnline)"); +} + +gem::HostCmdAck parse_hcack(const std::string& s, const std::string& path) { + if (s == "Accept") return gem::HostCmdAck::Accept; + if (s == "InvalidCommand") return gem::HostCmdAck::InvalidCommand; + if (s == "CannotDoNow") return gem::HostCmdAck::CannotDoNow; + if (s == "ParameterInvalid") return gem::HostCmdAck::ParameterInvalid; + if (s == "AcceptedWillFinishLater") return gem::HostCmdAck::AcceptedWillFinishLater; + if (s == "Rejected") return gem::HostCmdAck::Rejected; + if (s == "InvalidObject") return gem::HostCmdAck::InvalidObject; + fail(path, "unknown HCACK `" + s + "`"); +} + +YAML::Node load(const std::string& path) { + try { + return YAML::LoadFile(path); + } catch (const YAML::Exception& e) { + fail(path, std::string("YAML parse error: ") + e.what()); + } +} + +} // namespace + +ControlStateConfig load_control_state(const std::string& path) { + YAML::Node root = load(path); + + ControlStateConfig cfg; + if (auto initial = root["initial"]) { + auto parsed = gem::parse_control_state(initial.as()); + if (!parsed) fail(path, "unknown initial state `" + initial.as() + "`"); + cfg.initial = *parsed; + } + + const auto transitions = root["transitions"]; + if (!transitions || !transitions.IsSequence()) + fail(path, "missing or non-sequence `transitions`"); + + for (std::size_t i = 0; i < transitions.size(); ++i) { + const auto& row = transitions[i]; + const auto where = path + " transitions[" + std::to_string(i) + "]"; + + auto from = gem::parse_control_state(req_as(row["from"], where, "from")); + auto on = gem::parse_control_event(req_as(row["on"], where, "on")); + if (!from) fail(where, "unknown `from` state"); + if (!on) fail(where, "unknown `on` event"); + + gem::ControlTransition t{*from, *on, std::nullopt, std::nullopt, std::nullopt}; + if (auto to = row["to"]) { + auto s = gem::parse_control_state(to.as()); + if (!s) fail(where, "unknown `to` state"); + t.to = *s; + } + if (auto th = row["then"]) { + auto s = gem::parse_control_state(th.as()); + if (!s) fail(where, "unknown `then` state"); + t.then = *s; + } + if (auto ack = row["ack"]) { + const auto s = ack.as(); + const auto is_offline = (*on == gem::ControlEvent::HostRequestOffline); + if (is_offline) { + if (s != "Accept") fail(where, "OfflineAck only supports `Accept`"); + t.ack_code = static_cast(gem::OfflineAck::Accept); + } else { + t.ack_code = static_cast(parse_ack(s, where)); + } + } + cfg.table.add(t); + } + return cfg; +} + +EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataModel& model) { + YAML::Node root = load(path); + + EquipmentDescriptor desc; + if (auto d = root["device"]) { + desc.device_id = static_cast(d["id"].as()); + desc.model_name = d["model_name"].as(); + desc.software_rev = d["software_rev"].as(); + } + if (auto e = root["emit_on_control_change"]) { + if (!e.IsNull()) desc.emit_on_control_change = static_cast(e.as()); + } + + if (auto svids = root["svids"]) { + for (const auto& sv : svids) { + model.add_status_variable({ + static_cast(sv["id"].as()), + sv["name"].as(), + sv["units"] ? sv["units"].as() : "", + make_item(sv["type"], sv["value"], path + " svid"), + }); + } + } + + 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({ + static_cast(ec["id"].as()), + ec["name"].as(), + ec["units"] ? ec["units"].as() : "", + value, + value, // default = initial value + ec["min"] ? ec["min"].as() : "", + ec["max"] ? ec["max"].as() : "", + }); + } + } + + if (auto ceids = root["ceids"]) { + for (const auto& ce : ceids) { + model.register_event({ + static_cast(ce["id"].as()), + ce["name"].as(), + }); + } + } + + if (auto alarms = root["alarms"]) { + for (const auto& a : alarms) { + model.add_alarm({ + static_cast(a["id"].as()), + a["text"].as(), + static_cast(a["category"].as()), + }); + } + } + + if (auto recipes = root["recipes"]) { + for (const auto& r : recipes) { + model.add_process_program(r["id"].as(), r["body"].as()); + } + } + + if (auto commands = root["host_commands"]) { + for (const auto& c : commands) { + gem::EquipmentDataModel::CommandSpec spec; + spec.ack = parse_hcack(c["ack"].as(), path + " host_commands"); + if (auto e = c["emit_ceid"]) + spec.emit_ceid = static_cast(e.as()); + if (auto a = c["set_alarm"]) + spec.set_alarm = static_cast(a.as()); + model.register_command(c["name"].as(), spec); + } + } + + return desc; +} + +} // namespace secsgem::config diff --git a/src/gem/control_state.cpp b/src/gem/control_state.cpp index c3b64ff..d4d7e3f 100644 --- a/src/gem/control_state.cpp +++ b/src/gem/control_state.cpp @@ -1,5 +1,7 @@ #include "secsgem/gem/control_state.hpp" +#include + namespace secsgem::gem { const char* control_state_name(ControlState s) { @@ -13,6 +15,15 @@ const char* control_state_name(ControlState s) { return "?"; } +std::optional parse_control_state(const std::string& s) { + if (s == "EquipmentOffline") return ControlState::EquipmentOffline; + if (s == "AttemptOnline") return ControlState::AttemptOnline; + if (s == "HostOffline") return ControlState::HostOffline; + if (s == "OnlineLocal") return ControlState::OnlineLocal; + if (s == "OnlineRemote") return ControlState::OnlineRemote; + return std::nullopt; +} + bool is_online(ControlState s) { return s == ControlState::OnlineLocal || s == ControlState::OnlineRemote; } @@ -31,10 +42,79 @@ const char* control_event_name(ControlEvent e) { return "?"; } -ControlStateMachine::ControlStateMachine() : ControlStateMachine(Config{}) {} +std::optional parse_control_event(const std::string& s) { + if (s == "operator_online") return ControlEvent::OperatorSwitchOnline; + if (s == "operator_offline") return ControlEvent::OperatorSwitchOffline; + if (s == "operator_local") return ControlEvent::OperatorSwitchLocal; + if (s == "operator_remote") return ControlEvent::OperatorSwitchRemote; + if (s == "attempt_complete") return ControlEvent::AttemptComplete; + if (s == "attempt_failed") return ControlEvent::AttemptFailed; + if (s == "host_request_online") return ControlEvent::HostRequestOnline; + if (s == "host_request_offline") return ControlEvent::HostRequestOffline; + return std::nullopt; +} -ControlStateMachine::ControlStateMachine(Config cfg) - : cfg_(cfg), state_(cfg.initial) {} +void ControlTransitionTable::add(ControlTransition row) { + rows_.push_back(row); +} + +const ControlTransition* ControlTransitionTable::find(ControlState from, + ControlEvent on) const { + for (const auto& r : rows_) { + if (r.from == from && r.on == on) return &r; + } + return nullptr; +} + +ControlTransitionTable ControlTransitionTable::default_table() { + using S = ControlState; + using E = ControlEvent; + using A = OnlineAck; + ControlTransitionTable t; + + // Host: Request Online + t.add({S::HostOffline, E::HostRequestOnline, S::AttemptOnline, S::OnlineRemote, + static_cast(A::Accept)}); + t.add({S::OnlineLocal, E::HostRequestOnline, std::nullopt, std::nullopt, + static_cast(A::AlreadyOnline)}); + t.add({S::OnlineRemote, E::HostRequestOnline, std::nullopt, std::nullopt, + static_cast(A::AlreadyOnline)}); + t.add({S::EquipmentOffline, E::HostRequestOnline, std::nullopt, std::nullopt, + static_cast(A::NotAccept)}); + t.add({S::AttemptOnline, E::HostRequestOnline, std::nullopt, std::nullopt, + static_cast(A::NotAccept)}); + + // Host: Request Offline (always accept, idempotent) + for (auto from : {S::EquipmentOffline, S::HostOffline, S::AttemptOnline}) { + t.add({from, E::HostRequestOffline, std::nullopt, std::nullopt, + static_cast(OfflineAck::Accept)}); + } + t.add({S::OnlineLocal, E::HostRequestOffline, S::HostOffline, std::nullopt, + static_cast(OfflineAck::Accept)}); + t.add({S::OnlineRemote, E::HostRequestOffline, S::HostOffline, std::nullopt, + static_cast(OfflineAck::Accept)}); + + // Operator: Online (-> Local by default) + t.add({S::EquipmentOffline, E::OperatorSwitchOnline, S::AttemptOnline, S::OnlineLocal, std::nullopt}); + t.add({S::HostOffline, E::OperatorSwitchOnline, S::AttemptOnline, S::OnlineLocal, std::nullopt}); + + // Operator: Offline + t.add({S::OnlineLocal, E::OperatorSwitchOffline, S::HostOffline, std::nullopt, std::nullopt}); + t.add({S::OnlineRemote, E::OperatorSwitchOffline, S::HostOffline, std::nullopt, std::nullopt}); + t.add({S::AttemptOnline, E::OperatorSwitchOffline, S::HostOffline, std::nullopt, std::nullopt}); + + // Operator: Local <-> Remote + t.add({S::OnlineRemote, E::OperatorSwitchLocal, S::OnlineLocal, std::nullopt, std::nullopt}); + t.add({S::OnlineLocal, E::OperatorSwitchRemote, S::OnlineRemote, std::nullopt, std::nullopt}); + + return t; +} + +ControlStateMachine::ControlStateMachine() + : ControlStateMachine(ControlTransitionTable::default_table(), ControlState::HostOffline) {} + +ControlStateMachine::ControlStateMachine(ControlTransitionTable table, ControlState initial) + : table_(std::move(table)), state_(initial) {} void ControlStateMachine::transition(ControlState next, ControlEvent trigger) { if (state_ == next) return; @@ -43,84 +123,37 @@ void ControlStateMachine::transition(ControlState next, ControlEvent trigger) { if (on_change_) on_change_(prev, next, trigger); } +const ControlTransition* ControlStateMachine::fire(ControlEvent on) { + const ControlTransition* row = table_.find(state_, on); + if (!row) return nullptr; + if (row->to) transition(*row->to, on); + if (row->then) transition(*row->then, ControlEvent::AttemptComplete); + return row; +} + bool ControlStateMachine::operator_online() { - switch (state_) { - case ControlState::EquipmentOffline: - case ControlState::HostOffline: - transition(ControlState::AttemptOnline, ControlEvent::OperatorSwitchOnline); - transition(cfg_.operator_default_remote ? ControlState::OnlineRemote - : ControlState::OnlineLocal, - ControlEvent::AttemptComplete); - return true; - case ControlState::AttemptOnline: - case ControlState::OnlineLocal: - case ControlState::OnlineRemote: - return false; - } - return false; + return fire(ControlEvent::OperatorSwitchOnline) != nullptr; } - bool ControlStateMachine::operator_offline() { - switch (state_) { - case ControlState::OnlineLocal: - case ControlState::OnlineRemote: - case ControlState::AttemptOnline: - transition(ControlState::HostOffline, ControlEvent::OperatorSwitchOffline); - return true; - case ControlState::EquipmentOffline: - case ControlState::HostOffline: - return false; - } - return false; + return fire(ControlEvent::OperatorSwitchOffline) != nullptr; } - bool ControlStateMachine::operator_local() { - if (state_ == ControlState::OnlineRemote) { - transition(ControlState::OnlineLocal, ControlEvent::OperatorSwitchLocal); - return true; - } - return false; + return fire(ControlEvent::OperatorSwitchLocal) != nullptr; } - bool ControlStateMachine::operator_remote() { - if (state_ == ControlState::OnlineLocal) { - transition(ControlState::OnlineRemote, ControlEvent::OperatorSwitchRemote); - return true; - } - return false; + return fire(ControlEvent::OperatorSwitchRemote) != nullptr; } OnlineAck ControlStateMachine::on_host_request_online() { - switch (state_) { - case ControlState::HostOffline: - transition(ControlState::AttemptOnline, ControlEvent::HostRequestOnline); - transition(cfg_.host_request_grants_remote ? ControlState::OnlineRemote - : ControlState::OnlineLocal, - ControlEvent::AttemptComplete); - return OnlineAck::Accept; - case ControlState::OnlineLocal: - case ControlState::OnlineRemote: - return OnlineAck::AlreadyOnline; - case ControlState::EquipmentOffline: - case ControlState::AttemptOnline: - return OnlineAck::NotAccept; - } - return OnlineAck::NotAccept; + const ControlTransition* row = fire(ControlEvent::HostRequestOnline); + if (!row || !row->ack_code) return OnlineAck::NotAccept; + return static_cast(*row->ack_code); } OfflineAck ControlStateMachine::on_host_request_offline() { - switch (state_) { - case ControlState::OnlineLocal: - case ControlState::OnlineRemote: - transition(ControlState::HostOffline, ControlEvent::HostRequestOffline); - return OfflineAck::Accept; - case ControlState::EquipmentOffline: - case ControlState::AttemptOnline: - case ControlState::HostOffline: - // Idempotent: already offline from host's point of view. - return OfflineAck::Accept; - } - return OfflineAck::Accept; + const ControlTransition* row = fire(ControlEvent::HostRequestOffline); + if (!row || !row->ack_code) return OfflineAck::Accept; + return static_cast(*row->ack_code); } } // namespace secsgem::gem diff --git a/src/gem/data_model.cpp b/src/gem/data_model.cpp index 0dc11b4..b9cd54f 100644 --- a/src/gem/data_model.cpp +++ b/src/gem/data_model.cpp @@ -142,17 +142,17 @@ TimeAck EquipmentDataModel::set_time_string(const std::string& s) { // ---- Host commands ------------------------------------------------------- -void EquipmentDataModel::register_command(const std::string& rcmd, HostCommandHandler handler) { - commands_.insert_or_assign(rcmd, std::move(handler)); +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(); } -HostCmdAck EquipmentDataModel::dispatch_command( - const std::string& rcmd, const std::vector& params) const { +EquipmentDataModel::CommandResult EquipmentDataModel::dispatch_command( + const std::string& rcmd, const std::vector& /*params*/) const { auto it = commands_.find(rcmd); - if (it == commands_.end()) return HostCmdAck::InvalidCommand; - return it->second(params); + 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 --------------------------------------------------- diff --git a/src/hsms/frame.cpp b/src/hsms/frame.cpp deleted file mode 100644 index 51393fa..0000000 --- a/src/hsms/frame.cpp +++ /dev/null @@ -1,32 +0,0 @@ -#include "secsgem/hsms/frame.hpp" - -namespace secsgem::hsms { - -std::vector Frame::encode() const { - const std::size_t payload_len = kHeaderSize + body.size(); - - std::vector out; - out.reserve(kLengthPrefixSize + payload_len); - - out.push_back(static_cast(payload_len >> 24)); - out.push_back(static_cast(payload_len >> 16)); - out.push_back(static_cast(payload_len >> 8)); - out.push_back(static_cast(payload_len & 0xFF)); - - const auto hdr = header.encode(); - out.insert(out.end(), hdr.begin(), hdr.end()); - out.insert(out.end(), body.begin(), body.end()); - return out; -} - -Frame Frame::decode(const uint8_t* payload, std::size_t len) { - if (len < kHeaderSize) - throw FrameError("HSMS payload shorter than the 10-byte header"); - - Frame f; - f.header = Header::decode(payload); - f.body.assign(payload + kHeaderSize, payload + len); - return f; -} - -} // namespace secsgem::hsms diff --git a/src/hsms/header.cpp b/src/hsms/header.cpp index 2b21645..d712161 100644 --- a/src/hsms/header.cpp +++ b/src/hsms/header.cpp @@ -56,4 +56,27 @@ std::string Header::describe() const { return out; } +std::vector Frame::encode() const { + const std::size_t payload_len = kHeaderSize + body.size(); + std::vector out; + out.reserve(kLengthPrefixSize + payload_len); + out.push_back(static_cast(payload_len >> 24)); + out.push_back(static_cast(payload_len >> 16)); + out.push_back(static_cast(payload_len >> 8)); + out.push_back(static_cast(payload_len & 0xFF)); + const auto hdr = header.encode(); + out.insert(out.end(), hdr.begin(), hdr.end()); + out.insert(out.end(), body.begin(), body.end()); + return out; +} + +Frame Frame::decode(const uint8_t* payload, std::size_t len) { + if (len < kHeaderSize) + throw FrameError("HSMS payload shorter than the 10-byte header"); + Frame f; + f.header = Header::decode(payload); + f.body.assign(payload + kHeaderSize, payload + len); + return f; +} + } // namespace secsgem::hsms diff --git a/src/secs2/item.cpp b/src/secs2/item.cpp deleted file mode 100644 index be705a2..0000000 --- a/src/secs2/item.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "secsgem/secs2/item.hpp" - -namespace secsgem::secs2 { - -const char* format_name(Format f) { - switch (f) { - case Format::List: return "L"; - case Format::Binary: return "B"; - case Format::Boolean: return "BOOLEAN"; - case Format::ASCII: return "A"; - case Format::I8: return "I8"; - case Format::I1: return "I1"; - case Format::I2: return "I2"; - case Format::I4: return "I4"; - case Format::F8: return "F8"; - case Format::F4: return "F4"; - case Format::U8: return "U8"; - case Format::U1: return "U1"; - case Format::U2: return "U2"; - case Format::U4: return "U4"; - } - return "?"; -} - -std::size_t element_size(Format f) { - switch (f) { - case Format::List: return 0; - case Format::ASCII: - case Format::Binary: - case Format::Boolean: - case Format::U1: - case Format::I1: return 1; - case Format::U2: - case Format::I2: return 2; - case Format::U4: - case Format::I4: - case Format::F4: return 4; - case Format::U8: - case Format::I8: - case Format::F8: return 8; - } - return 0; -} - -std::size_t Item::size() const { - return std::visit([](const auto& v) { return v.size(); }, data_); -} - -} // namespace secsgem::secs2 diff --git a/src/secs2/message.cpp b/src/secs2/message.cpp deleted file mode 100644 index 6067c76..0000000 --- a/src/secs2/message.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "secsgem/secs2/message.hpp" - -#include "secsgem/secs2/codec.hpp" - -namespace secsgem::secs2 { - -std::vector Message::encode_body() const { - if (!body) return {}; - return encode(*body); -} - -Message Message::from_body(uint8_t stream, uint8_t function, bool reply_expected, - const std::vector& body_bytes) { - Message m(stream, function, reply_expected); - if (!body_bytes.empty()) m.body = decode(body_bytes); - return m; -} - -std::string Message::sml() const { - std::string out = "S" + std::to_string(stream) + "F" + std::to_string(function); - if (reply_expected) out += " W"; - if (body) { - out += "\n "; - out += to_sml(*body); - } - out += " ."; - return out; -} - -} // namespace secsgem::secs2 diff --git a/tests/test_control_state.cpp b/tests/test_control_state.cpp index 60f0707..0fa7a06 100644 --- a/tests/test_control_state.cpp +++ b/tests/test_control_state.cpp @@ -10,7 +10,6 @@ namespace { struct Recorder { std::vector> changes; - ControlStateMachine::StateChangeHandler handler() { return [this](ControlState from, ControlState to, ControlEvent ev) { changes.emplace_back(from, to, ev); @@ -18,6 +17,10 @@ struct Recorder { } }; +ControlStateMachine make_sm(ControlState initial) { + return ControlStateMachine(ControlTransitionTable::default_table(), initial); +} + } // namespace TEST_CASE("default initial state is HostOffline") { @@ -27,7 +30,7 @@ TEST_CASE("default initial state is HostOffline") { } TEST_CASE("custom initial state") { - ControlStateMachine sm({.initial = ControlState::EquipmentOffline}); + auto sm = make_sm(ControlState::EquipmentOffline); CHECK(sm.state() == ControlState::EquipmentOffline); } @@ -60,7 +63,7 @@ TEST_CASE("host request online when already online -> AlreadyOnline, no transiti } TEST_CASE("host request online from EquipmentOffline -> NotAccept") { - ControlStateMachine sm({.initial = ControlState::EquipmentOffline}); + auto sm = make_sm(ControlState::EquipmentOffline); CHECK(sm.on_host_request_online() == OnlineAck::NotAccept); CHECK(sm.state() == ControlState::EquipmentOffline); } @@ -89,42 +92,36 @@ TEST_CASE("host request offline when already offline is idempotent Accept") { CHECK(rec.changes.empty()); } -TEST_CASE("operator online from EquipmentOffline -> OnlineLocal by default") { - ControlStateMachine sm({.initial = ControlState::EquipmentOffline}); +TEST_CASE("operator online from EquipmentOffline -> OnlineLocal (default table)") { + auto sm = make_sm(ControlState::EquipmentOffline); CHECK(sm.operator_online()); CHECK(sm.state() == ControlState::OnlineLocal); } -TEST_CASE("operator online with default_remote -> OnlineRemote") { - ControlStateMachine sm({.initial = ControlState::HostOffline, .operator_default_remote = true}); - CHECK(sm.operator_online()); - CHECK(sm.state() == ControlState::OnlineRemote); -} - TEST_CASE("operator online when already online is rejected") { - ControlStateMachine sm({.initial = ControlState::OnlineLocal}); + auto sm = make_sm(ControlState::OnlineLocal); CHECK_FALSE(sm.operator_online()); CHECK(sm.state() == ControlState::OnlineLocal); } TEST_CASE("operator offline from any online state -> HostOffline") { - ControlStateMachine sm({.initial = ControlState::OnlineRemote}); + auto sm = make_sm(ControlState::OnlineRemote); CHECK(sm.operator_offline()); CHECK(sm.state() == ControlState::HostOffline); } TEST_CASE("operator local toggles only from OnlineRemote") { - ControlStateMachine sm({.initial = ControlState::OnlineRemote}); + auto sm = make_sm(ControlState::OnlineRemote); CHECK(sm.operator_local()); CHECK(sm.state() == ControlState::OnlineLocal); - CHECK_FALSE(sm.operator_local()); // already local + CHECK_FALSE(sm.operator_local()); } TEST_CASE("operator remote toggles only from OnlineLocal") { - ControlStateMachine sm({.initial = ControlState::OnlineLocal}); + auto sm = make_sm(ControlState::OnlineLocal); CHECK(sm.operator_remote()); CHECK(sm.state() == ControlState::OnlineRemote); - CHECK_FALSE(sm.operator_remote()); // already remote + CHECK_FALSE(sm.operator_remote()); } TEST_CASE("is_online classifier") { @@ -134,3 +131,23 @@ TEST_CASE("is_online classifier") { CHECK(is_online(ControlState::OnlineLocal)); CHECK(is_online(ControlState::OnlineRemote)); } + +TEST_CASE("default table covers all expected (state, event) pairs") { + auto t = ControlTransitionTable::default_table(); + // Every state must have an entry for each host event. + for (auto s : {ControlState::EquipmentOffline, ControlState::AttemptOnline, + ControlState::HostOffline, ControlState::OnlineLocal, + ControlState::OnlineRemote}) { + CHECK(t.find(s, ControlEvent::HostRequestOnline) != nullptr); + CHECK(t.find(s, ControlEvent::HostRequestOffline) != nullptr); + } +} + +TEST_CASE("custom table: a row that only sets ack, no transition") { + ControlTransitionTable t; + t.add({ControlState::HostOffline, ControlEvent::HostRequestOnline, std::nullopt, + std::nullopt, static_cast(OnlineAck::NotAccept)}); + ControlStateMachine sm(t, ControlState::HostOffline); + CHECK(sm.on_host_request_online() == OnlineAck::NotAccept); + CHECK(sm.state() == ControlState::HostOffline); +} diff --git a/tests/test_data_model.cpp b/tests/test_data_model.cpp index 6d5c3e3..411a90f 100644 --- a/tests/test_data_model.cpp +++ b/tests/test_data_model.cpp @@ -45,14 +45,26 @@ TEST_CASE("set_time_string accepts well-formed and rejects malformed") { TEST_CASE("host command registry") { EquipmentDataModel m; - m.register_command("START", [](const auto&) { return HostCmdAck::Accept; }); - m.register_command("STOP", [](const auto&) { return HostCmdAck::CannotDoNow; }); + 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")); CHECK_FALSE(m.has_command("PAUSE")); - CHECK(m.dispatch_command("START", {}) == HostCmdAck::Accept); - CHECK(m.dispatch_command("STOP", {}) == HostCmdAck::CannotDoNow); - CHECK(m.dispatch_command("UNKNOWN", {}) == HostCmdAck::InvalidCommand); + + auto start = m.dispatch_command("START", {}); + CHECK(start.ack == HostCmdAck::Accept); + CHECK(start.emit_ceid.value_or(0) == 300); + CHECK_FALSE(start.set_alarm.has_value()); + + auto stop = m.dispatch_command("STOP", {}); + 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(m.dispatch_command("UNKNOWN", {}).ack == HostCmdAck::InvalidCommand); } // ---- Event reports ------------------------------------------------------- diff --git a/tests/test_hsms.cpp b/tests/test_hsms.cpp index 28a7109..41f0187 100644 --- a/tests/test_hsms.cpp +++ b/tests/test_hsms.cpp @@ -1,6 +1,5 @@ #include -#include "secsgem/hsms/frame.hpp" #include "secsgem/hsms/header.hpp" #include "secsgem/secs2/codec.hpp" diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp new file mode 100644 index 0000000..0e13450 --- /dev/null +++ b/tests/test_loader.cpp @@ -0,0 +1,72 @@ +#include + +#include +#include + +#include "secsgem/config/loader.hpp" +#include "secsgem/secs2/item.hpp" + +using namespace secsgem; +namespace gem = secsgem::gem; +namespace s2 = secsgem::secs2; + +#ifndef SECSGEM_DATA_DIR +#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt" +#endif + +TEST_CASE("control_state.yaml parses into the same shape as default_table()") { + auto cfg = config::load_control_state(SECSGEM_DATA_DIR "/control_state.yaml"); + auto baseline = gem::ControlTransitionTable::default_table(); + + CHECK(cfg.initial == gem::ControlState::HostOffline); + // Same row count; same (from, on) pairs map to the same destination. + CHECK(cfg.table.size() == baseline.size()); + + for (const auto& r : baseline.rows()) { + const auto* loaded = cfg.table.find(r.from, r.on); + REQUIRE(loaded != nullptr); + CHECK(loaded->to == r.to); + CHECK(loaded->then == r.then); + CHECK(loaded->ack_code == r.ack_code); + } +} + +TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, commands") { + gem::EquipmentDataModel m; + auto desc = config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m); + + CHECK(desc.model_name == "SECSGEM-SIM"); + CHECK(desc.software_rev == "0.1.0"); + REQUIRE(desc.emit_on_control_change.has_value()); + CHECK(*desc.emit_on_control_change == 100); + + CHECK(m.all_status_variables().size() == 3); + CHECK(m.status_variable(1)->name == "ControlState"); + CHECK(m.status_variable(3)->value == s2::Item::boolean(true)); + + CHECK(m.all_equipment_constants().size() == 2); + CHECK(m.equipment_constant(10)->value == s2::Item::u4(uint32_t{1})); + + CHECK(m.all_events().size() == 3); + CHECK(m.has_event(100)); + CHECK(m.has_event(300)); + + CHECK(m.all_alarms().size() == 2); + CHECK(m.alarm(1)->text == "Chiller Temp High"); + CHECK(m.alarm(1)->severity_category == 4); + + CHECK(m.process_program_list().size() == 2); + CHECK(m.process_program("RECIPE-A").value().find("CHAMBER ARGON") != std::string::npos); + + CHECK(m.has_command("START")); + auto start = m.dispatch_command("START", {}); + CHECK(start.ack == gem::HostCmdAck::Accept); + CHECK(start.emit_ceid.value_or(0) == 300); + auto fault = m.dispatch_command("FAULT", {}); + CHECK(fault.set_alarm.value_or(0) == 1); +} + +TEST_CASE("loader surfaces YAML errors with file path") { + CHECK_THROWS_AS(config::load_equipment("/tmp/does-not-exist.yaml", *new gem::EquipmentDataModel), + config::ConfigError); +}