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>
This commit is contained in:
2026-06-10 18:01:16 +02:00
parent 4b4b2ac690
commit fc898f8410
14 changed files with 2135 additions and 1183 deletions
+48
View File
@@ -98,6 +98,8 @@ add_library(secsgem
src/gem/module_state.cpp
src/gem/e84_state.cpp
src/gem/host_handler.cpp
src/gem/runtime.cpp
src/gem/default_handlers.cpp
src/config/loader.cpp
src/config/validate.cpp
src/endpoint.cpp
@@ -131,6 +133,49 @@ target_link_libraries(secs_bench PRIVATE secsgem)
add_executable(pvd_tool examples/pvd_tool/main.cpp)
target_link_libraries(pvd_tool PRIVATE secsgem)
# --- gRPC daemon: secs_gemd -----------------------------------------------
# Runs the engine on a background thread and exposes proto/secsgem/v1 over
# gRPC. Opt-in and gracefully skipped where protobuf/grpc++ aren't installed,
# so the core library + tests build without them.
option(SECSGEM_DAEMON "Build the secs_gemd gRPC daemon" ON)
if(SECSGEM_DAEMON)
find_package(Protobuf QUIET)
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(GRPCPP QUIET IMPORTED_TARGET grpc++)
endif()
find_program(GRPC_CPP_PLUGIN grpc_cpp_plugin)
if(Protobuf_FOUND AND GRPCPP_FOUND AND GRPC_CPP_PLUGIN)
set(PROTO_DIR ${CMAKE_SOURCE_DIR}/proto)
set(PROTO_FILE ${PROTO_DIR}/secsgem/v1/equipment.proto)
set(PROTO_OUT ${CMAKE_BINARY_DIR}/generated)
add_custom_command(
OUTPUT ${PROTO_OUT}/secsgem/v1/equipment.pb.cc
${PROTO_OUT}/secsgem/v1/equipment.pb.h
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.h
COMMAND ${CMAKE_COMMAND} -E make_directory ${PROTO_OUT}
COMMAND ${Protobuf_PROTOC_EXECUTABLE}
--proto_path=${PROTO_DIR}
--cpp_out=${PROTO_OUT}
--grpc_out=${PROTO_OUT}
--plugin=protoc-gen-grpc=${GRPC_CPP_PLUGIN}
${PROTO_FILE}
DEPENDS ${PROTO_FILE}
COMMENT "Generating gRPC C++ from equipment.proto"
VERBATIM)
add_executable(secs_gemd
apps/secs_gemd.cpp
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
target_link_libraries(secs_gemd PRIVATE secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES})
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
else()
message(STATUS "secs_gemd skipped (need protobuf + grpc++ + grpc_cpp_plugin)")
endif()
endif()
if(SECSGEM_FUZZ)
# libFuzzer entry-point targets. Each owns its own `main` via the
# `-fsanitize=fuzzer` link flag; do NOT also link them into the
@@ -170,6 +215,9 @@ add_executable(secsgem_tests
tests/test_control_state.cpp
tests/test_communication_state.cpp
tests/test_data_model.cpp
tests/test_runtime.cpp
tests/test_default_handlers.cpp
tests/test_name_index.cpp
tests/test_messages.cpp
tests/test_loader.cpp
tests/test_process_jobs.cpp
+4
View File
@@ -9,6 +9,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
libasio-dev \
libyaml-cpp-dev \
libprotobuf-dev \
protobuf-compiler \
protobuf-compiler-grpc \
libgrpc++-dev \
python3 \
python3-yaml \
ca-certificates \
+128
View File
@@ -0,0 +1,128 @@
// secs_gemd — the SECS/GEM daemon.
//
// Runs the GEM engine (EquipmentRuntime + the default handlers) on a background
// thread, owning the HSMS link to the host, and exposes a small name-based gRPC
// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language
// — can drive the equipment without linking C++ or knowing SEMI.
//
// This is increment 1: the universal "report state to the host" RPCs plus
// control-state visibility. Events, alarms, and the host->tool Subscribe stream
// follow (see docs/DAEMON_ROADMAP.md).
#include <grpcpp/grpcpp.h>
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>
#include "secsgem/gem/default_handlers.hpp"
#include "secsgem/gem/name_index.hpp"
#include "secsgem/gem/runtime.hpp"
#include "secsgem/secs2/item.hpp"
#include "secsgem/v1/equipment.grpc.pb.h"
#include "secsgem/v1/equipment.pb.h"
namespace gem = secsgem::gem;
namespace s2 = secsgem::secs2;
namespace pb = secsgem::v1;
namespace {
// proto Value -> SECS-II Item. Increment 1 uses a direct mapping; format-aware
// conversion (honouring the variable's declared wire format) is a refinement.
s2::Item to_item(const pb::Value& v) {
switch (v.kind_case()) {
case pb::Value::kText: return s2::Item::ascii(v.text());
case pb::Value::kInteger: return s2::Item::i8(static_cast<int64_t>(v.integer()));
case pb::Value::kReal: return s2::Item::f8(v.real());
case pb::Value::kBoolean: return s2::Item::boolean(v.boolean());
default: return s2::Item::ascii("");
}
}
pb::ControlState::State to_proto_state(gem::ControlState s) {
switch (s) {
case gem::ControlState::EquipmentOffline: return pb::ControlState::EQUIPMENT_OFFLINE;
case gem::ControlState::AttemptOnline: return pb::ControlState::ATTEMPT_ONLINE;
case gem::ControlState::HostOffline: return pb::ControlState::HOST_OFFLINE;
case gem::ControlState::OnlineLocal: return pb::ControlState::ONLINE_LOCAL;
case gem::ControlState::OnlineRemote: return pb::ControlState::ONLINE_REMOTE;
}
return pb::ControlState::EQUIPMENT_OFFLINE;
}
class EquipmentService final : public pb::Equipment::Service {
public:
explicit EquipmentService(gem::EquipmentRuntime& rt) : rt_(rt) {}
grpc::Status SetVariables(grpc::ServerContext*, const pb::VariableUpdate* req,
pb::Ack* resp) override {
for (const auto& kv : req->values()) {
auto vid = gem::resolve_variable(rt_.model(), kv.first);
if (!vid) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("no variable named '" + kv.first + "'");
return grpc::Status::OK;
}
rt_.set_variable(*vid, to_item(kv.second));
}
resp->set_code(pb::Ack::ACCEPT);
return grpc::Status::OK;
}
grpc::Status GetControlState(grpc::ServerContext*, const pb::Empty*,
pb::ControlState* resp) override {
resp->set_state(to_proto_state(rt_.control_state()));
return grpc::Status::OK;
}
private:
gem::EquipmentRuntime& rt_;
};
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;
}
} // namespace
int main(int argc, char** argv) {
const std::string hsms_port = arg(argc, argv, "--port", "5000");
const std::string grpc_addr = arg(argc, argv, "--grpc", "0.0.0.0:50051");
const std::string cfgdir = arg(argc, argv, "--config-dir", "data");
gem::EquipmentRuntime::Config cfg;
cfg.equipment_yaml = cfgdir + "/equipment.yaml";
cfg.control_state_yaml = cfgdir + "/control_state.yaml";
cfg.process_job_yaml = cfgdir + "/process_job_state.yaml";
cfg.control_job_yaml = cfgdir + "/control_job_state.yaml";
cfg.port = static_cast<uint16_t>(std::stoi(hsms_port));
cfg.log = [](const std::string& m) { std::cout << "[gemd] " << m << std::endl; };
std::unique_ptr<gem::EquipmentRuntime> rt;
try {
rt = std::make_unique<gem::EquipmentRuntime>(cfg);
} catch (const std::exception& e) {
std::cerr << "[gemd] config error: " << e.what() << std::endl;
return 1;
}
gem::register_default_handlers(*rt);
rt->run_async(); // engine + HSMS link on a background thread
EquipmentService service(*rt);
grpc::ServerBuilder builder;
builder.AddListeningPort(grpc_addr, grpc::InsecureServerCredentials());
builder.RegisterService(&service);
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
if (!server) {
std::cerr << "[gemd] failed to start gRPC server on " << grpc_addr << std::endl;
return 1;
}
std::cout << "[gemd] gRPC on " << grpc_addr
<< "; HSMS equipment on :" << hsms_port << std::endl;
server->Wait();
return 0;
}
+26 -1183
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
# Vendor Daemon & gRPC API — Status and Roadmap to Fab-Readiness
> **This is a forward-looking roadmap, not a description of shipped behaviour.**
> Every item carries a status marker. Do not read an item as "done" unless it
> says ✅. (Written 2026-06-10.)
>
> Status legend: ✅ done · 🚧 in progress · ⬜ planned · ⚠️ risk/unknown
## What this is
A vendor-facing **daemon** that runs the SECS/GEM engine as its own process and
exposes a small, name-based, language-agnostic API over gRPC, so a tool's
control software (in any language) can drive the equipment without linking C++
or knowing SEMI. See `proto/secsgem/v1/equipment.proto` for the API.
The point of the daemon model: it owns the durable HSMS relationship with the
host and stays conformant while the tool software restarts/upgrades/crashes.
## Current status (2026-06-10)
| Piece | Status | Notes |
|---|---|---|
| `proto/secsgem/v1/equipment.proto` | ✅ | v1 API surface designed (universal + carrier/recipe/job tiers) |
| `HostCommandRegistry::set_handler` behaviour hook | ✅ | the engine seam the daemon binds to; tested |
| `EquipmentRuntime` (engine owner) | ✅ | infra + outbound API built & tested (`tests/test_runtime.cpp`); **`secs_server` now runs entirely on it** (verified by the live server↔client GEM300 demo — full job/spool/control-state flow, client exit 0). |
| `register_default_handlers` in the library (so the daemon reuses the 56 handlers) | ✅ | relocated into `src/gem/default_handlers.cpp` (programmatic move, zero retype); `secs_server` reduced to ~113 lines and calls it. Tested (`tests/test_default_handlers.cpp`: S1F1→S1F2, S2F41→on_command hook) + live GEM300 demo still passes. |
| gRPC/protobuf in toolchain (Dockerfile + CMake) | 🚧 | apt deps added to Dockerfile (`libgrpc++-dev libprotobuf-dev protobuf-compiler-grpc`); **image rebuild + CMake proto codegen still TODO**. |
| `secs_gemd` daemon implementing the service | ⬜ | translate RPCs ↔ runtime; stream host requests |
| Reference client library (Python) | ⬜ | thin wrapper over generated stubs |
**Nothing in the proto is wired to the engine yet.** The engine itself is broad
(56 wire handlers across S1/2/3/5/6/7/10/14/16; all GEM300 stores) — the daemon
is about *exposing* that, not building it.
## Gaps to fab-readiness
### Layer 0 — Make it run at all (blocks everything)
- ⬜ Extract `EquipmentRuntime` from `apps/secs_server.cpp` (io_context, Server,
model, router, emit lambdas, `set_handler`). Reduce `secs_server` to a thin
`main()` over it. Verify against the existing test suite.
- ⬜ Add gRPC/protobuf to the Dockerfile + CMake codegen for the proto.
- ⬜ Implement `secs_gemd`: construct the runtime, `io.run()` on a background
thread, map each RPC to a runtime call, route host requests onto the
`Subscribe` stream via `set_handler` + the FSM change handlers.
- ⬜ One reference client (Python) proving the end-to-end loop.
### Layer 1 — API completeness (engine supports these; surface them)
-**Job/carrier in-the-loop semantics.** The proto has `ProcessJob`/
`CarrierAction` + report RPCs, but the exact contract is unspecified: who acks
the host's S16F5/S3Fxx, whether the tool *gates* a job start or only observes,
and timing vs. T3. **Design this before implementing the daemon for process tools.**
- ⬜ Trace data collection (engine: `TraceStore`, S2F23/S6F1).
- ⬜ Limits monitoring (engine: `LimitMonitorStore`, S2F45).
- ⬜ Substrate/E90 + module/E157 tracking (engine: `SubstrateStore`, `ModuleStore`).
- ⬜ Terminal services / operator messages (engine: S10F1F6) — host↔tool HMI text.
- ⬜ Spool depth + force-flush API (engine: `SpoolStore`).
-`Describe` RPC: enumerate configured variables/events/alarms/commands at
runtime (diagnostics & tooling).
### Layer 2 — Production hardening
-**gRPC auth / exposure.** No auth today. Bind to a Unix domain socket or
localhost-only, or add credentials. Never expose the API on the equipment LAN
unauthenticated.
-**`Subscribe` reconnect/replay semantics.** Define what happens to host
requests (commands, jobs) if the tool client disconnects and reconnects: are
they buffered/replayed, or dropped? Required for a 24/7 tool. (Correctness gap.)
- ⬜ Supervised deployment (systemd unit / container), auto-restart; rely on the
existing spool persistence so queued host events survive a daemon restart.
- ⬜ Expose the existing Prometheus metrics + structured logs from the daemon.
- ⬜ Decide multi-host (HSMS-GS) story — engine supports it; v1 assumes one
equipment/session. Probably fine; document the assumption.
### Layer 3 — Actual fab acceptance (the hard gate)
- ⚠️ **Standards correctness is unverified.** The SECS/GEM behaviour in this repo
was substantially reconstructed without access to the SEMI standard texts.
Interop tests (secsgem-py, secs4java8, Wireshark) mitigate but do not prove
conformance. Subtle wire/state-machine deviations could fail a real host. This
is the #1 fab-readiness risk and it is *verification*, not features.
- ⬜ Pass a specific fab's **MES qualification suite** against their real host
(see `docs/MES_INTEROP.md` for the punch-list). Fab acceptance is empirical
and per-fab.
- ⬜ Produce the GEM **compliance statement** (S1F19/F20) + written GEM manual
matching the tool's actual data dictionary.
- ⬜ Finish the **SECS-I serial driver** (FSM done; asio `serial_port` adapter
missing) — only if a target tool uses RS-232 rather than HSMS/TCP.
- ⬜ Per-tool `equipment.yaml` authored to match the tool's real SVIDs/CEIDs/
ECIDs/alarms/recipes and the fab's spec (vendor work; the config validator helps).
## Sequencing recommendation
Layer 0 in order (runtime → deps → daemon → client), then Layer 1's job/carrier
semantics, then Layer 2 hardening. Layer 3 runs in parallel and is gated by
access to real standards and a real host — treat it as the thing that decides
whether any of this is truly fab-ready.
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "secsgem/gem/runtime.hpp"
namespace secsgem::gem {
// Registers the full default GEM behaviour onto a runtime: every SECS message
// handler (S1/S2/S3/S5/S6/S7/S10/S14/S16) on its Router, plus the state-change
// emitters (control state, process/control jobs, exceptions, substrates,
// modules) on its stores. Shared by the `secs_server` app and the gRPC daemon
// so both speak byte-identical GEM — the protocol behaviour lives here, once.
//
// Call once after constructing the runtime and before run(). Application
// behaviour (host-command callbacks via on_command, sensor value updates) is
// layered on top by the caller.
void register_default_handlers(EquipmentRuntime& R);
} // namespace secsgem::gem
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include "secsgem/gem/data_model.hpp"
namespace secsgem::gem {
// Resolve a human variable name to its numeric VID, spanning SVIDs and DVIDs
// (one VID space). SVIDs win on a name collision. nullopt if unknown.
//
// This is the name->id half of the vendor-facing binding layer: the daemon and
// any language client address items by the names from equipment.yaml, and the
// engine stays numeric. Best-effort by design — duplicate names simply resolve
// to the first match rather than erroring (see secsgem-vendor-accessibility).
inline std::optional<uint32_t> resolve_variable(const EquipmentDataModel& m,
const std::string& name) {
for (const auto& sv : m.svids.all())
if (sv.name == name) return sv.id;
for (const auto& dv : m.dvids.all())
if (dv.name == name) return dv.id;
return std::nullopt;
}
} // namespace secsgem::gem
+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include <asio.hpp>
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <utility>
#include "secsgem/config/loader.hpp"
#include "secsgem/endpoint.hpp"
#include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp"
#include "secsgem/gem/router.hpp"
#include "secsgem/gem/store/host_commands.hpp"
#include "secsgem/secs2/item.hpp"
#include "secsgem/secs2/message.hpp"
namespace secsgem::gem {
// Owns the SECS/GEM engine — io_context, the passive HSMS Server, the data
// model, the E30 control-state machine, and the Router — as one reusable
// object. It is the foundation both the demo `secs_server` app and the gRPC
// daemon build on, replacing a hand-wired main() with a class.
//
// Threading contract (unchanged from the engine's existing design): a single
// io_context thread owns the model and the connection. The outbound methods
// (set_variable / emit_event / set_alarm / clear_alarm) are safe to call from
// any thread — each posts onto the io_context, exactly like the established
// emit_* pattern. Router handlers and the command hook run on the io thread.
class EquipmentRuntime {
public:
struct Config {
std::string equipment_yaml;
std::string control_state_yaml;
std::string process_job_yaml;
std::string control_job_yaml;
uint16_t port = 5000;
std::string spool_dir; // empty = no persistence
std::function<void(const std::string&)> log; // empty = silent
};
explicit EquipmentRuntime(const Config& cfg);
~EquipmentRuntime();
EquipmentRuntime(const EquipmentRuntime&) = delete;
EquipmentRuntime& operator=(const EquipmentRuntime&) = delete;
// ---- access --------------------------------------------------------------
EquipmentDataModel& model() { return *model_; }
const EquipmentDataModel& model() const { return *model_; }
Router& router() { return router_; }
ControlStateMachine& control() { return *sm_; }
asio::io_context& io() { return io_; }
uint16_t device_id() const { return descriptor_.device_id; }
void log(const std::string& m) const { if (log_) log_(m); }
// Shared-pointer / reference accessors used when wiring handlers and
// emitters that capture the engine pieces by value (see register handlers).
std::shared_ptr<EquipmentDataModel> model_ptr() { return model_; }
std::shared_ptr<ControlStateMachine> control_ptr() { return sm_; }
const config::EquipmentDescriptor& descriptor() const { return descriptor_; }
std::shared_ptr<std::weak_ptr<Connection>> active_conn() { return active_conn_; }
// ---- outbound: report state to the host (thread-safe; each posts) --------
void set_variable(uint32_t vid, secs2::Item value);
void emit_event(uint32_t ceid);
void set_alarm(uint32_t alid);
void clear_alarm(uint32_t alid);
// ---- host-command behaviour hook -----------------------------------------
void on_command(std::string rcmd, HostCommandRegistry::Handler h) {
model_->commands.set_handler(std::move(rcmd), std::move(h));
}
// ---- control state -------------------------------------------------------
ControlState control_state() const { return sm_->state(); }
// Deliver a primary to the host, or spool it if there's no SELECTED session.
// Call on the io thread (e.g. from a router handler or a posted emitter).
// Returns false if dropped (stream not spoolable and no host).
bool deliver_or_spool(secs2::Message msg, const std::string& what);
// ---- lifecycle -----------------------------------------------------------
void run(); // start accepting + run the io_context (blocks)
void run_async(); // run the io_context on a background thread
void poll(); // drain ready handlers without blocking (tests)
void stop();
private:
void wire_connection();
asio::io_context io_;
std::shared_ptr<EquipmentDataModel> model_;
std::function<void(const std::string&)> log_;
std::shared_ptr<std::weak_ptr<Connection>> active_conn_;
std::shared_ptr<ControlStateMachine> sm_;
config::EquipmentDescriptor descriptor_;
std::unique_ptr<Server> server_;
Router router_;
std::optional<asio::executor_work_guard<asio::io_context::executor_type>> work_;
std::thread io_thread_;
};
} // namespace secsgem::gem
+264
View File
@@ -0,0 +1,264 @@
syntax = "proto3";
package secsgem.v1;
// =============================================================================
// SECS/GEM Equipment API
//
// The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the
// host (MES) and speaks GEM on your behalf. Your tool software connects as a
// client and only ever does two things:
//
// • tell the equipment about itself — set variables, fire events, raise alarms
// • react to what the host asks for — receive commands/jobs, answer them
//
// Everything SECS lives inside the daemon: message framing, report definitions,
// the GEM state machines, timers, spooling. You need no SEMI knowledge to use
// this API. Items are addressed by the human names from your equipment config
// (e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID.
//
// CAPABILITY TIERS — wire up only what your equipment is:
// • Universal — variables, events, alarms, control state, commands. Every tool.
// • Carriers — E87 carrier/load-port flows. Only carrier-based equipment.
// • Recipes — S7 process-program transfer. Only recipe-driven equipment.
// • Jobs — E40 process jobs. Only job-based process/front-end equipment.
// If a tier doesn't apply, you simply never receive its HostRequest variants and
// never call its report RPCs.
// =============================================================================
service Equipment {
// ---- Universal: report state to the host --------------------------------
// Update one or more status/data variables by name. The daemon remembers the
// values, so the host always sees the latest when it polls (S1F3).
rpc SetVariables(VariableUpdate) returns (Ack);
// Read back what the daemon currently holds (useful on tool restart/reconnect).
rpc GetVariables(VariableQuery) returns (VariableSnapshot);
// Fire a collection event by name. The daemon assembles the configured report
// and sends S6F11. Values in `data` override current values for this one event.
rpc FireEvent(Event) returns (Ack);
// Raise (S5F1 set) or clear an alarm by name.
rpc SetAlarm(Alarm) returns (Ack);
rpc ClearAlarm(Alarm) returns (Ack);
// ---- Universal: control state -------------------------------------------
// Current GEM control state (ONLINE/LOCAL/REMOTE/OFFLINE).
rpc GetControlState(Empty) returns (ControlState);
// Request a transition — e.g. an operator panel taking the tool OFFLINE for
// maintenance, or back ONLINE. The daemon applies E30 rules and may decline.
rpc RequestControlState(ControlStateRequest) returns (Ack);
// ---- Universal: react to the host ---------------------------------------
// Subscribe to everything the host asks of this equipment. The daemon streams
// HostRequest messages for as long as the call stays open.
rpc Subscribe(SubscribeRequest) returns (stream HostRequest);
// Answer a remote Command delivered on the stream, quoting its `id`. Until you
// call this (or the reply window elapses) the host's transaction stays open.
rpc CompleteCommand(CommandResult) returns (Ack);
// ---- Jobs / Carriers: report progress of work the host asked for --------
// Keyed by the durable id (job_id / carrier_id), not a per-message id — these
// are long-lived objects you report against as the physical work proceeds.
rpc ReportProcessJob(ProcessJobState) returns (Ack); // E40 — job-based tools
rpc ReportCarrier(CarrierState) returns (Ack); // E87 — carrier-based tools
// ---- Diagnostics --------------------------------------------------------
// Live daemon/link status: distinguishes "host went offline" from "cable
// unplugged" from "spool filling up". Streams a snapshot on every change.
rpc WatchHealth(Empty) returns (stream Health);
}
// ---- Values ----------------------------------------------------------------
// A SECS-II value in plain terms. The daemon converts it to the wire format
// declared for the target item in your config — you never choose U4/F4/ASCII.
message Value {
oneof kind {
string text = 1;
sint64 integer = 2;
double real = 3;
bool boolean = 4;
bytes binary = 5;
List list = 6; // SECS-II nested list
}
}
message List { repeated Value items = 1; }
message Empty {}
// ---- Universal: tool -> equipment ------------------------------------------
message VariableUpdate {
map<string, Value> values = 1; // name -> value
}
message VariableQuery {
repeated string names = 1; // empty = all configured variables
}
message VariableSnapshot {
map<string, Value> values = 1;
}
message Event {
string name = 1; // collection-event name
map<string, Value> data = 2; // optional per-fire variable values
}
message Alarm {
string name = 1; // alarm name
}
message SubscribeRequest {
string client = 1; // optional label for logging/diagnostics
}
// ---- Control state ---------------------------------------------------------
message ControlState {
State state = 1;
enum State {
EQUIPMENT_OFFLINE = 0;
ATTEMPT_ONLINE = 1;
HOST_OFFLINE = 2;
ONLINE_LOCAL = 3;
ONLINE_REMOTE = 4;
}
}
message ControlStateRequest {
ControlState.State desired = 1;
}
// ---- Host -> tool stream ---------------------------------------------------
// Everything the host initiates arrives here. Match on the populated variant;
// ignore the variants for tiers your equipment doesn't implement.
message HostRequest {
oneof request {
Command command = 1; // S2F41/F21/F49 — universal
ControlStateChange control_state = 2; // control state transitioned — universal
ConstantChange constant = 3; // host set an equipment constant — universal
ProcessProgram process_program = 4; // S7 recipe downloaded — recipe tools
CarrierAction carrier = 5; // E87 carrier at a port — carrier tools
ProcessJob process_job = 6; // E40 job to run/stop — job tools
}
}
// A remote command (S2F41 / S2F21 / S2F49). Answer with CompleteCommand.
message Command {
string id = 1; // correlation id — echo in CommandResult
string name = 2; // RCMD, e.g. "START"
map<string, Value> params = 3; // CPNAME -> CPVAL
}
// Control state changed (host went online/offline, operator toggled local/remote).
message ControlStateChange {
ControlState.State state = 1;
}
// The host wrote an equipment constant (S2F15). React if it tunes a process
// parameter you care about; the daemon already stored the new value.
message ConstantChange {
string name = 1;
Value value = 2;
}
// The host downloaded a recipe (S7F3). Load it into your process engine.
message ProcessProgram {
string ppid = 1; // recipe id
bytes body = 2; // recipe contents (opaque to the daemon)
}
// E87: a carrier needs attention at a load port. Reply with ReportCarrier.
message CarrierAction {
string carrier_id = 1; // CARRIERID
uint32 port = 2; // load-port number
Action action = 3;
enum Action {
VERIFY_ID = 0; // read & confirm the carrier's identity
PROCEED = 1; // begin access (open / map slots)
CANCEL = 2;
}
}
// E40: the host wants this process job run (or stopped). Report progress with
// ReportProcessJob. `recipe` + `carriers` tell you what to run and on what.
message ProcessJob {
string job_id = 1; // PRJobID
string recipe = 2; // PPID to run
Action action = 3;
repeated string carriers = 4; // material: carrier ids bound to this job
enum Action { START = 0; STOP = 1; PAUSE = 2; RESUME = 3; ABORT = 4; }
}
// ---- Tool -> equipment: replies & progress reports -------------------------
message CommandResult {
string id = 1; // the Command.id you are answering
Ack ack = 2; // outcome (maps to HCACK on the wire)
}
// Advance an E40 process job as the physical work proceeds; the daemon drives
// the E40 FSM and emits the matching S6F11 / S16F9 to the host.
message ProcessJobState {
string job_id = 1;
State state = 2;
enum State {
SETTING_UP = 0;
PROCESSING = 1;
COMPLETE = 2;
ABORTED = 3;
}
}
// Report a carrier's identity, slot map, and state as the tool reads them.
message CarrierState {
string carrier_id = 1;
uint32 port = 2;
State state = 3;
repeated bool slots = 4; // slot map: true = wafer present
enum State {
WAITING = 0;
IN_ACCESS = 1;
COMPLETE = 2;
}
}
// ---- Diagnostics -----------------------------------------------------------
message Health {
LinkState link = 1;
uint32 spool_depth = 2; // queued messages waiting for the host
ControlState.State control_state = 3;
enum LinkState {
DISCONNECTED = 0; // no TCP
CONNECTED = 1; // TCP up, not yet SELECTED
SELECTED = 2; // HSMS selected — actively talking to the host
}
}
// ---- Acknowledgement -------------------------------------------------------
// Mirrors SEMI HCACK exactly. For non-command RPCs, only ACCEPT vs an error
// code matters; `message` carries human detail ("no variable named 'presure'").
message Ack {
Code code = 1;
string message = 2;
enum Code {
ACCEPT = 0; // HCACK 0
INVALID_COMMAND = 1; // HCACK 1
CANNOT_DO_NOW = 2; // HCACK 2
PARAMETER_INVALID = 3; // HCACK 3
ACCEPTED_WILL_FINISH_LATER = 4; // HCACK 4
REJECTED = 5; // HCACK 5
INVALID_OBJECT = 6; // HCACK 6
}
}
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
#include "secsgem/gem/runtime.hpp"
#include "secsgem/gem/messages.hpp"
namespace secsgem::gem {
EquipmentRuntime::EquipmentRuntime(const Config& cfg)
: model_(std::make_shared<EquipmentDataModel>()),
log_(cfg.log),
active_conn_(std::make_shared<std::weak_ptr<Connection>>()) {
if (!cfg.spool_dir.empty()) {
model_->spool.enable_persistence(cfg.spool_dir);
log("spool: persisting to " + cfg.spool_dir + " (replayed " +
std::to_string(model_->spool.size()) + " messages)");
}
descriptor_ = config::load_equipment(cfg.equipment_yaml, *model_);
auto sm_cfg = config::load_control_state(cfg.control_state_yaml);
sm_ = std::make_shared<ControlStateMachine>(sm_cfg.table, sm_cfg.initial);
auto pj = config::load_process_job_state(cfg.process_job_yaml);
auto cj = config::load_control_job_state(cfg.control_job_yaml);
model_->process_jobs.set_table_factory([t = pj.table]() { return t; });
model_->control_jobs.set_table_factory([t = cj.table]() { return t; });
server_ = std::make_unique<Server>(
io_, Server::Config{cfg.port, descriptor_.device_id, {}});
server_->on_log([this](const std::string& m) { log(m); });
wire_connection();
}
EquipmentRuntime::~EquipmentRuntime() { stop(); }
bool EquipmentRuntime::deliver_or_spool(secs2::Message msg,
const std::string& what) {
auto conn = active_conn_->lock();
const bool spooling = model_->spool.force_spool() || !conn;
if (spooling) {
auto r = model_->spool.enqueue(msg);
if (r == SpoolStore::EnqueueResult::Queued) {
log("spool: " + what + " queued (depth=" +
std::to_string(model_->spool.size()) + ")");
return true;
}
log("spool: " + what + " dropped (stream not spoolable, no host)");
return false;
}
if (msg.reply_expected) {
conn->send_request(std::move(msg), [](std::error_code, const secs2::Message&) {});
} else {
conn->send_data(std::move(msg));
}
return true;
}
void EquipmentRuntime::set_variable(uint32_t vid, secs2::Item value) {
asio::post(io_, [this, vid, value = std::move(value)]() mutable {
if (model_->svids.has(vid)) model_->svids.set_value(vid, std::move(value));
else if (model_->dvids.has(vid)) model_->dvids.set_value(vid, std::move(value));
});
}
void EquipmentRuntime::emit_event(uint32_t ceid) {
asio::post(io_, [this, ceid]() {
if (!model_->is_event_enabled(ceid)) {
log("CEID " + std::to_string(ceid) + " not enabled; suppressed");
return;
}
auto reports = model_->compose_reports_for(ceid);
log("emit S6F11 CEID=" + std::to_string(ceid) + " (" +
std::to_string(reports.size()) + " reports)");
deliver_or_spool(s6f11_event_report(0, ceid, reports),
"S6F11 CEID=" + std::to_string(ceid));
});
}
void EquipmentRuntime::set_alarm(uint32_t alid) {
asio::post(io_, [this, alid]() {
auto alarm = model_->alarms.get(alid);
if (!alarm) return;
auto alcd = model_->alarms.set_active(alid);
if (!alcd) return;
if (model_->alarms.enabled(alid)) {
log("emit S5F1 alarm set ALID=" + std::to_string(alid));
deliver_or_spool(s5f1_alarm_report(*alcd, alid, alarm->text),
"S5F1 ALID=" + std::to_string(alid));
} else {
log("alarm " + std::to_string(alid) + " not enabled; suppressed");
}
});
}
void EquipmentRuntime::clear_alarm(uint32_t alid) {
asio::post(io_, [this, alid]() {
auto alarm = model_->alarms.get(alid);
if (!alarm) return;
auto alcd = model_->alarms.clear_active(alid);
if (!alcd) return;
if (model_->alarms.enabled(alid)) {
log("emit S5F1 alarm clear ALID=" + std::to_string(alid));
deliver_or_spool(s5f1_alarm_report(*alcd, alid, alarm->text),
"S5F1 clear ALID=" + std::to_string(alid));
}
});
}
void EquipmentRuntime::wire_connection() {
server_->on_connection([this](std::shared_ptr<Connection> conn) {
*active_conn_ = conn;
conn->set_closed_handler([this](const std::string&) { active_conn_->reset(); });
conn->set_selected_handler([this]() {
log(std::string("host is online; control=") + control_state_name(sm_->state()));
if (model_->spool.size() == 0) return;
asio::post(io_, [this]() {
auto c = active_conn_->lock();
if (!c) return;
const uint32_t n = static_cast<uint32_t>(model_->spool.size());
log("spool: notifying host of " + std::to_string(n) + " queued messages");
c->send_request(s6f25_spool_data_ready(n),
[](std::error_code, const secs2::Message&) {});
});
});
std::weak_ptr<Connection> wconn = conn;
conn->set_message_handler([this, wconn](const secs2::Message& msg)
-> std::optional<secs2::Message> {
if (!router_.has_handler(msg.stream, msg.function)) {
auto c = wconn.lock();
if (c && c->current_header()) {
const uint8_t s9_function =
router_.has_handler_for_stream(msg.stream) ? 5 : 3;
log("unhandled S" + std::to_string(msg.stream) + "F" +
std::to_string(msg.function) + "; emitting S9F" +
std::to_string(s9_function));
c->emit_s9(s9_function, c->current_header()->encode());
}
}
return router_.dispatch(msg);
});
});
}
void EquipmentRuntime::run() {
server_->start();
io_.run();
}
void EquipmentRuntime::run_async() {
work_.emplace(asio::make_work_guard(io_));
server_->start();
io_thread_ = std::thread([this]() { io_.run(); });
}
void EquipmentRuntime::poll() {
// restart() clears the "stopped" state a prior drain leaves behind, so
// repeated poll() calls each run their freshly-posted handlers.
io_.restart();
io_.poll();
}
void EquipmentRuntime::stop() {
if (work_) {
work_->reset();
work_.reset();
}
io_.stop();
if (io_thread_.joinable()) io_thread_.join();
}
} // namespace secsgem::gem
+73
View File
@@ -0,0 +1,73 @@
#include <doctest/doctest.h>
#include <vector>
#include "secsgem/gem/default_handlers.hpp"
#include "secsgem/gem/messages.hpp"
#include "secsgem/gem/runtime.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
static gem::EquipmentRuntime::Config test_config() {
gem::EquipmentRuntime::Config c;
c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml";
c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml";
c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml";
c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml";
c.port = 0;
return c;
}
TEST_CASE("register_default_handlers populates the runtime's Router") {
gem::EquipmentRuntime rt(test_config());
CHECK(rt.router().size() == 0); // nothing registered yet
gem::register_default_handlers(rt);
CHECK(rt.router().size() > 40); // the full GEM handler set (~56)
}
TEST_CASE("register_default_handlers: S1F1 dispatches to S1F2 On-Line Data") {
gem::EquipmentRuntime rt(test_config());
gem::register_default_handlers(rt);
auto reply = rt.router().dispatch(s2::Message(1, 1, true)); // Are You There
REQUIRE(reply.has_value());
CHECK(reply->stream == 1);
CHECK(reply->function == 2);
}
TEST_CASE("register_default_handlers: S2F41 reaches the on_command behaviour hook") {
gem::EquipmentRuntime rt(test_config());
gem::register_default_handlers(rt);
bool ran = false;
std::string seen;
rt.on_command("START", [&](const std::string& rcmd,
const std::vector<gem::CommandParameter>&) {
ran = true;
seen = rcmd;
return gem::HostCmdAck::Accept;
});
auto reply = rt.router().dispatch(gem::s2f41_host_command("START", {}));
REQUIRE(reply.has_value());
CHECK(reply->stream == 2);
CHECK(reply->function == 42); // S2F42 Host Command Ack
CHECK(ran); // the handler ran inside the S2F41 dispatch
CHECK(seen == "START");
}
TEST_CASE("register_default_handlers: unknown command is rejected, hook not invoked") {
gem::EquipmentRuntime rt(test_config());
gem::register_default_handlers(rt);
auto reply = rt.router().dispatch(gem::s2f41_host_command("NO_SUCH_CMD", {}));
REQUIRE(reply.has_value());
CHECK(reply->stream == 2);
CHECK(reply->function == 42); // still an S2F42, carrying an error HCACK
}
+23
View File
@@ -0,0 +1,23 @@
#include <doctest/doctest.h>
#include "secsgem/config/loader.hpp"
#include "secsgem/gem/name_index.hpp"
using namespace secsgem;
namespace gem = secsgem::gem;
#ifndef SECSGEM_DATA_DIR
#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt"
#endif
TEST_CASE("resolve_variable maps config names to VIDs across SVIDs and DVIDs") {
gem::EquipmentDataModel m;
config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
CHECK(gem::resolve_variable(m, "ControlState") == 1); // SVID
CHECK(gem::resolve_variable(m, "Clock") == 2); // SVID
CHECK(gem::resolve_variable(m, "WaferCounter") == 100); // DVID
CHECK(gem::resolve_variable(m, "ChamberPressure") == 101);// DVID
CHECK_FALSE(gem::resolve_variable(m, "nonexistent").has_value());
CHECK_FALSE(gem::resolve_variable(m, "").has_value());
}
+66
View File
@@ -0,0 +1,66 @@
#include <doctest/doctest.h>
#include <vector>
#include "secsgem/gem/runtime.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
// Port 0 binds an OS-chosen ephemeral port. These tests never call run()/
// run_async(), so the acceptor is opened but never armed — we exercise the
// outbound API by posting and draining with poll(), no host involved.
static gem::EquipmentRuntime::Config test_config() {
gem::EquipmentRuntime::Config c;
c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml";
c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml";
c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml";
c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml";
c.port = 0;
return c;
}
TEST_CASE("EquipmentRuntime loads the data dictionary from config") {
gem::EquipmentRuntime rt(test_config());
CHECK(rt.model().svids.all().size() == 3);
CHECK(rt.model().alarms.all().size() == 2);
CHECK(rt.control_state() == gem::ControlState::HostOffline);
}
TEST_CASE("EquipmentRuntime.set_variable posts onto the io thread and updates the model") {
gem::EquipmentRuntime rt(test_config());
rt.set_variable(1, s2::Item::ascii("OnlineRemote"));
CHECK(rt.model().svids.value(1) != s2::Item::ascii("OnlineRemote")); // not yet — posted
rt.poll();
CHECK(rt.model().svids.value(1) == s2::Item::ascii("OnlineRemote"));
}
TEST_CASE("EquipmentRuntime.set_alarm / clear_alarm toggle the active flag") {
gem::EquipmentRuntime rt(test_config());
rt.set_alarm(1);
rt.poll();
CHECK(rt.model().alarms.active(1));
rt.clear_alarm(1);
rt.poll();
CHECK_FALSE(rt.model().alarms.active(1));
}
TEST_CASE("EquipmentRuntime.on_command registers the behaviour hook on the model") {
gem::EquipmentRuntime rt(test_config());
bool ran = false;
rt.on_command("START", [&](const std::string& rcmd,
const std::vector<gem::CommandParameter>&) {
ran = (rcmd == "START");
return gem::HostCmdAck::Accept;
});
CHECK(rt.model().commands.has_handler("START"));
auto res = rt.model().commands.dispatch("START", {});
CHECK(ran);
CHECK(res.ack == gem::HostCmdAck::Accept);
}