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:
@@ -98,6 +98,8 @@ add_library(secsgem
|
|||||||
src/gem/module_state.cpp
|
src/gem/module_state.cpp
|
||||||
src/gem/e84_state.cpp
|
src/gem/e84_state.cpp
|
||||||
src/gem/host_handler.cpp
|
src/gem/host_handler.cpp
|
||||||
|
src/gem/runtime.cpp
|
||||||
|
src/gem/default_handlers.cpp
|
||||||
src/config/loader.cpp
|
src/config/loader.cpp
|
||||||
src/config/validate.cpp
|
src/config/validate.cpp
|
||||||
src/endpoint.cpp
|
src/endpoint.cpp
|
||||||
@@ -131,6 +133,49 @@ target_link_libraries(secs_bench PRIVATE secsgem)
|
|||||||
add_executable(pvd_tool examples/pvd_tool/main.cpp)
|
add_executable(pvd_tool examples/pvd_tool/main.cpp)
|
||||||
target_link_libraries(pvd_tool PRIVATE secsgem)
|
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)
|
if(SECSGEM_FUZZ)
|
||||||
# libFuzzer entry-point targets. Each owns its own `main` via the
|
# libFuzzer entry-point targets. Each owns its own `main` via the
|
||||||
# `-fsanitize=fuzzer` link flag; do NOT also link them into 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_control_state.cpp
|
||||||
tests/test_communication_state.cpp
|
tests/test_communication_state.cpp
|
||||||
tests/test_data_model.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_messages.cpp
|
||||||
tests/test_loader.cpp
|
tests/test_loader.cpp
|
||||||
tests/test_process_jobs.cpp
|
tests/test_process_jobs.cpp
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
git \
|
git \
|
||||||
libasio-dev \
|
libasio-dev \
|
||||||
libyaml-cpp-dev \
|
libyaml-cpp-dev \
|
||||||
|
libprotobuf-dev \
|
||||||
|
protobuf-compiler \
|
||||||
|
protobuf-compiler-grpc \
|
||||||
|
libgrpc++-dev \
|
||||||
python3 \
|
python3 \
|
||||||
python3-yaml \
|
python3-yaml \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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: S10F1–F6) — 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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user