feat(clients)+test(interop): C++ client + Java validation of the daemon (B7)
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>
This commit is contained in:
2026-06-10 23:37:14 +02:00
parent 4f3031aeb9
commit b30443089f
9 changed files with 515 additions and 13 deletions
+26
View File
@@ -0,0 +1,26 @@
# secsgem-client (C++)
The C++ twin of [clients/python](../python): a header-only client for the
`secs_gemd` daemon. Name-based, plain-typed, no SEMI knowledge required.
```cpp
#include "secsgem_client/equipment.hpp"
secsgem_client::Equipment eq("localhost:50051");
eq.set("ChamberPressure", 2.5); // host sees it on its next poll
eq.fire("ProcessStarted"); // S6F11, report auto-assembled
eq.alarm("chiller_temp_high"); // S5F1 set / eq.clear(...) clears
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()
```
Errors throw `secsgem_client::SecsGemError` carrying the daemon's
explanation. Build: include this header, compile the generated
`equipment.pb.cc` / `equipment.grpc.pb.cc` from
[proto/secsgem/v1](../../proto/secsgem/v1/equipment.proto), link `grpc++`.
In this repo the `cpp_mini_tool` CMake target (built when grpc is present)
is the worked example; out of tree, copy its four-line target definition.
+28
View File
@@ -0,0 +1,28 @@
// A complete GEM tool in ~30 lines of C++ — the twin of
// clients/python/examples/mini_tool.py. Run secs_gemd, then this.
#include <chrono>
#include <iostream>
#include <random>
#include <thread>
#include "secsgem_client/equipment.hpp"
int main() {
secsgem_client::Equipment eq("localhost:50051");
eq.on("START", [&](const secsgem_client::Command& cmd) {
std::cout << "host says START (id " << cmd.id << ")\n";
eq.fire("ProcessStarted");
});
eq.listen_async();
std::mt19937 rng{std::random_device{}()};
std::uniform_real_distribution<double> pressure(1.0, 3.0);
while (true) {
const double p = pressure(rng);
eq.set("ChamberPressure", p);
if (p > 2.9) eq.alarm("chiller_temp_high");
else eq.clear("chiller_temp_high");
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
@@ -0,0 +1,300 @@
#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