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
+6
View File
@@ -201,6 +201,12 @@ jobs:
- name: secs4j cross-validation
run: bash interop/secs4j_validate.sh
# Same 55 Java checks against the DAEMON's HSMS face: secs_gemd and
# secs_server both sit on register_default_handlers, so they must be
# byte-identical GEM. Image layers are shared with the step above.
- name: secs4j cross-validation (secs_gemd)
run: TARGET=gemd bash interop/secs4j_validate.sh
python-interop:
# secsgem-py (the Python reference implementation) judging our wire
# behaviour: its GemHostHandler drives our secs_server (31 checks), the
+11 -1
View File
@@ -170,6 +170,15 @@ if(SECSGEM_DAEMON)
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
target_link_libraries(secs_gemd PRIVATE secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES})
# The header-only C++ client's worked example (clients/cpp).
add_executable(cpp_mini_tool
clients/cpp/examples/mini_tool.cpp
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
target_include_directories(cpp_mini_tool PRIVATE
${PROTO_OUT} ${Protobuf_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/clients/cpp/include)
target_link_libraries(cpp_mini_tool PRIVATE PkgConfig::GRPCPP ${Protobuf_LIBRARIES} Threads::Threads)
set(SECSGEM_DAEMON_BUILT TRUE)
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
else()
@@ -266,10 +275,11 @@ add_test(NAME secsgem_tests COMMAND secsgem_tests)
if(SECSGEM_DAEMON_BUILT)
add_executable(secs_gemd_tests
tests/test_daemon_service.cpp
tests/test_cpp_client.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})
${PROTO_OUT} ${Protobuf_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/clients/cpp/include)
target_link_libraries(secs_gemd_tests PRIVATE
secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES} doctest::doctest)
target_compile_definitions(secs_gemd_tests PRIVATE
+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
+17 -5
View File
@@ -27,7 +27,7 @@ host and stays conformant while the tool software restarts/upgrades/crashes.
| gRPC/protobuf toolchain (Dockerfile + CMake codegen) | ✅ | grpc++ 1.51 / protoc 3.21; opt-in `SECSGEM_DAEMON`, graceful skip without grpc |
| `secs_gemd`: `SetVariables` / `FireEvent` / `GetControlState` / `GetVariables` | ✅ | **format-aware** both directions (declared SECS-II formats on write, `from_item` on read) and thread-safe (snapshot maps + posted writes + `read_sync` reads). In-process gRPC tests incl. run_async production mode (`test_daemon_service.cpp`, 61 assertions) |
| Daemon interop vs **secsgem-py** reference host | ✅ | `interop/daemon_interop.py` (via `gemd` compose service): gRPC `SetVariables(ChamberPressure=2.5)` + `FireEvent` → host receives `S6F11 CEID 300` carrying `<F4 2.5>` — value *and declared format* flow gRPC→engine→HSMS→host |
| Daemon interop vs **secs4j** (Java) | | mirror the secsgem-py harness against `interop/secs4j` |
| Daemon interop vs **secs4j** (Java) | | `TARGET=gemd interop/secs4j_validate.sh` — 55/55 against the daemon's HSMS face; in CI |
| `Subscribe` host→tool command stream + `CompleteCommand` | ✅ | HCACK-4 contract implemented + tested in-process AND live vs secsgem-py (full loop: S2F41 → stream → complete → S6F11) |
| Universal RPC surface complete (vars/events/alarms/control-state/health) | ✅ | Phase A done; daemon tests 101 assertions, interop 15 checks |
| Python client package (the "beautiful API") | ✅ | `clients/python` (`secsgem-client`); 13-check interop green via the published API |
@@ -170,7 +170,12 @@ debts tax every later phase, and the most valuable tests aren't automated.
START` → `S2F42 HCACK=4` → tool receives Command(name=START, id) on the
stream → `CompleteCommand` → tool fires the event → host receives `S6F11`.
(interop now 20 checks.)
7. Java interop: `secs4j` host variant of the same scenario.
7. Java interop: `TARGET=gemd interop/secs4j_validate.sh` runs the full
55-check secs4java8 suite against the DAEMON's HSMS face — 55/55 green
(secs_gemd and secs_server sit on the same register_default_handlers, so
byte-identical GEM is now proven, not assumed). CI step added. The
command loop with a live subscriber is covered by the python harnesses
(a Java *tool-side* gRPC client remains possible future work).
### Phase C — the beautiful Python client
8. ✅ `clients/python/` — pip-installable `secsgem-client`, pure Python,
@@ -185,9 +190,16 @@ debts tax every later phase, and the most valuable tests aren't automated.
S5F1 set+clear on the wire, HCACK-4 command loop through the decorator,
operator offline). Conversion layer unit-tested (bool-before-int etc).
Wired into tools/run_interop.sh as the `pyclient` step.
9. 🚧 `clients/python/examples/mini_tool.py` — a complete GEM tool in ~25
lines ✅. Migrating the C++ `pvd_tool` to EquipmentRuntime + capability
registration + `set_handler` ⬜.
9. `clients/python/examples/mini_tool.py` (~25 lines) and the C++
`pvd_tool` migrated to EquipmentRuntime + register_default_handlers +
`set_handler` (1093 -> 570 lines; now serves all 56 handlers; boots
verified). Chapter 42 teaches the daemon path.
10. ✅ **C++ client** (`clients/cpp`): header-only twin of the Python
client — `eq.set("ChamberPressure", 2.5)`, `eq.on("START", fn)` +
`listen_async()`, alarms/health/control-state, SecsGemError. Tested
end-to-end over loopback TCP against the real service inside
`secs_gemd_tests` (141 assertions total), incl. the HCACK-4 loop.
`cpp_mini_tool` is the worked example.
### Phase D — GEM300 in-the-loop (process/carrier tools)
10. ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe —
+6 -2
View File
@@ -17,6 +17,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake ninja-build \
libasio-dev libyaml-cpp-dev \
libprotobuf-dev protobuf-compiler protobuf-compiler-grpc libgrpc++-dev \
python3 python3-yaml \
git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -24,20 +25,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /src
COPY . /src
RUN cmake -S /src -B /src/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
&& cmake --build /src/build --target secs_server
&& cmake --build /src/build --target secs_server secs_gemd
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Dev packages in the runtime layer purely to pull the grpc/protobuf
# runtime libs by reliable names; image size is irrelevant for a CI harness.
RUN apt-get update && apt-get install -y --no-install-recommends \
libyaml-cpp0.8 \
libyaml-cpp0.8 libgrpc++-dev libprotobuf-dev \
&& rm -rf /var/lib/apt/lists/*
# WORKDIR must be the directory holding `data/` because secs_server's
# default --config / --state-table paths are relative ("data/...").
WORKDIR /app
COPY --from=build /src/build/secs_server /usr/local/bin/secs_server
COPY --from=build /src/build/secs_gemd /usr/local/bin/secs_gemd
COPY data /app/data
EXPOSE 5000
+15 -2
View File
@@ -10,6 +10,10 @@
# filesystem. Wired into CI via .gitea/workflows/ci.yml.
#
# Usage: bash interop/secs4j_validate.sh
# TARGET=gemd bash interop/secs4j_validate.sh
# -> runs the same 55 checks against secs_gemd's HSMS face
# (the daemon must be byte-identical GEM to secs_server,
# since both sit on register_default_handlers).
# Exit codes:
# 0 — every check the harness defines passed
# 1 — one or more checks failed
@@ -41,13 +45,22 @@ docker build -t secsgem-secs4j-interop -f interop/secs4j/Dockerfile interop/secs
docker network rm "$NET" >/dev/null 2>&1 || true
docker network create "$NET" >/dev/null
echo "starting secs_server..."
echo "starting ${TARGET:-server} (secs4j peer)..."
# --name doubles as the DNS hostname on the user-defined network, so
# the harness reaches it as "secs4j-interop-server:5000".
docker run -d --rm \
if [ "${TARGET:-server}" = "gemd" ]; then
docker run -d --rm \
--name "$SERVER_NAME" \
--network "$NET" \
--entrypoint /usr/local/bin/secs_gemd \
secsgem-secs4j-server \
--port 5000 --grpc 0.0.0.0:50051 --config-dir /app/data >/dev/null
else
docker run -d --rm \
--name "$SERVER_NAME" \
--network "$NET" \
secsgem-secs4j-server >/dev/null
fi
# Give the server a moment to bind.
sleep 1
+103
View File
@@ -0,0 +1,103 @@
// The C++ client (clients/cpp) against the real service over loopback TCP —
// the same end-to-end shape as the Python client's interop harness, in-tree.
#include <doctest/doctest.h>
#include <atomic>
#include <chrono>
#include <thread>
#include "secsgem/gem/default_handlers.hpp"
#include "secsgem/gem/messages.hpp"
#include "secsgem/daemon/equipment_service.hpp"
#include "secsgem_client/equipment.hpp"
using namespace secsgem;
namespace gem = secsgem::gem;
namespace s2 = secsgem::secs2;
#ifndef SECSGEM_DATA_DIR
#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt"
#endif
namespace {
gem::EquipmentRuntime::Config client_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;
return c;
}
} // namespace
TEST_CASE("C++ client end-to-end against the service over loopback TCP") {
gem::EquipmentRuntime rt(client_test_config());
gem::register_default_handlers(rt);
secsgem::daemon::EquipmentService svc(rt);
rt.run_async();
int port = 0;
grpc::ServerBuilder builder;
builder.AddListeningPort("127.0.0.1:0", grpc::InsecureServerCredentials(), &port);
builder.RegisterService(&svc);
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
REQUIRE(server);
REQUIRE(port != 0);
secsgem_client::Equipment eq("127.0.0.1:" + std::to_string(port));
// set/get round-trip with C++ literals (int stays integer, double real).
eq.set("ChamberPressure", 2.5);
eq.set("WaferCounter", 7);
auto vals = eq.get({"ChamberPressure", "WaferCounter"});
CHECK(std::get<double>(vals.at("ChamberPressure")) == doctest::Approx(2.5));
CHECK(std::get<int64_t>(vals.at("WaferCounter")) == 7);
// Errors carry the daemon's explanation.
CHECK_THROWS_WITH_AS(eq.set("NoSuchVariable", 1),
doctest::Contains("NoSuchVariable"),
secsgem_client::SecsGemError);
// Alarms by config name.
eq.alarm("chiller_temp_high");
auto active = rt.read_sync([&rt] { return rt.model().alarms.active(1); });
REQUIRE(active.has_value());
CHECK(*active);
eq.clear("chiller_temp_high");
// Control state + health.
CHECK(eq.control_state() == "HOST_OFFLINE");
auto h = eq.health();
CHECK(h.link == "DISCONNECTED");
CHECK(h.control_state == "HOST_OFFLINE");
// The command loop: handler runs, params arrive, S2F42 says HCACK 4.
std::atomic<bool> ran{false};
std::string seen_ppid;
eq.on("START", [&](const secsgem_client::Command& cmd) {
seen_ppid = std::get<std::string>(cmd.params.at("PPID"));
ran = true;
});
eq.listen_async();
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // subscribe race
auto reply = rt.read_sync([&rt]() {
return rt.router().dispatch(gem::s2f41_host_command(
"START", {{"PPID", s2::Item::ascii("RECIPE-A")}}));
});
REQUIRE(reply.has_value());
REQUIRE(reply->has_value());
auto parsed = gem::parse_s2f42(**reply);
REQUIRE(parsed.has_value());
CHECK(parsed->hcack == gem::HostCmdAck::AcceptedWillFinishLater);
for (int i = 0; i < 50 && !ran.load(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
CHECK(ran.load());
CHECK(seen_ppid == "RECIPE-A");
eq.stop();
server->Shutdown();
rt.stop();
}