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
+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