b30443089f
tests / build-and-test (push) Successful in 2m54s
tests / thread-sanitizer (push) Successful in 3m48s
tests / tshark-dissector (push) Successful in 2m24s
tests / secs4j-interop (push) Successful in 1m44s
tests / python-interop (push) Successful in 3m10s
tests / libfuzzer (push) Successful in 3m38s
B7 — the daemon's HSMS face under the Java reference: Dockerfile.server now
bakes secs_gemd alongside secs_server (grpc deps in both stages), and
secs4j_validate.sh gains TARGET=gemd to point the 55-check secs4java8 suite
at the daemon instead. Result: 55/55 green. With secsgem-py already
validating both faces, byte-identical GEM between secs_server and secs_gemd
is now proven by both reference implementations, not inferred from shared
code. CI runs the daemon target as an extra step (image layers shared).
Second client — clients/cpp: a header-only C++ twin of the Python client
over the same proto. eq.set("ChamberPressure", 2.5) with bare literals
(integral/floating dispatch avoids variant ambiguity), get/fire/alarm/
clear, control_state/request_control_state/health, on("START", fn) +
listen()/listen_async()/stop() with auto-CompleteCommand, SecsGemError
carrying the daemon's message. cpp_mini_tool (~30 lines) mirrors the
Python mini_tool. Tested end-to-end over real loopback TCP against the
service inside secs_gemd_tests — now 4 cases / 141 assertions — including
set/get round-trips, error text, alarm-by-name into the model, health,
and the full HCACK-4 command loop with parameters.
(Build note: two grpc-heavy TUs at -O3 OOM even at -j2 on Docker Desktop;
built -j1. Known environment limitation, roadmap-documented.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
9.6 KiB
C++
301 lines
9.6 KiB
C++
#pragma once
|
|
|
|
// secsgem_client::Equipment — drive a secs_gemd SECS/GEM daemon from C++.
|
|
//
|
|
// The C++ twin of the Python client (clients/python): name-based, plain-
|
|
// typed, no SEMI knowledge required. Header-only over the generated gRPC
|
|
// stubs; link grpc++ + the generated proto objects (see clients/cpp/README).
|
|
//
|
|
// secsgem_client::Equipment eq("localhost:50051");
|
|
// eq.set("ChamberPressure", 2.5);
|
|
// eq.fire("ProcessStarted");
|
|
// eq.on("START", [&](const secsgem_client::Command& cmd) {
|
|
// run_recipe(cmd.params.at("PPID"));
|
|
// eq.fire("ProcessStarted"); // the host's completion signal
|
|
// });
|
|
// eq.listen(); // or listen_async() + stop()
|
|
|
|
#include <grpcpp/grpcpp.h>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <functional>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <type_traits>
|
|
#include <variant>
|
|
#include <vector>
|
|
|
|
#include "secsgem/v1/equipment.grpc.pb.h"
|
|
#include "secsgem/v1/equipment.pb.h"
|
|
|
|
namespace secsgem_client {
|
|
|
|
namespace pb = secsgem::v1;
|
|
|
|
// Plain value: what you put in and what you get back. (Lists arrive as
|
|
// their text-free underlying values only via the daemon's scalar rules;
|
|
// nested lists are rare in tool code — extend when needed.)
|
|
using Value = std::variant<bool, int64_t, double, std::string,
|
|
std::vector<uint8_t>>;
|
|
|
|
class SecsGemError : public std::runtime_error {
|
|
public:
|
|
SecsGemError(int code, const std::string& msg)
|
|
: std::runtime_error(msg), code_(code) {}
|
|
int code() const { return code_; }
|
|
|
|
private:
|
|
int code_;
|
|
};
|
|
|
|
struct Command {
|
|
std::string id;
|
|
std::string name;
|
|
std::map<std::string, Value> params;
|
|
};
|
|
|
|
struct Health {
|
|
std::string link; // "DISCONNECTED" | "CONNECTED" | "SELECTED"
|
|
std::string control_state; // "HOST_OFFLINE" | "ONLINE_REMOTE" | ...
|
|
uint32_t spool_depth = 0;
|
|
};
|
|
|
|
namespace detail {
|
|
|
|
inline pb::Value to_value(const Value& v) {
|
|
pb::Value out;
|
|
if (std::holds_alternative<bool>(v)) out.set_boolean(std::get<bool>(v));
|
|
else if (std::holds_alternative<int64_t>(v)) out.set_integer(std::get<int64_t>(v));
|
|
else if (std::holds_alternative<double>(v)) out.set_real(std::get<double>(v));
|
|
else if (std::holds_alternative<std::string>(v)) out.set_text(std::get<std::string>(v));
|
|
else {
|
|
const auto& b = std::get<std::vector<uint8_t>>(v);
|
|
out.set_binary(std::string(b.begin(), b.end()));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
inline Value from_value(const pb::Value& v) {
|
|
switch (v.kind_case()) {
|
|
case pb::Value::kBoolean: return v.boolean();
|
|
case pb::Value::kInteger: return static_cast<int64_t>(v.integer());
|
|
case pb::Value::kReal: return v.real();
|
|
case pb::Value::kText: return v.text();
|
|
case pb::Value::kBinary:
|
|
return std::vector<uint8_t>(v.binary().begin(), v.binary().end());
|
|
default: return std::string{}; // unset / list: see header note
|
|
}
|
|
}
|
|
|
|
// Accept any sane C++ literal without variant-conversion ambiguity
|
|
// (e.g. a bare `7` would be ambiguous between int64_t and double).
|
|
template <typename T>
|
|
Value make_value(T&& v) {
|
|
using D = std::decay_t<T>;
|
|
if constexpr (std::is_same_v<D, bool>) return Value(v);
|
|
else if constexpr (std::is_integral_v<D>) return Value(static_cast<int64_t>(v));
|
|
else if constexpr (std::is_floating_point_v<D>) return Value(static_cast<double>(v));
|
|
else if constexpr (std::is_same_v<D, std::vector<uint8_t>>) return Value(std::forward<T>(v));
|
|
else return Value(std::string(std::forward<T>(v)));
|
|
}
|
|
|
|
inline void check(const pb::Ack& ack) {
|
|
if (ack.code() != pb::Ack::ACCEPT)
|
|
throw SecsGemError(ack.code(), ack.message().empty()
|
|
? pb::Ack::Code_Name(ack.code())
|
|
: ack.message());
|
|
}
|
|
|
|
inline void check(const grpc::Status& st) {
|
|
if (!st.ok()) throw SecsGemError(pb::Ack::PARAMETER_INVALID, st.error_message());
|
|
}
|
|
|
|
} // namespace detail
|
|
|
|
class Equipment {
|
|
public:
|
|
explicit Equipment(const std::string& address = "localhost:50051",
|
|
std::chrono::seconds connect_timeout = std::chrono::seconds(10)) {
|
|
channel_ = grpc::CreateChannel(address, grpc::InsecureChannelCredentials());
|
|
if (!channel_->WaitForConnected(
|
|
std::chrono::system_clock::now() + connect_timeout))
|
|
throw SecsGemError(pb::Ack::CANNOT_DO_NOW, "no daemon at " + address);
|
|
stub_ = pb::Equipment::NewStub(channel_);
|
|
}
|
|
|
|
~Equipment() { stop(); }
|
|
|
|
// ---- report state to the host -------------------------------------------
|
|
|
|
template <typename T>
|
|
void set(const std::string& name, T&& value) {
|
|
set({{name, detail::make_value(std::forward<T>(value))}});
|
|
}
|
|
|
|
void set(const std::map<std::string, Value>& values) {
|
|
pb::VariableUpdate req;
|
|
for (const auto& [name, v] : values)
|
|
(*req.mutable_values())[name] = detail::to_value(v);
|
|
pb::Ack ack;
|
|
grpc::ClientContext ctx;
|
|
detail::check(stub_->SetVariables(&ctx, req, &ack));
|
|
detail::check(ack);
|
|
}
|
|
|
|
// No names = every configured variable.
|
|
std::map<std::string, Value> get(const std::vector<std::string>& names = {}) {
|
|
pb::VariableQuery req;
|
|
for (const auto& n : names) req.add_names(n);
|
|
pb::VariableSnapshot snap;
|
|
grpc::ClientContext ctx;
|
|
detail::check(stub_->GetVariables(&ctx, req, &snap));
|
|
std::map<std::string, Value> out;
|
|
for (const auto& [k, v] : snap.values()) out.emplace(k, detail::from_value(v));
|
|
return out;
|
|
}
|
|
|
|
void fire(const std::string& event,
|
|
const std::map<std::string, Value>& data = {}) {
|
|
pb::Event req;
|
|
req.set_name(event);
|
|
for (const auto& [name, v] : data)
|
|
(*req.mutable_data())[name] = detail::to_value(v);
|
|
pb::Ack ack;
|
|
grpc::ClientContext ctx;
|
|
detail::check(stub_->FireEvent(&ctx, req, &ack));
|
|
detail::check(ack);
|
|
}
|
|
|
|
void alarm(const std::string& name) { alarm_action(name, /*set=*/true); }
|
|
void clear(const std::string& name) { alarm_action(name, /*set=*/false); }
|
|
|
|
// ---- control state & health ----------------------------------------------
|
|
|
|
std::string control_state() {
|
|
pb::Empty req;
|
|
pb::ControlState resp;
|
|
grpc::ClientContext ctx;
|
|
detail::check(stub_->GetControlState(&ctx, req, &resp));
|
|
return pb::ControlState::State_Name(resp.state());
|
|
}
|
|
|
|
void request_control_state(const std::string& desired) {
|
|
pb::ControlState::State s;
|
|
if (!pb::ControlState::State_Parse(desired, &s))
|
|
throw SecsGemError(pb::Ack::PARAMETER_INVALID,
|
|
"unknown control state '" + desired + "'");
|
|
pb::ControlStateRequest req;
|
|
req.set_desired(s);
|
|
pb::Ack ack;
|
|
grpc::ClientContext ctx;
|
|
detail::check(stub_->RequestControlState(&ctx, req, &ack));
|
|
detail::check(ack);
|
|
}
|
|
|
|
Health health() {
|
|
pb::Empty req;
|
|
grpc::ClientContext ctx;
|
|
auto reader = stub_->WatchHealth(&ctx, req);
|
|
pb::Health h;
|
|
if (!reader->Read(&h))
|
|
throw SecsGemError(pb::Ack::CANNOT_DO_NOW, "health stream ended");
|
|
ctx.TryCancel();
|
|
pb::Health drain;
|
|
while (reader->Read(&drain)) {}
|
|
(void)reader->Finish(); // CANCELLED — expected
|
|
return {pb::Health::LinkState_Name(h.link()),
|
|
pb::ControlState::State_Name(h.control_state()), h.spool_depth()};
|
|
}
|
|
|
|
// ---- react to the host -----------------------------------------------------
|
|
|
|
void on(const std::string& command, std::function<void(const Command&)> fn) {
|
|
handlers_[command] = std::move(fn);
|
|
}
|
|
|
|
// Consume host requests and dispatch to on() handlers. Blocks until stop().
|
|
void listen() {
|
|
grpc::ClientContext ctx;
|
|
{
|
|
std::lock_guard<std::mutex> lk(listen_mu_);
|
|
listen_ctx_ = &ctx;
|
|
}
|
|
pb::SubscribeRequest req;
|
|
req.set_client("secsgem_client_cpp");
|
|
auto reader = stub_->Subscribe(&ctx, req);
|
|
pb::HostRequest hr;
|
|
while (!stopping_.load() && reader->Read(&hr)) {
|
|
if (!hr.has_command()) continue; // future HostRequest variants
|
|
const auto& c = hr.command();
|
|
Command cmd{c.id(), c.name(), {}};
|
|
for (const auto& [k, v] : c.params()) cmd.params.emplace(k, detail::from_value(v));
|
|
auto it = handlers_.find(cmd.name);
|
|
if (it == handlers_.end()) it = handlers_.find("*");
|
|
bool ok = true;
|
|
if (it != handlers_.end()) {
|
|
try {
|
|
it->second(cmd);
|
|
} catch (...) {
|
|
ok = false;
|
|
}
|
|
}
|
|
complete(cmd.id, ok);
|
|
}
|
|
(void)reader->Finish();
|
|
{
|
|
std::lock_guard<std::mutex> lk(listen_mu_);
|
|
listen_ctx_ = nullptr;
|
|
}
|
|
}
|
|
|
|
void listen_async() {
|
|
listen_thread_ = std::thread([this] { listen(); });
|
|
}
|
|
|
|
void stop() {
|
|
stopping_.store(true);
|
|
{
|
|
std::lock_guard<std::mutex> lk(listen_mu_);
|
|
if (listen_ctx_) listen_ctx_->TryCancel();
|
|
}
|
|
if (listen_thread_.joinable()) listen_thread_.join();
|
|
}
|
|
|
|
private:
|
|
void alarm_action(const std::string& name, bool set) {
|
|
pb::Alarm req;
|
|
req.set_name(name);
|
|
pb::Ack ack;
|
|
grpc::ClientContext ctx;
|
|
detail::check(set ? stub_->SetAlarm(&ctx, req, &ack)
|
|
: stub_->ClearAlarm(&ctx, req, &ack));
|
|
detail::check(ack);
|
|
}
|
|
|
|
void complete(const std::string& id, bool ok) {
|
|
pb::CommandResult res;
|
|
res.set_id(id);
|
|
res.mutable_ack()->set_code(ok ? pb::Ack::ACCEPT : pb::Ack::REJECTED);
|
|
pb::Ack ack;
|
|
grpc::ClientContext ctx;
|
|
(void)stub_->CompleteCommand(&ctx, res, &ack); // audit-only
|
|
}
|
|
|
|
std::shared_ptr<grpc::Channel> channel_;
|
|
std::unique_ptr<pb::Equipment::Stub> stub_;
|
|
std::map<std::string, std::function<void(const Command&)>> handlers_;
|
|
std::thread listen_thread_;
|
|
std::atomic<bool> stopping_{false};
|
|
std::mutex listen_mu_;
|
|
grpc::ClientContext* listen_ctx_ = nullptr;
|
|
};
|
|
|
|
} // namespace secsgem_client
|