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:
+6
-4
@@ -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)
|
||||||
|
|||||||
@@ -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/*
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)); }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 ---------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user