Files
secs-gem/apps/equipment_service.hpp
raphael 42044e92e2
tests / build-and-test (push) Successful in 2m42s
tests / thread-sanitizer (push) Successful in 2m50s
tests / tshark-dissector (push) Successful in 2m24s
tests / secs4j-interop (push) Successful in 37s
tests / python-interop (push) Successful in 2m56s
tests / libfuzzer (push) Successful in 3m44s
ci(interop): one-command external-validation suite + CI lanes for the daemon
tools/run_interop.sh runs ALL nine validation steps with a PASS/FAIL summary:
build, unit (464), daemon-unit (41), secsgem-py host vs server (31 checks),
secs_conformance (47), gRPC+secsgem-py daemon bridge, spool persistence
across restart, tshark HSMS dissector, secs4java8 (55 checks). Verified green
end-to-end. The unit suite is partly self-referential (our parsers validate
our builders); these external validators are the real oracle — now they run
with one command instead of by hand. Two bugs found by running it: unbounded
ninja at -O3 OOM-kills cc1plus in memory-constrained Docker VMs (build with
-j 2) and bash-3.2 lacks negative array subscripts.

CI: grpc deps added to the build job so secs_gemd + secs_gemd_tests build and
RUN in CI (previously the daemon silently dropped out — now fails loudly if
missing), plus a python-interop lane running py-host/conformance/daemon
harnesses against localhost in one container (no docker-in-docker).

Service hardening while in there: reject proto Values with no kind set at
the RPC edge (previously silently became ASCII ""), TODO markers for list
element formats and daemon graceful shutdown. New tests: unset-Value guard
+ a property test iterating ALL configured variables via gRPC asserting each
keeps its declared SECS-II format (daemon tests 16 -> 41 assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:08:37 +02:00

172 lines
6.7 KiB
C++

#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.
//
// Threading: gRPC handlers run on gRPC's own threads while the engine's
// io thread owns the data model, so handlers must not read the live stores.
// The constructor therefore snapshots the name->id/format maps (immutable
// after config load) — construct the service BEFORE run_async(). All writes
// go through the runtime's posting API.
#include <grpcpp/grpcpp.h>
#include <cstdint>
#include <map>
#include <string>
#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, honouring the variable's declared wire format
// (from equipment.yaml) so the host sees the same format in S1F11 namelists
// and in reported values. E.g. real 2.5 -> F4 if the variable is F4.
inline s2::Item to_item(const pb::Value& v, s2::Format want) {
using F = s2::Format;
switch (v.kind_case()) {
case pb::Value::kText:
return s2::Item::ascii(v.text());
case pb::Value::kBoolean:
return s2::Item::boolean(v.boolean());
case pb::Value::kBinary:
return s2::Item::binary({v.binary().begin(), v.binary().end()});
case pb::Value::kReal:
return want == F::F4 ? s2::Item::f4(static_cast<float>(v.real()))
: s2::Item::f8(v.real());
case pb::Value::kInteger: {
const int64_t n = v.integer();
switch (want) {
case F::U1: return s2::Item::u1(static_cast<uint8_t>(n));
case F::U2: return s2::Item::u2(static_cast<uint16_t>(n));
case F::U4: return s2::Item::u4(static_cast<uint32_t>(n));
case F::U8: return s2::Item::u8(static_cast<uint64_t>(n));
case F::I1: return s2::Item::i1(static_cast<int8_t>(n));
case F::I2: return s2::Item::i2(static_cast<int16_t>(n));
case F::I4: return s2::Item::i4(static_cast<int32_t>(n));
case F::F4: return s2::Item::f4(static_cast<float>(n));
case F::F8: return s2::Item::f8(static_cast<double>(n));
case F::Boolean: return s2::Item::boolean(n != 0);
default: return s2::Item::i8(n);
}
}
case pb::Value::kList: {
// TODO(daemon): list elements inherit the variable's scalar format;
// honouring per-element formats needs the declared Item's nested shape.
s2::Item::List items;
for (const auto& e : v.list().items()) items.push_back(to_item(e, want));
return s2::Item::list(std::move(items));
}
default:
// Unreachable: callers reject KIND_NOT_SET before converting (see
// value_is_set). Kept as a safe fallback for future oneof additions.
return s2::Item::ascii("");
}
}
// Validate a client-supplied Value before conversion. An unset oneof would
// otherwise silently become ASCII "" — reject it at the API edge instead.
inline bool value_is_set(const pb::Value& v) {
return v.kind_case() != pb::Value::KIND_NOT_SET;
}
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:
// Snapshots the (immutable) name->id/format dictionaries. Construct before
// run_async() so the model is read while the io thread isn't running yet.
explicit EquipmentService(gem::EquipmentRuntime& rt) : rt_(rt) {
for (const auto& sv : rt.model().svids.all())
vars_.insert({sv.name, {sv.id, sv.value.format()}});
for (const auto& dv : rt.model().dvids.all())
vars_.insert({dv.name, {dv.id, dv.value.format()}});
for (const auto& ev : rt.model().events.all_events())
events_.insert({ev.name, ev.id});
}
grpc::Status SetVariables(grpc::ServerContext*, const pb::VariableUpdate* req,
pb::Ack* resp) override {
for (const auto& kv : req->values()) {
auto it = vars_.find(kv.first);
if (it == vars_.end()) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("no variable named '" + kv.first + "'");
return grpc::Status::OK;
}
if (!value_is_set(kv.second)) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("value for '" + kv.first + "' has no kind set");
return grpc::Status::OK;
}
rt_.set_variable(it->second.vid, to_item(kv.second, it->second.format));
}
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 it = vars_.find(kv.first);
if (it == vars_.end()) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("no variable named '" + kv.first + "'");
return grpc::Status::OK;
}
if (!value_is_set(kv.second)) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("value for '" + kv.first + "' has no kind set");
return grpc::Status::OK;
}
rt_.set_variable(it->second.vid, to_item(kv.second, it->second.format));
}
auto ev = events_.find(req->name());
if (ev == events_.end()) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("no event named '" + req->name() + "'");
return grpc::Status::OK;
}
rt_.emit_event(ev->second);
resp->set_code(pb::Ack::ACCEPT);
return grpc::Status::OK;
}
grpc::Status GetControlState(grpc::ServerContext*, const pb::Empty*,
pb::ControlState* resp) override {
// Thread-safe: control_state() reads the runtime's atomic mirror.
resp->set_state(to_proto_state(rt_.control_state()));
return grpc::Status::OK;
}
private:
struct VarRef {
uint32_t vid;
s2::Format format; // declared wire format from equipment.yaml
};
gem::EquipmentRuntime& rt_;
std::map<std::string, VarRef> vars_; // SVIDs + DVIDs (SVIDs win on clash)
std::map<std::string, uint32_t> events_; // CEID by name
};
} // namespace secsgem::daemon