From cb85199f494407bcebac849bac204f5b2a05c998 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Wed, 10 Jun 2026 18:07:12 +0200 Subject: [PATCH] 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) --- CMakeLists.txt | 17 +++++ apps/equipment_service.hpp | 99 ++++++++++++++++++++++++++++++ apps/secs_gemd.cpp | 69 ++------------------- include/secsgem/gem/name_index.hpp | 8 +++ tests/test_daemon_service.cpp | 91 +++++++++++++++++++++++++++ tests/test_name_index.cpp | 10 +++ 6 files changed, 230 insertions(+), 64 deletions(-) create mode 100644 apps/equipment_service.hpp create mode 100644 tests/test_daemon_service.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f91ee1..594d1b3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/apps/equipment_service.hpp b/apps/equipment_service.hpp new file mode 100644 index 0000000..562561a --- /dev/null +++ b/apps/equipment_service.hpp @@ -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 + +#include +#include + +#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(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 diff --git a/apps/secs_gemd.cpp b/apps/secs_gemd.cpp index 3f4a21d..a3eab51 100644 --- a/apps/secs_gemd.cpp +++ b/apps/secs_gemd.cpp @@ -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 @@ -16,77 +16,18 @@ #include #include +#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(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); diff --git a/include/secsgem/gem/name_index.hpp b/include/secsgem/gem/name_index.hpp index 0f34122..f9eb6a4 100644 --- a/include/secsgem/gem/name_index.hpp +++ b/include/secsgem/gem/name_index.hpp @@ -24,4 +24,12 @@ inline std::optional resolve_variable(const EquipmentDataModel& m, return std::nullopt; } +// Resolve a collection-event name to its CEID. nullopt if unknown. +inline std::optional 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 diff --git a/tests/test_daemon_service.cpp b/tests/test_daemon_service.cpp new file mode 100644 index 0000000..280c760 --- /dev/null +++ b/tests/test_daemon_service.cpp @@ -0,0 +1,91 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include + +#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 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(); +} diff --git a/tests/test_name_index.cpp b/tests/test_name_index.cpp index b16daad..89f7907 100644 --- a/tests/test_name_index.cpp +++ b/tests/test_name_index.cpp @@ -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()); +}