Files
secs-gem/apps/secs_server.cpp
T
raphael fc898f8410 feat: EquipmentRuntime engine owner + secs_gemd gRPC daemon
Extract the SECS/GEM engine wiring out of the secs_server app into a
reusable class, and stand up a language-agnostic gRPC daemon on top so a
tool's software (any language) can drive the equipment without linking C++
or knowing SEMI. Foundation for replacing a vendor's SECS/GEM server.

Engine reuse:
- EquipmentRuntime (include/secsgem/gem/runtime.hpp, src/gem/runtime.cpp):
  owns io_context, passive Server, model, control-state machine, Router;
  thread-safe outbound API (set_variable/emit_event/set_alarm/clear_alarm),
  on_command hook, deliver_or_spool, run()/run_async()/poll()/stop().
- register_default_handlers (src/gem/default_handlers.cpp): the 56 GEM
  handlers + domain emitters, relocated from secs_server so the app and the
  daemon speak byte-identical GEM. secs_server.cpp reduced ~1270 -> 113 lines.
- name_index.hpp: resolve_variable(name) -> VID (the name->id binding layer).

Daemon (apps/secs_gemd.cpp, proto/secsgem/v1/equipment.proto):
- runs the engine + HSMS link on a background thread; serves the gRPC
  Equipment service. Increment 1: SetVariables (name-resolved, plain
  value->Item) and GetControlState. proto carries the full v1 surface
  (universal + carrier/recipe/job tiers); remaining RPCs + the Subscribe
  command stream are next (docs/DAEMON_ROADMAP.md).
- CMake: opt-in SECSGEM_DAEMON, protoc/grpc_cpp_plugin codegen, gracefully
  skipped where protobuf/grpc++ are absent. Dockerfile gains the grpc deps.

Tests (proof): test_runtime, test_default_handlers (S1F1->S1F2, S2F41->hook),
test_name_index. Full suite 458/458, 2795 assertions; live server<->client
GEM300 demo still passes on the refactored server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:01:16 +02:00

114 lines
4.4 KiB
C++

// Passive SECS/GEM equipment. Capabilities (SVIDs, ECIDs, CEIDs, alarms,
// recipes, host commands) come from data/equipment.yaml; the E30 control
// state machine comes from data/control_state.yaml. Dispatch is a Router
// table. No imperative if-ladder; no in-code device data dictionary.
#include <asio.hpp>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/config/loader.hpp"
#include "secsgem/config/validate.hpp"
#include "secsgem/endpoint.hpp"
#include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp"
#include "secsgem/gem/e116_constants.hpp"
#include "secsgem/gem/e157_constants.hpp"
#include "secsgem/gem/e90_constants.hpp"
#include "secsgem/gem/messages.hpp"
#include "secsgem/gem/router.hpp"
#include "secsgem/gem/runtime.hpp"
#include "secsgem/gem/default_handlers.hpp"
#include "secsgem/secs2/message.hpp"
using namespace secsgem;
namespace s2 = secsgem::secs2;
namespace gem = secsgem::gem;
namespace {
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
for (int i = 1; i + 1 < argc; ++i)
if (key == argv[i]) return argv[i + 1];
return def;
}
bool has_flag(int argc, char** argv, const std::string& key) {
for (int i = 1; i < argc; ++i)
if (key == argv[i]) return true;
return false;
}
} // namespace
int main(int argc, char** argv) {
const auto port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000")));
// Defaults are relative to the current working directory so the
// binary works both inside the docker image (WORKDIR=/app) and on a
// host build run from the repo root. Pass explicit --config etc.
// when running from anywhere else.
const auto equipment_yaml = arg(argc, argv, "--config", "data/equipment.yaml");
const auto state_yaml = arg(argc, argv, "--state-table", "data/control_state.yaml");
const auto pj_state_yaml = arg(argc, argv, "--pj-state-table",
"data/process_job_state.yaml");
const auto cj_state_yaml = arg(argc, argv, "--cj-state-table",
"data/control_job_state.yaml");
const auto spool_dir = arg(argc, argv, "--spool-dir", "");
const bool validate_only = has_flag(argc, argv, "--validate-config");
auto logfn = [](const std::string& m) { std::cout << "[equip] " << m << std::endl; };
// --validate-config: read every YAML, accumulate every issue we can
// find, print to stderr, and exit 0/1. Does NOT bind the port — this
// is the day-1 friction killer the README points customers at.
if (validate_only) {
config::ConfigValidator v;
v.validate_equipment(equipment_yaml);
v.validate_control_state(state_yaml);
v.validate_process_job_state(pj_state_yaml);
v.validate_control_job_state(cj_state_yaml);
config::format_issues_to(std::cerr, v.issues());
std::cerr << v.error_count() << " error(s), "
<< v.warning_count() << " warning(s) across 4 files\n";
return v.has_errors() ? 1 : 0;
}
// The engine — io_context, passive Server, model, control-state machine,
// Router, emit/spool plumbing — now lives in EquipmentRuntime. Construct it
// from the YAML config; the handler-registration block below wires GEM
// behaviour onto it through the aliases that follow.
gem::EquipmentRuntime::Config rt_cfg;
rt_cfg.equipment_yaml = equipment_yaml;
rt_cfg.control_state_yaml = state_yaml;
rt_cfg.process_job_yaml = pj_state_yaml;
rt_cfg.control_job_yaml = cj_state_yaml;
rt_cfg.port = port;
rt_cfg.spool_dir = spool_dir;
rt_cfg.log = logfn;
std::unique_ptr<gem::EquipmentRuntime> runtime;
try {
runtime = std::make_unique<gem::EquipmentRuntime>(rt_cfg);
} catch (const std::exception& e) {
std::cerr << "[equip] config error: " << e.what() << std::endl;
return 1;
}
auto& R = *runtime;
logfn("loaded " + std::to_string(R.model().svids.size()) + " SVIDs, " +
std::to_string(R.model().ecids.all().size()) + " ECIDs, " +
std::to_string(R.model().events.all_events().size()) + " CEIDs, " +
std::to_string(R.model().alarms.all().size()) + " alarms");
gem::register_default_handlers(R);
// Accepting connections, the SELECTED spool-notify handler, and the
// S9-on-unhandled message dispatch are all wired by the runtime; run it.
R.run();
return 0;
}