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:
@@ -170,6 +170,7 @@ if(SECSGEM_DAEMON)
|
|||||||
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
||||||
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
|
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
|
||||||
target_link_libraries(secs_gemd PRIVATE secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES})
|
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})")
|
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
|
||||||
else()
|
else()
|
||||||
message(STATUS "secs_gemd skipped (need protobuf + grpc++ + grpc_cpp_plugin)")
|
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
|
target_compile_definitions(secsgem_tests PRIVATE
|
||||||
SECSGEM_DATA_DIR="${CMAKE_SOURCE_DIR}/data")
|
SECSGEM_DATA_DIR="${CMAKE_SOURCE_DIR}/data")
|
||||||
add_test(NAME secsgem_tests COMMAND secsgem_tests)
|
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()
|
||||||
|
|||||||
@@ -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
@@ -5,9 +5,9 @@
|
|||||||
// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language
|
// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language
|
||||||
// — can drive the equipment without linking C++ or knowing SEMI.
|
// — can drive the equipment without linking C++ or knowing SEMI.
|
||||||
//
|
//
|
||||||
// This is increment 1: the universal "report state to the host" RPCs plus
|
// Increment 1: the universal "report state to the host" RPCs (SetVariables,
|
||||||
// control-state visibility. Events, alarms, and the host->tool Subscribe stream
|
// FireEvent) plus control-state visibility. Alarms, GetVariables, and the
|
||||||
// follow (see docs/DAEMON_ROADMAP.md).
|
// host->tool Subscribe command stream follow (see docs/DAEMON_ROADMAP.md).
|
||||||
|
|
||||||
#include <grpcpp/grpcpp.h>
|
#include <grpcpp/grpcpp.h>
|
||||||
|
|
||||||
@@ -16,77 +16,18 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "equipment_service.hpp"
|
||||||
#include "secsgem/gem/default_handlers.hpp"
|
#include "secsgem/gem/default_handlers.hpp"
|
||||||
#include "secsgem/gem/name_index.hpp"
|
|
||||||
#include "secsgem/gem/runtime.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 gem = secsgem::gem;
|
||||||
namespace s2 = secsgem::secs2;
|
|
||||||
namespace pb = secsgem::v1;
|
|
||||||
|
|
||||||
namespace {
|
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) {
|
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
|
||||||
for (int i = 1; i + 1 < argc; ++i)
|
for (int i = 1; i + 1 < argc; ++i)
|
||||||
if (key == argv[i]) return argv[i + 1];
|
if (key == argv[i]) return argv[i + 1];
|
||||||
return def;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
@@ -112,7 +53,7 @@ int main(int argc, char** argv) {
|
|||||||
gem::register_default_handlers(*rt);
|
gem::register_default_handlers(*rt);
|
||||||
rt->run_async(); // engine + HSMS link on a background thread
|
rt->run_async(); // engine + HSMS link on a background thread
|
||||||
|
|
||||||
EquipmentService service(*rt);
|
secsgem::daemon::EquipmentService service(*rt);
|
||||||
grpc::ServerBuilder builder;
|
grpc::ServerBuilder builder;
|
||||||
builder.AddListeningPort(grpc_addr, grpc::InsecureServerCredentials());
|
builder.AddListeningPort(grpc_addr, grpc::InsecureServerCredentials());
|
||||||
builder.RegisterService(&service);
|
builder.RegisterService(&service);
|
||||||
|
|||||||
@@ -24,4 +24,12 @@ inline std::optional<uint32_t> resolve_variable(const EquipmentDataModel& m,
|
|||||||
return std::nullopt;
|
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
|
} // namespace secsgem::gem
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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, "nonexistent").has_value());
|
||||||
CHECK_FALSE(gem::resolve_variable(m, "").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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user