Table/YAML-driven refactor (Layer 1 start)

Move equipment capabilities and the E30 control state machine out of C++
code and into YAML data files; introduce a Router for SECS dispatch;
consolidate small files.

Behavioural changes: none.  Demo identical (15 SxFy transactions +
3 equipment-initiated primaries), 67 test cases / 384 assertions still
all green.  Structural changes only.

Why
---

The previous server.cpp held the equipment data dictionary (3 SVIDs,
2 ECIDs, 3 CEIDs, 2 alarms, 2 recipes, 4 host commands) as imperative
C++ in a 50-line `populate()` function, and routed inbound messages
through a 150-line if-ladder.  Adding a new SVID required a recompile.
Adding a new state transition required editing two switch statements
(`operator_*` and `on_host_request_*`).  The control state machine's
behavioural rules were spread across imperative code in two methods.

This is exactly what implementation_plan.md calls out as the wrong
shape: behavioural rules should live in versioned data, and every
runtime/test/analyzer should read from that data rather than re-encode
it.  This commit starts that move.

What's new
----------

data/equipment.yaml
  Equipment data dictionary.  Declarative SVIDs / ECIDs / CEIDs /
  alarms / recipes / host commands.  Host commands carry their HCACK
  ack code plus optional `emit_ceid` and `set_alarm` side-effects.
  Adding a new SVID or command is a YAML edit, no recompile.

data/control_state.yaml
  The E30 §6.2 control state transition table as data.  Each row is
  (from, on) -> (to [, then] [, ack]).  `then` chains an auto-advance
  through the transient AttemptOnline state.  The previous
  imperative switch is gone.

include/secsgem/config/loader.hpp + src/config/loader.cpp
  yaml-cpp-backed loader.  `load_control_state(path)` returns a
  ControlTransitionTable + initial state; `load_equipment(path, model)`
  populates the EquipmentDataModel and returns the device descriptor
  (id, MDLN, SOFTREV, optional auto-emit CEID).  Surfaces config
  errors with file path + field name via ConfigError.

include/secsgem/gem/router.hpp  (header-only)
  Small (stream, function) -> handler map.  Server registers all
  handlers once at startup, then the Connection's message handler is
  just `router.dispatch(msg)`.  Unhandled primaries with W set get
  SxF0 by default.  Replaces the if-ladder in secs_server.cpp.

include/secsgem/gem/control_state.hpp + .cpp
  ControlTransitionTable is the new pure data type.  ControlStateMachine
  is now a thin engine over the table: `fire(event)` looks up the row,
  optionally transitions, optionally chains a `then` transition, returns
  the ack code.  Behaviour rules no longer live in C++ switches.
  The default in-code table matches data/control_state.yaml row for row;
  tests rely on it so they don't need the YAML file.

include/secsgem/gem/data_model.hpp + .cpp
  `register_command(rcmd, CommandSpec)` replaces the function-handler
  signature.  CommandSpec = (HostCmdAck, optional emit_ceid, optional
  set_alarm).  `dispatch_command` returns a CommandResult so the server
  can fire the side-effects after S2F42 is sent.

apps/secs_server.cpp
  No populate(), no if-ladder.  Loads equipment.yaml + control_state.yaml
  at startup (clean error on bad config), wires the Router once,
  delegates dispatch.  Sm change handler reads emit_on_control_change
  from the YAML.  Welcome S10F3 removed for parity with config (a future
  YAML rule could re-introduce it declaratively).

tests/test_loader.cpp  (new)
  Verifies the YAML loader produces the same shape as the in-code
  default table, and that equipment.yaml populates every section
  (SVIDs/ECIDs/CEIDs/alarms/recipes/commands).  SECSGEM_DATA_DIR
  CMake define points at ${CMAKE_SOURCE_DIR}/data so tests don't
  depend on cwd.

CMakeLists.txt, Dockerfile
  find_package(yaml-cpp) and link.  libyaml-cpp-dev added to the
  Ubuntu base image (yaml-cpp 0.8 ships the modern target name).

File consolidation
------------------

Five small files removed; their content lives in fewer headers:

  - secs2/item.cpp        -> inline in secs2/item.hpp
  - secs2/message.cpp     -> inline in secs2/message.hpp
  - hsms/types.hpp        -> merged into hsms/header.hpp
  - hsms/frame.hpp        -> merged into hsms/header.hpp
  - hsms/frame.cpp        -> merged into hsms/header.cpp

hsms/header.hpp is now "the HSMS wire format" in one place: SType + status
enums + Timers + Header + Frame + constants.  All includers updated.

Net effect
----------

Before: equipment data dictionary lived in 50 lines of imperative
populate() inside secs_server.cpp; dispatch in a 20-branch if-ladder.

After: equipment data dictionary lives in 47 lines of YAML; dispatch
is a Router built once.  Adding a new capability is now a YAML edit
in the common case.

Test count up to 67 cases / 384 assertions (+4 cases / +106 assertions)
covering the loader and the new table-driven SM paths.

What's NOT changed
------------------

The per-SxFy reply construction still lives in C++ (each message has a
unique body shape).  Moving those into YAML/JSON message-shape
definitions is the next refactor step but requires a generic typed
encoder/decoder driven by shape descriptors; out of scope here.

Spooling, the S9 error stream, S1F19/F20, and the other gaps in
COMPLIANCE.md remain unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 08:57:38 +02:00
parent 96b02f8b50
commit b871cd9da2
28 changed files with 1141 additions and 682 deletions
+6 -4
View File
@@ -12,6 +12,7 @@ endif()
add_compile_options(-Wall -Wextra -Wpedantic) add_compile_options(-Wall -Wextra -Wpedantic)
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
find_package(yaml-cpp REQUIRED)
# --- Asio (standalone, header-only; from libasio-dev) --------------------- # --- Asio (standalone, header-only; from libasio-dev) ---------------------
find_path(ASIO_INCLUDE_DIR asio.hpp) find_path(ASIO_INCLUDE_DIR asio.hpp)
@@ -26,18 +27,16 @@ target_link_libraries(asio INTERFACE Threads::Threads)
# --- Core library --------------------------------------------------------- # --- Core library ---------------------------------------------------------
add_library(secsgem add_library(secsgem
src/secs2/item.cpp
src/secs2/codec.cpp src/secs2/codec.cpp
src/secs2/message.cpp
src/hsms/header.cpp src/hsms/header.cpp
src/hsms/frame.cpp
src/hsms/connection.cpp src/hsms/connection.cpp
src/gem/control_state.cpp src/gem/control_state.cpp
src/gem/data_model.cpp src/gem/data_model.cpp
src/config/loader.cpp
src/endpoint.cpp src/endpoint.cpp
) )
target_include_directories(secsgem PUBLIC include) target_include_directories(secsgem PUBLIC include)
target_link_libraries(secsgem PUBLIC asio) target_link_libraries(secsgem PUBLIC asio yaml-cpp)
# --- Demo executables ----------------------------------------------------- # --- Demo executables -----------------------------------------------------
add_executable(secs_server apps/secs_server.cpp) add_executable(secs_server apps/secs_server.cpp)
@@ -63,6 +62,9 @@ add_executable(secsgem_tests
tests/test_control_state.cpp tests/test_control_state.cpp
tests/test_data_model.cpp tests/test_data_model.cpp
tests/test_messages.cpp tests/test_messages.cpp
tests/test_loader.cpp
) )
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) 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) add_test(NAME secsgem_tests COMMAND secsgem_tests)
+1
View File
@@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ninja-build \ ninja-build \
git \ git \
libasio-dev \ libasio-dev \
libyaml-cpp-dev \
ca-certificates \ ca-certificates \
bash \ bash \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+46 -25
View File
@@ -25,36 +25,31 @@ docker compose up --no-deps server client # live two-container demo
## Architecture ## 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 │ │ app / demo │
│ apps/secs_server.cpp apps/secs_client.cpp │ │ apps/secs_server.cpp apps/secs_client.cpp │
│ uses gem::Router for SECS dispatch │
└────────────┬───────────────────────────┬─────────────────────┘ └────────────┬───────────────────────────┬─────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
secsgem::gem (E30 / E5 logic) secsgem::config loader.hpp YAML -> tables + data model
data_model.hpp SVIDs, DVIDs, ECIDs, CEIDs, alarms, secsgem::gem control_state (table-driven), data_model,
reports, links, recipes messages (SxFy builders), router
control_state.h E30 control state machine secsgem::hsms Connection (Asio), Header, Frame, Timers
messages.hpp all SxFy builders + parsers secsgem::secs2 Item, codec (encode/decode), Message
└────────────┬─────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────┘
``` ```
@@ -66,16 +61,42 @@ secs-gem/
├── CMakeLists.txt ├── CMakeLists.txt
├── implementation_plan.md # the 7-layer spec-as-data roadmap ├── implementation_plan.md # the 7-layer spec-as-data roadmap
├── COMPLIANCE.md # per-capability E5/E30/E37 audit ├── 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/ ├── include/secsgem/
│ ├── secs2/{item,codec,message}.hpp │ ├── secs2/{item,codec,message}.hpp
│ ├── hsms/{types,header,frame,connection}.hpp │ ├── hsms/{header,connection}.hpp # header.hpp also holds Frame + Timers
│ ├── gem/{control_state,data_model,messages}.hpp │ ├── gem/{control_state,data_model,messages,router}.hpp
│ ├── config/loader.hpp
│ └── endpoint.hpp │ └── endpoint.hpp
├── src/{secs2,hsms,gem,endpoint.cpp}/*.cpp ├── src/{secs2,hsms,gem,config}/*.cpp + endpoint.cpp
├── apps/{secs_server,secs_client}.cpp ├── apps/{secs_server,secs_client}.cpp
└── tests/test_*.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 ## What's implemented
### HSMS (E37) ### HSMS (E37)
+130 -200
View File
@@ -1,7 +1,7 @@
// Passive SECS/GEM equipment: a small simulated tool. Drives the E30 control // Passive SECS/GEM equipment. Capabilities (SVIDs, ECIDs, CEIDs, alarms,
// state machine, owns an in-memory data dictionary (SVIDs, DVIDs, ECIDs, // recipes, host commands) come from data/equipment.yaml; the E30 control
// CEIDs, alarms, recipes), answers the GEM SxFy core, and emits S6F11 + // state machine comes from data/control_state.yaml. Dispatch is a Router
// S5F1 primaries when host-subscribed events fire. // table. No imperative if-ladder; no in-code device data dictionary.
#include <asio.hpp> #include <asio.hpp>
#include <chrono> #include <chrono>
@@ -10,14 +10,14 @@
#include <memory> #include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
#include <system_error>
#include <vector> #include <vector>
#include "secsgem/config/loader.hpp"
#include "secsgem/endpoint.hpp" #include "secsgem/endpoint.hpp"
#include "secsgem/gem/control_state.hpp" #include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp" #include "secsgem/gem/data_model.hpp"
#include "secsgem/gem/messages.hpp" #include "secsgem/gem/messages.hpp"
#include "secsgem/secs2/item.hpp" #include "secsgem/gem/router.hpp"
#include "secsgem/secs2/message.hpp" #include "secsgem/secs2/message.hpp"
using namespace secsgem; using namespace secsgem;
@@ -32,50 +32,10 @@ std::string arg(int argc, char** argv, const std::string& key, const std::string
return def; return def;
} }
constexpr const char* kModelName = "SECSGEM-SIM";
constexpr const char* kSoftRev = "0.1.0";
// SVIDs
constexpr uint32_t kSvidControlState = 1; constexpr uint32_t kSvidControlState = 1;
constexpr uint32_t kSvidClock = 2; constexpr uint32_t kSvidClock = 2;
constexpr uint32_t kSvidEventsEnabled = 3;
// ECIDs void refresh(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) {
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) {
m.set_status_value(kSvidControlState, s2::Item::ascii(gem::control_state_name(sm.state()))); 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())); 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 } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
Server::Config cfg; const auto port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000")));
cfg.port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000"))); const auto equipment_yaml = arg(argc, argv, "--config", "/app/data/equipment.yaml");
cfg.device_id = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--device", "0"))); const auto state_yaml = arg(argc, argv, "--state-table", "/app/data/control_state.yaml");
asio::io_context io;
Server server(io, cfg);
auto logfn = [](const std::string& m) { std::cout << "[equip] " << m << std::endl; }; auto logfn = [](const std::string& m) { std::cout << "[equip] " << m << std::endl; };
server.on_log(logfn);
auto sm = std::make_shared<gem::ControlStateMachine>();
auto model = std::make_shared<gem::EquipmentDataModel>(); auto model = std::make_shared<gem::EquipmentDataModel>();
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<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 active_conn = std::make_shared<std::weak_ptr<Connection>>();
// 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) { auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) {
asio::post(io, [active_conn, model, logfn, ceid]() { asio::post(io, [active_conn, model, logfn, ceid]() {
auto conn = active_conn->lock(); auto conn = active_conn->lock();
@@ -113,15 +85,10 @@ int main(int argc, char** argv) {
logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" + logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" +
std::to_string(reports.size()) + " reports)"); std::to_string(reports.size()) + " reports)");
conn->send_request(gem::s6f11_event_report(0, ceid, reports), conn->send_request(gem::s6f11_event_report(0, ceid, reports),
[logfn, ceid](std::error_code ec, const s2::Message&) { [](std::error_code, const s2::Message&) {});
if (ec)
logfn("CEID " + std::to_string(ceid) + " emit failed: " + ec.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) { auto emit_alarm_set = [&io, active_conn, model, logfn, emit_event](uint32_t alid) {
asio::post(io, [active_conn, model, logfn, emit_event, alid]() { asio::post(io, [active_conn, model, logfn, emit_event, alid]() {
auto conn = active_conn->lock(); auto conn = active_conn->lock();
@@ -130,64 +97,33 @@ int main(int argc, char** argv) {
if (!alarm) return; if (!alarm) return;
auto alcd = model->alarm_set(alid); auto alcd = model->alarm_set(alid);
if (!alcd) return; if (!alcd) return;
if (!model->alarm_enabled(alid)) { if (model->alarm_enabled(alid)) {
logfn("alarm " + std::to_string(alid) + " not enabled; suppressed");
} else {
logfn("emit S5F1 alarm set ALID=" + std::to_string(alid)); logfn("emit S5F1 alarm set ALID=" + std::to_string(alid));
conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text), conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text),
[](std::error_code, const s2::Message&) {}); [](std::error_code, const s2::Message&) {});
} 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( 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) + " -> " + logfn(std::string("control: ") + gem::control_state_name(from) + " -> " +
gem::control_state_name(to) + " (" + gem::control_event_name(ev) + ")"); 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, // ---- Build the SECS dispatch table once -------------------------------
emit_alarm_set](std::shared_ptr<Connection> conn) { gem::Router router;
*active_conn = conn;
conn->set_closed_handler([active_conn, logfn](const std::string&) { router.on(1, 1, [desc, logfn](const s2::Message&) {
active_conn->reset(); 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> {
conn->set_selected_handler([logfn, sm]() { refresh(*model, *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<s2::Message> {
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); auto svids = gem::parse_s1f3(msg);
if (!svids) return s2::Message(1, 0, false); if (!svids) return s2::Message(1, 0, false);
std::vector<std::optional<s2::Item>> values; std::vector<std::optional<s2::Item>> values;
@@ -199,32 +135,29 @@ int main(int argc, char** argv) {
values.push_back(sv ? std::optional<s2::Item>(sv->value) : std::nullopt); values.push_back(sv ? std::optional<s2::Item>(sv->value) : std::nullopt);
} }
} }
logfn("S1F3; replying S1F4 (" + std::to_string(values.size()) + " values)"); logfn("S1F3 -> S1F4 (" + std::to_string(values.size()) + " values)");
return gem::s1f4_selected_status_data(values); return gem::s1f4_selected_status_data(values);
} });
if (s == 1 && f == 11) { router.on(1, 11, [model, logfn](const s2::Message&) {
logfn("S1F11; replying S1F12 (status namelist)"); logfn("S1F11 -> S1F12 (namelist)");
return gem::s1f12_status_namelist_data(model->all_status_variables()); return gem::s1f12_status_namelist_data(model->all_status_variables());
} });
if (s == 1 && f == 13) { router.on(1, 13, [desc, logfn](const s2::Message&) {
logfn("S1F13; replying S1F14 (COMMACK=0)"); logfn("S1F13 -> S1F14");
return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, kModelName, kSoftRev); return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, desc.model_name, desc.software_rev);
} });
if (s == 1 && f == 15) { router.on(1, 15, [sm, logfn](const s2::Message&) {
auto ack = sm->on_host_request_offline(); auto ack = sm->on_host_request_offline();
logfn(std::string("S1F15; replying S1F16 (OFLACK=") + logfn("S1F15 -> S1F16 OFLACK=" + std::to_string(static_cast<int>(ack)));
std::to_string(static_cast<int>(ack)) + ")");
return gem::s1f16_offline_ack(ack); return gem::s1f16_offline_ack(ack);
} });
if (s == 1 && f == 17) { router.on(1, 17, [sm, logfn](const s2::Message&) {
auto ack = sm->on_host_request_online(); auto ack = sm->on_host_request_online();
logfn(std::string("S1F17; replying S1F18 (ONLACK=") + logfn("S1F17 -> S1F18 ONLACK=" + std::to_string(static_cast<int>(ack)));
std::to_string(static_cast<int>(ack)) + ")");
return gem::s1f18_online_ack(ack); return gem::s1f18_online_ack(ack);
} });
// ---- S2: equipment control + reports ----------------------------- router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional<s2::Message> {
if (s == 2 && f == 13) {
auto ids = gem::parse_u4_list_body(msg); auto ids = gem::parse_u4_list_body(msg);
if (!ids) return s2::Message(2, 0, false); if (!ids) return s2::Message(2, 0, false);
std::vector<s2::Item> values; std::vector<s2::Item> values;
@@ -232,141 +165,138 @@ int main(int argc, char** argv) {
auto ec = model->equipment_constant(id); auto ec = model->equipment_constant(id);
values.push_back(ec ? ec->value : s2::Item::list({})); values.push_back(ec ? ec->value : s2::Item::list({}));
} }
logfn("S2F13; replying S2F14 (" + std::to_string(values.size()) + " values)"); logfn("S2F13 -> S2F14 (" + std::to_string(values.size()) + " values)");
return gem::s2f14_ec_data(values); return gem::s2f14_ec_data(values);
} });
if (s == 2 && f == 15) { router.on(2, 15, [model, logfn](const s2::Message& msg) {
auto sets = gem::parse_s2f15(msg); auto sets = gem::parse_s2f15(msg);
if (!sets) return gem::s2f16_ec_ack(gem::EquipmentAck::Denied_OutOfRange);
auto eac = gem::EquipmentAck::Accept; auto eac = gem::EquipmentAck::Accept;
if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange;
else
for (const auto& [id, val] : *sets) { for (const auto& [id, val] : *sets) {
auto r = model->set_equipment_constant_value(id, val); auto r = model->set_equipment_constant_value(id, val);
if (r != gem::EquipmentAck::Accept) eac = r; if (r != gem::EquipmentAck::Accept) eac = r;
} }
logfn(std::string("S2F15; replying S2F16 (EAC=") + logfn("S2F15 -> S2F16 EAC=" + std::to_string(static_cast<int>(eac)));
std::to_string(static_cast<int>(eac)) + ")");
return gem::s2f16_ec_ack(eac); return gem::s2f16_ec_ack(eac);
} });
if (s == 2 && f == 17) { router.on(2, 17, [model, logfn](const s2::Message&) {
logfn("S2F17; replying S2F18 (clock)"); logfn("S2F17 -> S2F18 (clock)");
return gem::s2f18_date_time_data(model->current_time_string()); return gem::s2f18_date_time_data(model->current_time_string());
} });
if (s == 2 && f == 29) { router.on(2, 29, [model, logfn](const s2::Message& msg) {
auto ids = gem::parse_u4_list_body(msg); auto ids = gem::parse_u4_list_body(msg);
std::vector<gem::EquipmentConstant> ecs; std::vector<gem::EquipmentConstant> ecs;
if (ids && ids->empty()) { if (ids && ids->empty()) ecs = model->all_equipment_constants();
ecs = model->all_equipment_constants(); else if (ids)
} else if (ids) {
for (auto id : *ids) { for (auto id : *ids) {
auto ec = model->equipment_constant(id); auto ec = model->equipment_constant(id);
if (ec) ecs.push_back(*ec); if (ec) ecs.push_back(*ec);
} }
} logfn("S2F29 -> S2F30 (" + std::to_string(ecs.size()) + " ECs)");
logfn("S2F29; replying S2F30 (" + std::to_string(ecs.size()) + " EC entries)");
return gem::s2f30_ec_namelist_data(ecs); return gem::s2f30_ec_namelist_data(ecs);
} });
if (s == 2 && f == 31) { router.on(2, 31, [model, logfn](const s2::Message& msg) {
auto t = gem::parse_s2f31(msg); auto t = gem::parse_s2f31(msg);
auto ack = t ? model->set_time_string(*t) : gem::TimeAck::Error; auto ack = t ? model->set_time_string(*t) : gem::TimeAck::Error;
logfn(std::string("S2F31; replying S2F32 (TIACK=") + logfn("S2F31 -> S2F32 TIACK=" + std::to_string(static_cast<int>(ack)));
std::to_string(static_cast<int>(ack)) + ")");
return gem::s2f32_date_time_ack(ack); return gem::s2f32_date_time_ack(ack);
} });
if (s == 2 && f == 33) { router.on(2, 33, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s2f33(msg); auto req = gem::parse_s2f33(msg);
auto ack = req ? model->define_reports(req->reports) auto ack = req ? model->define_reports(req->reports)
: gem::DefineReportAck::InvalidFormat; : gem::DefineReportAck::InvalidFormat;
logfn(std::string("S2F33; replying S2F34 (DRACK=") + logfn("S2F33 -> S2F34 DRACK=" + std::to_string(static_cast<int>(ack)));
std::to_string(static_cast<int>(ack)) + ")");
return gem::s2f34_define_report_ack(ack); return gem::s2f34_define_report_ack(ack);
} });
if (s == 2 && f == 35) { router.on(2, 35, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s2f35(msg); auto req = gem::parse_s2f35(msg);
auto ack = req ? model->link_event_reports(req->links) : gem::LinkEventAck::InvalidFormat; auto ack = req ? model->link_event_reports(req->links) : gem::LinkEventAck::InvalidFormat;
logfn(std::string("S2F35; replying S2F36 (LRACK=") + logfn("S2F35 -> S2F36 LRACK=" + std::to_string(static_cast<int>(ack)));
std::to_string(static_cast<int>(ack)) + ")");
return gem::s2f36_link_event_report_ack(ack); return gem::s2f36_link_event_report_ack(ack);
} });
if (s == 2 && f == 37) { router.on(2, 37, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s2f37(msg); auto req = gem::parse_s2f37(msg);
auto ack = req ? model->enable_events(req->enable, req->ceids) auto ack = req ? model->enable_events(req->enable, req->ceids)
: gem::EnableEventAck::UnknownCeid; : gem::EnableEventAck::UnknownCeid;
logfn(std::string("S2F37 ") + (req && req->enable ? "enable" : "disable") + logfn(std::string("S2F37 ") + (req && req->enable ? "enable" : "disable") +
"; replying S2F38 (ERACK=" + std::to_string(static_cast<int>(ack)) + ")"); " -> S2F38 ERACK=" + std::to_string(static_cast<int>(ack)));
return gem::s2f38_enable_event_ack(ack); return gem::s2f38_enable_event_ack(ack);
} });
if (s == 2 && f == 41) { router.on(2, 41, [model, logfn, emit_event, emit_alarm_set](const s2::Message& msg) {
auto cmd = gem::parse_s2f41(msg); auto cmd = gem::parse_s2f41(msg);
if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid); if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid);
auto ack = model->dispatch_command(cmd->rcmd, cmd->params); auto result = model->dispatch_command(cmd->rcmd, cmd->params);
logfn("S2F41 RCMD=" + cmd->rcmd + "; replying S2F42 (HCACK=" + logfn("S2F41 RCMD=" + cmd->rcmd + " -> S2F42 HCACK=" +
std::to_string(static_cast<int>(ack)) + ")"); std::to_string(static_cast<int>(result.ack)));
return gem::s2f42_host_command_ack(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);
});
// ---- S5: alarms -------------------------------------------------- router.on(5, 3, [model, logfn](const s2::Message& msg) {
if (s == 5 && f == 3) {
auto req = gem::parse_s5f3(msg); auto req = gem::parse_s5f3(msg);
auto ack = req ? model->set_alarm_enabled(req->alid, req->enable) auto ack = req ? model->set_alarm_enabled(req->alid, req->enable)
: gem::AlarmAck::Error; : gem::AlarmAck::Error;
logfn(std::string("S5F3 ALID=") + (req ? std::to_string(req->alid) : "?") + logfn(std::string("S5F3 -> S5F4 ACKC5=") + std::to_string(static_cast<int>(ack)));
(req && req->enable ? " enable" : " disable") +
"; replying S5F4 (ACKC5=" + std::to_string(static_cast<int>(ack)) + ")");
return gem::s5f4_enable_alarm_ack(ack); return gem::s5f4_enable_alarm_ack(ack);
} });
if (s == 5 && f == 5) { router.on(5, 5, [model, logfn](const s2::Message& msg) {
auto ids = gem::parse_u4_list_body(msg); auto ids = gem::parse_u4_list_body(msg);
std::vector<gem::Alarm> alarms; std::vector<gem::Alarm> alarms;
if (ids && ids->empty()) { if (ids && ids->empty()) alarms = model->all_alarms();
alarms = model->all_alarms(); else if (ids)
} else if (ids) {
for (auto id : *ids) { for (auto id : *ids) {
auto a = model->alarm(id); auto a = model->alarm(id);
if (a) alarms.push_back(*a); if (a) alarms.push_back(*a);
} }
} logfn("S5F5 -> S5F6 (" + std::to_string(alarms.size()) + " alarms)");
logfn("S5F5; replying S5F6 (" + std::to_string(alarms.size()) + " alarms)"); return gem::s5f6_list_alarms_data(
return gem::s5f6_list_alarms_data(alarms, [model](uint32_t id) { alarms, [model](uint32_t id) { return model->alarm_active(id); });
return model->alarm_active(id);
}); });
}
// ---- S7: process programs ---------------------------------------- router.on(7, 3, [model, logfn](const s2::Message& msg) {
if (s == 7 && f == 3) {
auto pp = gem::parse_s7f3(msg); auto pp = gem::parse_s7f3(msg);
if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError); if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError);
model->add_process_program(pp->ppid, pp->ppbody); model->add_process_program(pp->ppid, pp->ppbody);
logfn("S7F3 PPID=" + pp->ppid + " (" + std::to_string(pp->ppbody.size()) + logfn("S7F3 PPID=" + pp->ppid + " -> S7F4 (Accept)");
" bytes); replying S7F4 (ACKC7=0)");
return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept); return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept);
} });
if (s == 7 && f == 5) { router.on(7, 5, [model, logfn](const s2::Message& msg) {
auto ppid = gem::parse_s7f5(msg); auto ppid = gem::parse_s7f5(msg);
if (!ppid) return gem::s7f6_process_program_data("", ""); if (!ppid) return gem::s7f6_process_program_data("", "");
auto body = model->process_program(*ppid); auto body = model->process_program(*ppid);
logfn("S7F5 PPID=" + *ppid + (body ? "; replying S7F6" : "; PPID not found")); logfn("S7F5 PPID=" + *ppid + " -> S7F6");
return gem::s7f6_process_program_data(*ppid, body ? *body : ""); return gem::s7f6_process_program_data(*ppid, body ? *body : "");
} });
if (s == 7 && f == 19) { router.on(7, 19, [model, logfn](const s2::Message&) {
auto list = model->process_program_list(); auto list = model->process_program_list();
logfn("S7F19; replying S7F20 (" + std::to_string(list.size()) + " PPIDs)"); logfn("S7F19 -> S7F20 (" + std::to_string(list.size()) + " PPIDs)");
return gem::s7f20_current_eppd_data(list); return gem::s7f20_current_eppd_data(list);
} });
// ---- S10: terminal services -------------------------------------- router.on(10, 1, [logfn](const s2::Message& msg) {
if (s == 10 && f == 1) {
auto td = gem::parse_terminal_display(msg); auto td = gem::parse_terminal_display(msg);
if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text);
return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted); 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;
}); });
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(); server.start();
+45
View File
@@ -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}
+51
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#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<uint32_t> 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
+1 -1
View File
@@ -7,7 +7,7 @@
#include <string> #include <string>
#include "secsgem/hsms/connection.hpp" #include "secsgem/hsms/connection.hpp"
#include "secsgem/hsms/types.hpp" #include "secsgem/hsms/header.hpp"
namespace secsgem { namespace secsgem {
+51 -33
View File
@@ -2,25 +2,25 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <optional>
#include <string> #include <string>
#include <vector>
namespace secsgem::gem { namespace secsgem::gem {
// E30 Control State Model (§6.2). Drives whether the equipment is // E30 §6.2 Control State Model.
// communicating with a host and, if so, who is in control.
enum class ControlState { enum class ControlState {
EquipmentOffline, // equipment is offline; no host comms attempted EquipmentOffline,
AttemptOnline, // transient: equipment trying to come online AttemptOnline,
HostOffline, // HSMS up but host has not established control HostOffline,
OnlineLocal, // online, operator in control; host observes only OnlineLocal,
OnlineRemote, // online, host in control OnlineRemote,
}; };
const char* control_state_name(ControlState s); const char* control_state_name(ControlState s);
std::optional<ControlState> parse_control_state(const std::string& s);
bool is_online(ControlState 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 { enum class ControlEvent {
OperatorSwitchOnline, OperatorSwitchOnline,
OperatorSwitchOffline, OperatorSwitchOffline,
@@ -28,72 +28,90 @@ enum class ControlEvent {
OperatorSwitchRemote, OperatorSwitchRemote,
AttemptComplete, AttemptComplete,
AttemptFailed, AttemptFailed,
HostRequestOnline, // S1F17 HostRequestOnline,
HostRequestOffline, // S1F15 HostRequestOffline,
}; };
const char* control_event_name(ControlEvent e); const char* control_event_name(ControlEvent e);
std::optional<ControlEvent> parse_control_event(const std::string& s);
// S1F18 ONLACK codes.
enum class OnlineAck : uint8_t { enum class OnlineAck : uint8_t {
Accept = 0, Accept = 0,
NotAccept = 1, NotAccept = 1,
AlreadyOnline = 2, AlreadyOnline = 2,
}; };
// S1F16 OFLACK codes.
enum class OfflineAck : uint8_t { enum class OfflineAck : uint8_t {
Accept = 0, Accept = 0,
}; };
// S1F14 COMMACK codes.
enum class CommAck : uint8_t { enum class CommAck : uint8_t {
Accept = 0, Accept = 0,
Denied = 1, Denied = 1,
}; };
// Drives the E30 control state. Pure state machine — no IO. The Server layer // One row of the control-state transition table (E30 §6.2 + extensions).
// owns one of these per equipment and dispatches host-initiated events and // `to` and `then` are both optional: a row may produce no transition (e.g.
// operator actions into it. // host_request_online while already OnlineRemote — ack only); a row may also
class ControlStateMachine { // chain straight through the transient AttemptOnline state via `then`.
public: // `ack_code` is the raw uint8_t carried by S1F18 (ONLACK) or S1F16 (OFLACK);
struct Config { // its interpretation depends on the triggering event.
ControlState initial = ControlState::HostOffline; struct ControlTransition {
// When ATTEMPT_ONLINE completes via a host request, do we land in REMOTE ControlState from;
// (host in control) or LOCAL (host observes only)? For host-initiated ControlEvent on;
// online this defaults to REMOTE; for operator-initiated online it follows std::optional<ControlState> to;
// `operator_default_remote`. std::optional<ControlState> then;
bool host_request_grants_remote = true; std::optional<uint8_t> ack_code;
bool operator_default_remote = false;
}; };
// 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<ControlTransition>& 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<ControlTransition> 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:
using StateChangeHandler = using StateChangeHandler =
std::function<void(ControlState from, ControlState to, ControlEvent trigger)>; std::function<void(ControlState from, ControlState to, ControlEvent trigger)>;
ControlStateMachine(); ControlStateMachine();
explicit ControlStateMachine(Config cfg); explicit ControlStateMachine(ControlTransitionTable table,
ControlState initial = ControlState::HostOffline);
ControlState state() const { return state_; } ControlState state() const { return state_; }
bool online() const { return is_online(state_); } bool online() const { return is_online(state_); }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); } void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
// Operator actions. Each returns true if a transition occurred, false if the // Operator actions. Return true if a transition (or self-ack) was found.
// current state didn't permit it.
bool operator_online(); bool operator_online();
bool operator_offline(); bool operator_offline();
bool operator_local(); bool operator_local();
bool operator_remote(); bool operator_remote();
// Host-initiated requests. The SM responds with the SEMI-mandated ack code // Host requests. Return the ack code from the matching table row.
// and performs any transition.
OnlineAck on_host_request_online(); OnlineAck on_host_request_online();
OfflineAck on_host_request_offline(); OfflineAck on_host_request_offline();
private: private:
// Apply the matching row (if any) for (state_, event); returns the row.
const ControlTransition* fire(ControlEvent on);
void transition(ControlState next, ControlEvent trigger); void transition(ControlState next, ControlEvent trigger);
Config cfg_; ControlTransitionTable table_;
ControlState state_; ControlState state_;
StateChangeHandler on_change_; StateChangeHandler on_change_;
}; };
+18 -4
View File
@@ -161,7 +161,21 @@ class EquipmentDataModel {
std::string name; std::string name;
s2::Item value; s2::Item value;
}; };
using HostCommandHandler = std::function<HostCmdAck(const std::vector<CommandParam>&)>;
// Declarative host-command effect, loaded from YAML. The server
// dispatches a command by looking up the spec and (optionally) firing
// a CEID emit / setting an alarm after the S2F42 reply is sent.
struct CommandSpec {
HostCmdAck ack = HostCmdAck::Accept;
std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm;
};
struct CommandResult {
HostCmdAck ack = HostCmdAck::InvalidCommand;
std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm;
};
// --- SVID --------------------------------------------------------------- // --- SVID ---------------------------------------------------------------
void add_status_variable(StatusVariable sv); void add_status_variable(StatusVariable sv);
@@ -191,8 +205,8 @@ class EquipmentDataModel {
TimeAck set_time_string(const std::string& time_str); TimeAck set_time_string(const std::string& time_str);
// --- Host commands ------------------------------------------------------ // --- Host commands ------------------------------------------------------
void register_command(const std::string& rcmd, HostCommandHandler handler); void register_command(const std::string& rcmd, CommandSpec spec);
HostCmdAck dispatch_command(const std::string& rcmd, CommandResult dispatch_command(const std::string& rcmd,
const std::vector<CommandParam>& params) const; const std::vector<CommandParam>& params) const;
bool has_command(const std::string& rcmd) const; bool has_command(const std::string& rcmd) const;
@@ -247,7 +261,7 @@ class EquipmentDataModel {
std::map<uint32_t, DataVariable> dvids_; std::map<uint32_t, DataVariable> dvids_;
std::map<uint32_t, EquipmentConstant> ecids_; std::map<uint32_t, EquipmentConstant> ecids_;
std::int64_t time_offset_seconds_ = 0; std::int64_t time_offset_seconds_ = 0;
std::map<std::string, HostCommandHandler> commands_; std::map<std::string, CommandSpec> commands_;
std::map<uint32_t, CollectionEvent> ceids_; std::map<uint32_t, CollectionEvent> ceids_;
std::map<uint32_t, Report> reports_; std::map<uint32_t, Report> reports_;
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
#include <utility>
#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<std::optional<s2::Message>(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<s2::Message> 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<std::pair<uint8_t, uint8_t>, Handler> handlers_;
Handler fallback_;
};
} // namespace secsgem::gem
+1 -2
View File
@@ -11,8 +11,7 @@
#include <string> #include <string>
#include <system_error> #include <system_error>
#include "secsgem/hsms/frame.hpp" #include "secsgem/hsms/header.hpp"
#include "secsgem/hsms/types.hpp"
#include "secsgem/secs2/message.hpp" #include "secsgem/secs2/message.hpp"
namespace secsgem::hsms { namespace secsgem::hsms {
-40
View File
@@ -1,40 +0,0 @@
#pragma once
#include <cstdint>
#include <stdexcept>
#include <vector>
#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<uint8_t> body;
Frame() = default;
explicit Frame(Header h, std::vector<uint8_t> b = {})
: header(h), body(std::move(b)) {}
// Full wire bytes including the 4-byte length prefix.
std::vector<uint8_t> 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
+79 -7
View File
@@ -1,16 +1,64 @@
#pragma once #pragma once
#include <array> #include <array>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <stdexcept>
#include <string> #include <string>
#include <vector>
#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 { namespace secsgem::hsms {
// The fixed 10-byte HSMS message header (SEMI E37). The interpretation of // ---- Session type + status / reason enums --------------------------------
// byte2/byte3 depends on `stype`: for data messages byte2 = (W<<7)|stream and
// byte3 = function; for control messages they carry status / reason codes. 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 { struct Header {
uint16_t session_id = kControlSessionId; uint16_t session_id = kControlSessionId;
uint8_t byte2 = 0; uint8_t byte2 = 0;
@@ -19,7 +67,6 @@ struct Header {
SType stype = SType::Data; SType stype = SType::Data;
uint32_t system_bytes = 0; uint32_t system_bytes = 0;
// Data-message field views.
bool w_bit() const { return (byte2 & 0x80) != 0; } bool w_bit() const { return (byte2 & 0x80) != 0; }
uint8_t stream() const { return byte2 & 0x7F; } uint8_t stream() const { return byte2 & 0x7F; }
uint8_t function() const { return byte3; } uint8_t function() const { return byte3; }
@@ -51,10 +98,35 @@ struct Header {
std::array<uint8_t, 10> encode() const; std::array<uint8_t, 10> encode() const;
static Header decode(const uint8_t* data); // reads exactly 10 bytes static Header decode(const uint8_t* data); // reads exactly 10 bytes
std::string describe() const; std::string describe() const;
bool operator==(const Header&) const = default; 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<uint8_t> body;
Frame() = default;
explicit Frame(Header h, std::vector<uint8_t> b = {})
: header(h), body(std::move(b)) {}
// Full wire bytes including the 4-byte length prefix.
std::vector<uint8_t> 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 } // namespace secsgem::hsms
-62
View File
@@ -1,62 +0,0 @@
#pragma once
#include <chrono>
#include <cstdint>
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
+41 -3
View File
@@ -28,12 +28,48 @@ enum class Format : uint8_t {
U4 = 054, // 44 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. // 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 // Lists are special (their length is an element count, not a byte count) and
// return 0 here. // 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; // 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 // 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, // Number of elements: child count for lists, character count for ASCII,
// array length for numeric/binary formats. // 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 ------------------------------------------------- // --- Factory functions -------------------------------------------------
static Item list(List items) { return Item(Format::List, std::move(items)); } static Item list(List items) { return Item(Format::List, std::move(items)); }
+22 -7
View File
@@ -5,6 +5,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "secsgem/secs2/codec.hpp"
#include "secsgem/secs2/item.hpp" #include "secsgem/secs2/item.hpp"
namespace secsgem::secs2 { namespace secsgem::secs2 {
@@ -22,16 +23,30 @@ struct Message {
Message(uint8_t s, uint8_t f, bool w, std::optional<Item> b = std::nullopt) Message(uint8_t s, uint8_t f, bool w, std::optional<Item> b = std::nullopt)
: stream(s), function(f), reply_expected(w), body(std::move(b)) {} : 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<uint8_t> encode_body() const {
std::vector<uint8_t> 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, static Message from_body(uint8_t stream, uint8_t function, bool reply_expected,
const std::vector<uint8_t>& body_bytes); const std::vector<uint8_t>& body_bytes) {
Message m(stream, function, reply_expected);
if (!body_bytes.empty()) m.body = decode(body_bytes);
return m;
}
// e.g. S1F2 W // e.g. S1F2
// <L [2] <A "MDLN"> <A "1.0"> > // <L [2] <A "MDLN"> <A "1.0"> > .
std::string sml() const; 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 } // namespace secsgem::secs2
+214
View File
@@ -0,0 +1,214 @@
#include "secsgem/config/loader.hpp"
#include <yaml-cpp/yaml.h>
#include <cstdint>
#include <stdexcept>
#include <string>
#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 <typename T>
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<T>();
} 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<std::string>();
if (type == "ASCII") return s2::Item::ascii(value_n ? value_n.as<std::string>() : "");
if (type == "BOOLEAN") return s2::Item::boolean(value_n && value_n.as<bool>());
if (type == "BINARY") {
std::vector<uint8_t> b;
if (value_n && value_n.IsSequence())
for (const auto& e : value_n) b.push_back(static_cast<uint8_t>(e.as<int>()));
return s2::Item::binary(std::move(b));
}
if (type == "U1") return s2::Item::u1(static_cast<uint8_t>(value_n.as<int>()));
if (type == "U2") return s2::Item::u2(static_cast<uint16_t>(value_n.as<int>()));
if (type == "U4") return s2::Item::u4(static_cast<uint32_t>(value_n.as<uint64_t>()));
if (type == "U8") return s2::Item::u8(value_n.as<uint64_t>());
if (type == "I1") return s2::Item::i1(static_cast<int8_t>(value_n.as<int>()));
if (type == "I2") return s2::Item::i2(static_cast<int16_t>(value_n.as<int>()));
if (type == "I4") return s2::Item::i4(static_cast<int32_t>(value_n.as<int>()));
if (type == "I8") return s2::Item::i8(value_n.as<int64_t>());
if (type == "F4") return s2::Item::f4(value_n.as<float>());
if (type == "F8") return s2::Item::f8(value_n.as<double>());
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<std::string>());
if (!parsed) fail(path, "unknown initial state `" + initial.as<std::string>() + "`");
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<std::string>(row["from"], where, "from"));
auto on = gem::parse_control_event(req_as<std::string>(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<std::string>());
if (!s) fail(where, "unknown `to` state");
t.to = *s;
}
if (auto th = row["then"]) {
auto s = gem::parse_control_state(th.as<std::string>());
if (!s) fail(where, "unknown `then` state");
t.then = *s;
}
if (auto ack = row["ack"]) {
const auto s = ack.as<std::string>();
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<uint8_t>(gem::OfflineAck::Accept);
} else {
t.ack_code = static_cast<uint8_t>(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<uint16_t>(d["id"].as<int>());
desc.model_name = d["model_name"].as<std::string>();
desc.software_rev = d["software_rev"].as<std::string>();
}
if (auto e = root["emit_on_control_change"]) {
if (!e.IsNull()) desc.emit_on_control_change = static_cast<uint32_t>(e.as<int>());
}
if (auto svids = root["svids"]) {
for (const auto& sv : svids) {
model.add_status_variable({
static_cast<uint32_t>(sv["id"].as<int>()),
sv["name"].as<std::string>(),
sv["units"] ? sv["units"].as<std::string>() : "",
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<uint32_t>(ec["id"].as<int>()),
ec["name"].as<std::string>(),
ec["units"] ? ec["units"].as<std::string>() : "",
value,
value, // default = initial value
ec["min"] ? ec["min"].as<std::string>() : "",
ec["max"] ? ec["max"].as<std::string>() : "",
});
}
}
if (auto ceids = root["ceids"]) {
for (const auto& ce : ceids) {
model.register_event({
static_cast<uint32_t>(ce["id"].as<int>()),
ce["name"].as<std::string>(),
});
}
}
if (auto alarms = root["alarms"]) {
for (const auto& a : alarms) {
model.add_alarm({
static_cast<uint32_t>(a["id"].as<int>()),
a["text"].as<std::string>(),
static_cast<uint8_t>(a["category"].as<int>()),
});
}
}
if (auto recipes = root["recipes"]) {
for (const auto& r : recipes) {
model.add_process_program(r["id"].as<std::string>(), r["body"].as<std::string>());
}
}
if (auto commands = root["host_commands"]) {
for (const auto& c : commands) {
gem::EquipmentDataModel::CommandSpec spec;
spec.ack = parse_hcack(c["ack"].as<std::string>(), path + " host_commands");
if (auto e = c["emit_ceid"])
spec.emit_ceid = static_cast<uint32_t>(e.as<int>());
if (auto a = c["set_alarm"])
spec.set_alarm = static_cast<uint32_t>(a.as<int>());
model.register_command(c["name"].as<std::string>(), spec);
}
}
return desc;
}
} // namespace secsgem::config
+101 -68
View File
@@ -1,5 +1,7 @@
#include "secsgem/gem/control_state.hpp" #include "secsgem/gem/control_state.hpp"
#include <algorithm>
namespace secsgem::gem { namespace secsgem::gem {
const char* control_state_name(ControlState s) { const char* control_state_name(ControlState s) {
@@ -13,6 +15,15 @@ const char* control_state_name(ControlState s) {
return "?"; return "?";
} }
std::optional<ControlState> 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) { bool is_online(ControlState s) {
return s == ControlState::OnlineLocal || s == ControlState::OnlineRemote; return s == ControlState::OnlineLocal || s == ControlState::OnlineRemote;
} }
@@ -31,10 +42,79 @@ const char* control_event_name(ControlEvent e) {
return "?"; return "?";
} }
ControlStateMachine::ControlStateMachine() : ControlStateMachine(Config{}) {} std::optional<ControlEvent> 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) void ControlTransitionTable::add(ControlTransition row) {
: cfg_(cfg), state_(cfg.initial) {} 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<uint8_t>(A::Accept)});
t.add({S::OnlineLocal, E::HostRequestOnline, std::nullopt, std::nullopt,
static_cast<uint8_t>(A::AlreadyOnline)});
t.add({S::OnlineRemote, E::HostRequestOnline, std::nullopt, std::nullopt,
static_cast<uint8_t>(A::AlreadyOnline)});
t.add({S::EquipmentOffline, E::HostRequestOnline, std::nullopt, std::nullopt,
static_cast<uint8_t>(A::NotAccept)});
t.add({S::AttemptOnline, E::HostRequestOnline, std::nullopt, std::nullopt,
static_cast<uint8_t>(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<uint8_t>(OfflineAck::Accept)});
}
t.add({S::OnlineLocal, E::HostRequestOffline, S::HostOffline, std::nullopt,
static_cast<uint8_t>(OfflineAck::Accept)});
t.add({S::OnlineRemote, E::HostRequestOffline, S::HostOffline, std::nullopt,
static_cast<uint8_t>(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) { void ControlStateMachine::transition(ControlState next, ControlEvent trigger) {
if (state_ == next) return; if (state_ == next) return;
@@ -43,84 +123,37 @@ void ControlStateMachine::transition(ControlState next, ControlEvent trigger) {
if (on_change_) on_change_(prev, next, 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() { bool ControlStateMachine::operator_online() {
switch (state_) { return fire(ControlEvent::OperatorSwitchOnline) != nullptr;
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;
}
bool ControlStateMachine::operator_offline() { bool ControlStateMachine::operator_offline() {
switch (state_) { return fire(ControlEvent::OperatorSwitchOffline) != nullptr;
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;
}
bool ControlStateMachine::operator_local() { bool ControlStateMachine::operator_local() {
if (state_ == ControlState::OnlineRemote) { return fire(ControlEvent::OperatorSwitchLocal) != nullptr;
transition(ControlState::OnlineLocal, ControlEvent::OperatorSwitchLocal);
return true;
} }
return false;
}
bool ControlStateMachine::operator_remote() { bool ControlStateMachine::operator_remote() {
if (state_ == ControlState::OnlineLocal) { return fire(ControlEvent::OperatorSwitchRemote) != nullptr;
transition(ControlState::OnlineRemote, ControlEvent::OperatorSwitchRemote);
return true;
}
return false;
} }
OnlineAck ControlStateMachine::on_host_request_online() { OnlineAck ControlStateMachine::on_host_request_online() {
switch (state_) { const ControlTransition* row = fire(ControlEvent::HostRequestOnline);
case ControlState::HostOffline: if (!row || !row->ack_code) return OnlineAck::NotAccept;
transition(ControlState::AttemptOnline, ControlEvent::HostRequestOnline); return static_cast<OnlineAck>(*row->ack_code);
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;
} }
OfflineAck ControlStateMachine::on_host_request_offline() { OfflineAck ControlStateMachine::on_host_request_offline() {
switch (state_) { const ControlTransition* row = fire(ControlEvent::HostRequestOffline);
case ControlState::OnlineLocal: if (!row || !row->ack_code) return OfflineAck::Accept;
case ControlState::OnlineRemote: return static_cast<OfflineAck>(*row->ack_code);
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;
} }
} // namespace secsgem::gem } // namespace secsgem::gem
+6 -6
View File
@@ -142,17 +142,17 @@ TimeAck EquipmentDataModel::set_time_string(const std::string& s) {
// ---- Host commands ------------------------------------------------------- // ---- Host commands -------------------------------------------------------
void EquipmentDataModel::register_command(const std::string& rcmd, HostCommandHandler handler) { void EquipmentDataModel::register_command(const std::string& rcmd, CommandSpec spec) {
commands_.insert_or_assign(rcmd, std::move(handler)); commands_.insert_or_assign(rcmd, std::move(spec));
} }
bool EquipmentDataModel::has_command(const std::string& rcmd) const { bool EquipmentDataModel::has_command(const std::string& rcmd) const {
return commands_.find(rcmd) != commands_.end(); return commands_.find(rcmd) != commands_.end();
} }
HostCmdAck EquipmentDataModel::dispatch_command( EquipmentDataModel::CommandResult EquipmentDataModel::dispatch_command(
const std::string& rcmd, const std::vector<CommandParam>& params) const { const std::string& rcmd, const std::vector<CommandParam>& /*params*/) const {
auto it = commands_.find(rcmd); auto it = commands_.find(rcmd);
if (it == commands_.end()) return HostCmdAck::InvalidCommand; if (it == commands_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt};
return it->second(params); return {it->second.ack, it->second.emit_ceid, it->second.set_alarm};
} }
// ---- Collection events --------------------------------------------------- // ---- Collection events ---------------------------------------------------
-32
View File
@@ -1,32 +0,0 @@
#include "secsgem/hsms/frame.hpp"
namespace secsgem::hsms {
std::vector<uint8_t> Frame::encode() const {
const std::size_t payload_len = kHeaderSize + body.size();
std::vector<uint8_t> out;
out.reserve(kLengthPrefixSize + payload_len);
out.push_back(static_cast<uint8_t>(payload_len >> 24));
out.push_back(static_cast<uint8_t>(payload_len >> 16));
out.push_back(static_cast<uint8_t>(payload_len >> 8));
out.push_back(static_cast<uint8_t>(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
+23
View File
@@ -56,4 +56,27 @@ std::string Header::describe() const {
return out; return out;
} }
std::vector<uint8_t> Frame::encode() const {
const std::size_t payload_len = kHeaderSize + body.size();
std::vector<uint8_t> out;
out.reserve(kLengthPrefixSize + payload_len);
out.push_back(static_cast<uint8_t>(payload_len >> 24));
out.push_back(static_cast<uint8_t>(payload_len >> 16));
out.push_back(static_cast<uint8_t>(payload_len >> 8));
out.push_back(static_cast<uint8_t>(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 } // namespace secsgem::hsms
-49
View File
@@ -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
-30
View File
@@ -1,30 +0,0 @@
#include "secsgem/secs2/message.hpp"
#include "secsgem/secs2/codec.hpp"
namespace secsgem::secs2 {
std::vector<uint8_t> 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<uint8_t>& 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
+34 -17
View File
@@ -10,7 +10,6 @@ namespace {
struct Recorder { struct Recorder {
std::vector<std::tuple<ControlState, ControlState, ControlEvent>> changes; std::vector<std::tuple<ControlState, ControlState, ControlEvent>> changes;
ControlStateMachine::StateChangeHandler handler() { ControlStateMachine::StateChangeHandler handler() {
return [this](ControlState from, ControlState to, ControlEvent ev) { return [this](ControlState from, ControlState to, ControlEvent ev) {
changes.emplace_back(from, to, 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 } // namespace
TEST_CASE("default initial state is HostOffline") { TEST_CASE("default initial state is HostOffline") {
@@ -27,7 +30,7 @@ TEST_CASE("default initial state is HostOffline") {
} }
TEST_CASE("custom initial state") { TEST_CASE("custom initial state") {
ControlStateMachine sm({.initial = ControlState::EquipmentOffline}); auto sm = make_sm(ControlState::EquipmentOffline);
CHECK(sm.state() == 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") { 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.on_host_request_online() == OnlineAck::NotAccept);
CHECK(sm.state() == ControlState::EquipmentOffline); CHECK(sm.state() == ControlState::EquipmentOffline);
} }
@@ -89,42 +92,36 @@ TEST_CASE("host request offline when already offline is idempotent Accept") {
CHECK(rec.changes.empty()); CHECK(rec.changes.empty());
} }
TEST_CASE("operator online from EquipmentOffline -> OnlineLocal by default") { TEST_CASE("operator online from EquipmentOffline -> OnlineLocal (default table)") {
ControlStateMachine sm({.initial = ControlState::EquipmentOffline}); auto sm = make_sm(ControlState::EquipmentOffline);
CHECK(sm.operator_online()); CHECK(sm.operator_online());
CHECK(sm.state() == ControlState::OnlineLocal); 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") { 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_FALSE(sm.operator_online());
CHECK(sm.state() == ControlState::OnlineLocal); CHECK(sm.state() == ControlState::OnlineLocal);
} }
TEST_CASE("operator offline from any online state -> HostOffline") { 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.operator_offline());
CHECK(sm.state() == ControlState::HostOffline); CHECK(sm.state() == ControlState::HostOffline);
} }
TEST_CASE("operator local toggles only from OnlineRemote") { 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.operator_local());
CHECK(sm.state() == ControlState::OnlineLocal); 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") { 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.operator_remote());
CHECK(sm.state() == ControlState::OnlineRemote); CHECK(sm.state() == ControlState::OnlineRemote);
CHECK_FALSE(sm.operator_remote()); // already remote CHECK_FALSE(sm.operator_remote());
} }
TEST_CASE("is_online classifier") { TEST_CASE("is_online classifier") {
@@ -134,3 +131,23 @@ TEST_CASE("is_online classifier") {
CHECK(is_online(ControlState::OnlineLocal)); CHECK(is_online(ControlState::OnlineLocal));
CHECK(is_online(ControlState::OnlineRemote)); 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<uint8_t>(OnlineAck::NotAccept)});
ControlStateMachine sm(t, ControlState::HostOffline);
CHECK(sm.on_host_request_online() == OnlineAck::NotAccept);
CHECK(sm.state() == ControlState::HostOffline);
}
+17 -5
View File
@@ -45,14 +45,26 @@ TEST_CASE("set_time_string accepts well-formed and rejects malformed") {
TEST_CASE("host command registry") { TEST_CASE("host command registry") {
EquipmentDataModel m; EquipmentDataModel m;
m.register_command("START", [](const auto&) { return HostCmdAck::Accept; }); m.register_command("START", {HostCmdAck::Accept, 300, std::nullopt});
m.register_command("STOP", [](const auto&) { return HostCmdAck::CannotDoNow; }); 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(m.has_command("START"));
CHECK_FALSE(m.has_command("PAUSE")); CHECK_FALSE(m.has_command("PAUSE"));
CHECK(m.dispatch_command("START", {}) == HostCmdAck::Accept);
CHECK(m.dispatch_command("STOP", {}) == HostCmdAck::CannotDoNow); auto start = m.dispatch_command("START", {});
CHECK(m.dispatch_command("UNKNOWN", {}) == HostCmdAck::InvalidCommand); 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 ------------------------------------------------------- // ---- Event reports -------------------------------------------------------
-1
View File
@@ -1,6 +1,5 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include "secsgem/hsms/frame.hpp"
#include "secsgem/hsms/header.hpp" #include "secsgem/hsms/header.hpp"
#include "secsgem/secs2/codec.hpp" #include "secsgem/secs2/codec.hpp"
+72
View File
@@ -0,0 +1,72 @@
#include <doctest/doctest.h>
#include <fstream>
#include <string>
#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);
}