feat(daemon): FireEvent + event name resolution + in-process gRPC tests

- name_index: add resolve_event(name) -> CEID (unit-tested).
- equipment_service.hpp: extract the gRPC service + value/state conversion
  into a shared header; add FireEvent (optional per-fire variable values,
  then trigger the collection event by name). secs_gemd slims to main().
- test_daemon_service: real in-process gRPC integration test (client stub ->
  service -> EquipmentRuntime) proving SetVariables lands in the model,
  GetControlState reports the state, FireEvent and unknown-name paths behave.
  Separate secs_gemd_tests target (links grpc++/proto), gated on the daemon.

Core suite 459/459 (2799 assertions); daemon gRPC tests 15/15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:07:12 +02:00
parent fc898f8410
commit cb85199f49
6 changed files with 230 additions and 64 deletions
+17
View File
@@ -170,6 +170,7 @@ if(SECSGEM_DAEMON)
${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})
set(SECSGEM_DAEMON_BUILT TRUE)
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
else()
message(STATUS "secs_gemd skipped (need protobuf + grpc++ + grpc_cpp_plugin)")
@@ -258,3 +259,19 @@ target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
target_compile_definitions(secsgem_tests PRIVATE
SECSGEM_DATA_DIR="${CMAKE_SOURCE_DIR}/data")
add_test(NAME secsgem_tests COMMAND secsgem_tests)
# Daemon gRPC integration tests (separate binary: needs grpc++ + the generated
# proto, which the core test binary doesn't link). Built only when the daemon is.
if(SECSGEM_DAEMON_BUILT)
add_executable(secs_gemd_tests
tests/test_daemon_service.cpp
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
target_include_directories(secs_gemd_tests PRIVATE
${PROTO_OUT} ${Protobuf_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/apps)
target_link_libraries(secs_gemd_tests PRIVATE
secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES} doctest::doctest)
target_compile_definitions(secs_gemd_tests PRIVATE
SECSGEM_DATA_DIR="${CMAKE_SOURCE_DIR}/data")
add_test(NAME secs_gemd_tests COMMAND secs_gemd_tests)
endif()
+99
View File
@@ -0,0 +1,99 @@
#pragma once
// The gRPC Equipment service: translates proto/secsgem/v1 RPCs onto an
// EquipmentRuntime. Header-only so both secs_gemd and the daemon tests share
// one definition.
#include <grpcpp/grpcpp.h>
#include <cstdint>
#include <string>
#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 secsgem::daemon {
namespace gem = secsgem::gem;
namespace s2 = secsgem::secs2;
namespace pb = secsgem::v1;
// proto Value -> SECS-II Item. Increment 1 uses a direct mapping; format-aware
// conversion (honouring the variable's declared wire format) is a refinement.
inline 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("");
}
}
inline 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 FireEvent(grpc::ServerContext*, const pb::Event* req,
pb::Ack* resp) override {
// Optional per-fire variable values, then trigger the collection event.
for (const auto& kv : req->data()) {
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));
}
auto ceid = gem::resolve_event(rt_.model(), req->name());
if (!ceid) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("no event named '" + req->name() + "'");
return grpc::Status::OK;
}
rt_.emit_event(*ceid);
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_;
};
} // namespace secsgem::daemon
+5 -64
View File
@@ -5,9 +5,9 @@
// 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).
// Increment 1: the universal "report state to the host" RPCs (SetVariables,
// FireEvent) plus control-state visibility. Alarms, GetVariables, and the
// host->tool Subscribe command stream follow (see docs/DAEMON_ROADMAP.md).
#include <grpcpp/grpcpp.h>
@@ -16,77 +16,18 @@
#include <memory>
#include <string>
#include "equipment_service.hpp"
#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) {
@@ -112,7 +53,7 @@ int main(int argc, char** argv) {
gem::register_default_handlers(*rt);
rt->run_async(); // engine + HSMS link on a background thread
EquipmentService service(*rt);
secsgem::daemon::EquipmentService service(*rt);
grpc::ServerBuilder builder;
builder.AddListeningPort(grpc_addr, grpc::InsecureServerCredentials());
builder.RegisterService(&service);
+8
View File
@@ -24,4 +24,12 @@ inline std::optional<uint32_t> resolve_variable(const EquipmentDataModel& m,
return std::nullopt;
}
// Resolve a collection-event name to its CEID. nullopt if unknown.
inline std::optional<uint32_t> resolve_event(const EquipmentDataModel& m,
const std::string& name) {
for (const auto& e : m.events.all_events())
if (e.name == name) return e.id;
return std::nullopt;
}
} // namespace secsgem::gem
+91
View File
@@ -0,0 +1,91 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <grpcpp/grpcpp.h>
#include "equipment_service.hpp"
using namespace secsgem;
namespace gem = secsgem::gem;
namespace s2 = secsgem::secs2;
namespace pb = secsgem::v1;
namespace dmn = secsgem::daemon;
#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; // ephemeral; the engine isn't run() here, only poll()ed
return c;
}
// Exercises the real gRPC service over an in-process channel: client stub ->
// service -> EquipmentRuntime, proving the RPCs move data, not just compile.
TEST_CASE("Equipment gRPC service over an in-process channel") {
gem::EquipmentRuntime rt(test_config());
dmn::EquipmentService svc(rt);
grpc::ServerBuilder builder;
builder.RegisterService(&svc);
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
REQUIRE(server);
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
SUBCASE("GetControlState returns the initial control state") {
grpc::ClientContext ctx;
pb::Empty req;
pb::ControlState resp;
auto st = stub->GetControlState(&ctx, req, &resp);
CHECK(st.ok());
CHECK(resp.state() == pb::ControlState::HOST_OFFLINE);
}
SUBCASE("SetVariables accepts a known name and updates the model") {
grpc::ClientContext ctx;
pb::VariableUpdate req;
pb::Ack resp;
(*req.mutable_values())["ChamberPressure"].set_real(2.5);
auto st = stub->SetVariables(&ctx, req, &resp);
CHECK(st.ok());
CHECK(resp.code() == pb::Ack::ACCEPT);
rt.poll(); // drain the posted set_variable onto this thread
CHECK(rt.model().dvids.value(101) == s2::Item::f8(2.5));
}
SUBCASE("SetVariables rejects an unknown variable name") {
grpc::ClientContext ctx;
pb::VariableUpdate req;
pb::Ack resp;
(*req.mutable_values())["definitely_not_a_var"].set_real(1.0);
auto st = stub->SetVariables(&ctx, req, &resp);
CHECK(st.ok());
CHECK(resp.code() == pb::Ack::PARAMETER_INVALID);
}
SUBCASE("FireEvent accepts a known event and rejects an unknown one") {
{
grpc::ClientContext ctx;
pb::Event req;
pb::Ack resp;
req.set_name("ProcessStarted");
CHECK(stub->FireEvent(&ctx, req, &resp).ok());
CHECK(resp.code() == pb::Ack::ACCEPT);
}
{
grpc::ClientContext ctx;
pb::Event req;
pb::Ack resp;
req.set_name("NoSuchEvent");
CHECK(stub->FireEvent(&ctx, req, &resp).ok());
CHECK(resp.code() == pb::Ack::PARAMETER_INVALID);
}
}
server->Shutdown();
}
+10
View File
@@ -21,3 +21,13 @@ TEST_CASE("resolve_variable maps config names to VIDs across SVIDs and DVIDs") {
CHECK_FALSE(gem::resolve_variable(m, "nonexistent").has_value());
CHECK_FALSE(gem::resolve_variable(m, "").has_value());
}
TEST_CASE("resolve_event maps collection-event names to CEIDs") {
gem::EquipmentDataModel m;
config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
CHECK(gem::resolve_event(m, "ControlStateChanged") == 100);
CHECK(gem::resolve_event(m, "ProcessStarted") == 300);
CHECK(gem::resolve_event(m, "ControlJobCompleted") == 401);
CHECK_FALSE(gem::resolve_event(m, "nonexistent").has_value());
}