Files
secs-gem/apps/equipment_service.hpp
T
raphael 1da56f973f feat(daemon): alarms by name + RequestControlState + WatchHealth (Phase A complete)
A2 — alarms: optional 'name:' on alarm config (a LOCAL key — SEMI only
defines numeric ALID + freetext ALTX; field appended last so existing
{id, text, category} brace-inits compile unchanged), parsed by the loader,
checked by the validator, shipped in equipment.yaml. SetAlarm/ClearAlarm
RPCs resolve config name OR stringified ALID via a constructor snapshot.

A3 — control state + health: RequestControlState fires operator events on
the io thread (read_sync) and reports what the E30 table actually did —
ACCEPT iff the equipment landed in the requested state, CANNOT_DO_NOW naming
the actual state otherwise (the shipped table has no operator path to
EquipmentOffline; the test pins that honesty). ATTEMPT_ONLINE is rejected as
transient. WatchHealth streams an immediate snapshot then pushes on link/
control-state changes via service observers (add_link_observer +
add_control_state_observer — the HandlerSlot work paying off), spool depth
sampled at the 500ms poll; ends on cancel or engine stop.

Tests: daemon suite 61 -> 101 assertions (alarm lifecycle by name/id/unknown,
WatchHealth initial + change push, all four RequestControlState semantics);
loader test for the alarm name (present + absent fallback); core 467/3055.
Interop now 15 checks incl. gRPC SetAlarm -> host receives S5F1 ALCD=0x84
ALID=1, and RequestControlState(HOST_OFFLINE) -> GetControlState confirms.

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

428 lines
17 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 <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#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;
}
// SECS-II Item -> proto Value (the read direction). Single-element numeric
// arrays — the overwhelmingly common case — become scalars; multi-element
// arrays become a List so nothing is lost.
// TODO(daemon): C2 (2-byte Unicode) currently surfaces as integer code
// points (it shares storage with U2); convert to text when a tool needs it.
// TODO(daemon): U8 values above 2^63-1 wrap through the sint64 integer
// field; switch Value to a dedicated uint64 arm if that ever bites.
inline pb::Value from_item(const s2::Item& item) {
pb::Value out;
switch (item.format()) {
case s2::Format::List: {
auto* list = out.mutable_list();
for (const auto& child : item.as_list()) *list->add_items() = from_item(child);
return out;
}
case s2::Format::ASCII:
case s2::Format::JIS8:
out.set_text(item.as_ascii());
return out;
case s2::Format::Binary: {
const auto& b = item.as_bytes();
out.set_binary(std::string(b.begin(), b.end()));
return out;
}
case s2::Format::Boolean: {
const auto& b = item.as_bytes();
if (b.size() == 1) {
out.set_boolean(b[0] != 0);
} else {
auto* list = out.mutable_list();
for (auto v : b) list->add_items()->set_boolean(v != 0);
}
return out;
}
default:
break; // numeric formats: handled generically below
}
return std::visit(
[&](const auto& vec) -> pb::Value {
using V = std::decay_t<decltype(vec)>;
if constexpr (std::is_same_v<V, s2::Item::List> ||
std::is_same_v<V, std::string>) {
return out; // unreachable: handled above
} else {
auto set_one = [](pb::Value& dst, auto v) {
if constexpr (std::is_floating_point_v<decltype(v)>)
dst.set_real(static_cast<double>(v));
else
dst.set_integer(static_cast<int64_t>(v));
};
if (vec.size() == 1) {
set_one(out, vec[0]);
} else {
auto* list = out.mutable_list();
for (auto v : vec) set_one(*list->add_items(), v);
}
return out;
}
},
item.storage());
}
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 and registers the
// health observers. Construct before run_async() so the model is read (and
// observers land) 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});
for (const auto& al : rt.model().alarms.all()) {
if (!al.name.empty()) alarms_.insert({al.name, al.id});
alarms_.insert({std::to_string(al.id), al.id}); // always addressable by id
}
// Health signals: bump a version + wake WatchHealth streams whenever the
// HSMS link or the control state changes (observers fire on the io thread;
// add_ observers survive register_default_handlers' primary set_).
rt.add_link_observer([this](bool selected) {
link_selected_.store(selected, std::memory_order_relaxed);
bump_health();
});
rt.add_control_state_observer(
[this](gem::ControlState, gem::ControlState, gem::ControlEvent) {
bump_health();
});
}
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 SetAlarm(grpc::ServerContext*, const pb::Alarm* req,
pb::Ack* resp) override {
return alarm_action(req->name(), /*set=*/true, resp);
}
grpc::Status ClearAlarm(grpc::ServerContext*, const pb::Alarm* req,
pb::Ack* resp) override {
return alarm_action(req->name(), /*set=*/false, resp);
}
grpc::Status GetVariables(grpc::ServerContext*, const pb::VariableQuery* req,
pb::VariableSnapshot* resp) override {
// Resolve names against the snapshot maps (empty query = everything).
std::vector<std::pair<std::string, uint32_t>> wanted;
if (req->names().empty()) {
wanted.reserve(vars_.size());
for (const auto& [name, ref] : vars_) wanted.emplace_back(name, ref.vid);
} else {
for (const auto& name : req->names()) {
auto it = vars_.find(name);
if (it == vars_.end())
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
"no variable named '" + name + "'");
wanted.emplace_back(name, it->second.vid);
}
}
// Mutable values live on the io thread — read them there (read_sync is
// the standard pattern; see runtime.hpp).
std::vector<uint32_t> vids;
vids.reserve(wanted.size());
for (const auto& w : wanted) vids.push_back(w.second);
auto values = rt_.read_sync([this, vids]() {
std::vector<std::optional<s2::Item>> out;
out.reserve(vids.size());
for (auto vid : vids) out.push_back(rt_.model().vid_value(vid));
return out;
});
if (!values)
return grpc::Status(grpc::StatusCode::UNAVAILABLE,
"engine io thread did not answer (not running?)");
for (std::size_t i = 0; i < wanted.size(); ++i) {
if (!(*values)[i]) continue; // vid vanished — defensive, can't happen
(*resp->mutable_values())[wanted[i].first] = from_item(*(*values)[i]);
}
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;
}
// Operator-panel control-state transitions (e.g. "offline for maintenance").
// Fires the operator events on the io thread and reports what the E30 table
// actually did: ACCEPT iff the equipment landed in the requested state,
// CANNOT_DO_NOW (naming the actual state) otherwise. Note the shipped table
// has no operator path to EQUIPMENT_OFFLINE — operator_offline lands
// HOST_OFFLINE — so honesty matters here.
grpc::Status RequestControlState(grpc::ServerContext*,
const pb::ControlStateRequest* req,
pb::Ack* resp) override {
using PS = pb::ControlState;
const auto desired = req->desired();
if (desired == PS::ATTEMPT_ONLINE) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("ATTEMPT_ONLINE is transient; request a settled state");
return grpc::Status::OK;
}
auto final_state = rt_.read_sync([this, desired]() {
auto& sm = rt_.control();
switch (desired) {
case PS::ONLINE_LOCAL:
if (sm.state() == gem::ControlState::OnlineRemote) sm.operator_local();
else if (!sm.online()) sm.operator_online(); // table chains to OnlineLocal
break;
case PS::ONLINE_REMOTE:
if (sm.state() == gem::ControlState::OnlineLocal) sm.operator_remote();
else if (!sm.online()) {
sm.operator_online();
sm.operator_remote();
}
break;
case PS::HOST_OFFLINE:
case PS::EQUIPMENT_OFFLINE:
sm.operator_offline();
break;
default:
break;
}
return sm.state();
});
if (!final_state) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer (not running?)");
return grpc::Status::OK;
}
if (to_proto_state(*final_state) == desired) {
resp->set_code(pb::Ack::ACCEPT);
} else {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message(std::string("equipment is in ") +
gem::control_state_name(*final_state));
}
return grpc::Status::OK;
}
// Streams a Health snapshot immediately, then again whenever the link or
// control state changes (and on spool-depth changes, sampled at the poll
// interval). Ends when the client cancels or the engine stops.
grpc::Status WatchHealth(grpc::ServerContext* ctx, const pb::Empty*,
grpc::ServerWriter<pb::Health>* writer) override {
bool first = true;
pb::Health last;
while (!ctx->IsCancelled()) {
const uint64_t seen = health_version_.load(std::memory_order_relaxed);
auto snap = make_health();
if (!snap) break; // engine stopped — end the stream
if (first || snap->link() != last.link() ||
snap->control_state() != last.control_state() ||
snap->spool_depth() != last.spool_depth()) {
if (!writer->Write(*snap)) break;
last = *snap;
first = false;
}
std::unique_lock<std::mutex> lk(health_mu_);
health_cv_.wait_for(lk, std::chrono::milliseconds(500), [&] {
return health_version_.load(std::memory_order_relaxed) != seen;
});
}
return grpc::Status::OK;
}
private:
void bump_health() {
health_version_.fetch_add(1, std::memory_order_relaxed);
health_cv_.notify_all();
}
// Build a Health snapshot. Control state + link come from atomics; spool
// depth is mutable engine state, read via read_sync. nullopt = engine down.
std::optional<pb::Health> make_health() {
auto depth = rt_.read_sync(
[this]() { return static_cast<uint32_t>(rt_.model().spool.size()); });
if (!depth) return std::nullopt;
pb::Health h;
// CONNECTED (TCP up, not yet SELECTED) is reserved: the runtime's link
// observer fires on SELECTED/closed only. TODO(daemon): surface the
// intermediate state if a tool ever needs it.
h.set_link(link_selected_.load(std::memory_order_relaxed)
? pb::Health::SELECTED
: pb::Health::DISCONNECTED);
h.set_spool_depth(*depth);
h.set_control_state(to_proto_state(rt_.control_state()));
return h;
}
grpc::Status alarm_action(const std::string& name, bool set, pb::Ack* resp) {
auto it = alarms_.find(name);
if (it == alarms_.end()) {
resp->set_code(pb::Ack::PARAMETER_INVALID);
resp->set_message("no alarm named '" + name + "'");
return grpc::Status::OK;
}
if (set) rt_.set_alarm(it->second);
else rt_.clear_alarm(it->second);
resp->set_code(pb::Ack::ACCEPT);
return grpc::Status::OK;
}
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
std::map<std::string, uint32_t> alarms_; // ALID by name AND stringified id
// WatchHealth plumbing: observers (io thread) bump the version and wake the
// per-stream wait loops (gRPC threads).
std::atomic<bool> link_selected_{false};
std::atomic<uint64_t> health_version_{0};
std::mutex health_mu_;
std::condition_variable health_cv_;
};
} // namespace secsgem::daemon