diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f1c8899..3526128 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 453a477..9e4ffd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/clients/cpp/README.md b/clients/cpp/README.md new file mode 100644 index 0000000..a264f7e --- /dev/null +++ b/clients/cpp/README.md @@ -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. diff --git a/clients/cpp/examples/mini_tool.cpp b/clients/cpp/examples/mini_tool.cpp new file mode 100644 index 0000000..0280a4b --- /dev/null +++ b/clients/cpp/examples/mini_tool.cpp @@ -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 +#include +#include +#include + +#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 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)); + } +} diff --git a/clients/cpp/include/secsgem_client/equipment.hpp b/clients/cpp/include/secsgem_client/equipment.hpp new file mode 100644 index 0000000..0e35f58 --- /dev/null +++ b/clients/cpp/include/secsgem_client/equipment.hpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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>; + +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 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(v)) out.set_boolean(std::get(v)); + else if (std::holds_alternative(v)) out.set_integer(std::get(v)); + else if (std::holds_alternative(v)) out.set_real(std::get(v)); + else if (std::holds_alternative(v)) out.set_text(std::get(v)); + else { + const auto& b = std::get>(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(v.integer()); + case pb::Value::kReal: return v.real(); + case pb::Value::kText: return v.text(); + case pb::Value::kBinary: + return std::vector(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 +Value make_value(T&& v) { + using D = std::decay_t; + if constexpr (std::is_same_v) return Value(v); + else if constexpr (std::is_integral_v) return Value(static_cast(v)); + else if constexpr (std::is_floating_point_v) return Value(static_cast(v)); + else if constexpr (std::is_same_v>) return Value(std::forward(v)); + else return Value(std::string(std::forward(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 + void set(const std::string& name, T&& value) { + set({{name, detail::make_value(std::forward(value))}}); + } + + void set(const std::map& 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 get(const std::vector& 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 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& 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 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 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 lk(listen_mu_); + listen_ctx_ = nullptr; + } + } + + void listen_async() { + listen_thread_ = std::thread([this] { listen(); }); + } + + void stop() { + stopping_.store(true); + { + std::lock_guard 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 channel_; + std::unique_ptr stub_; + std::map> handlers_; + std::thread listen_thread_; + std::atomic stopping_{false}; + std::mutex listen_mu_; + grpc::ClientContext* listen_ctx_ = nullptr; +}; + +} // namespace secsgem_client diff --git a/docs/DAEMON_ROADMAP.md b/docs/DAEMON_ROADMAP.md index 324e13d..40273fb 100644 --- a/docs/DAEMON_ROADMAP.md +++ b/docs/DAEMON_ROADMAP.md @@ -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 `` — 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 — diff --git a/interop/Dockerfile.server b/interop/Dockerfile.server index cc43bed..99b6a3a 100644 --- a/interop/Dockerfile.server +++ b/interop/Dockerfile.server @@ -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 diff --git a/interop/secs4j_validate.sh b/interop/secs4j_validate.sh index a8055bb..8f26044 100755 --- a/interop/secs4j_validate.sh +++ b/interop/secs4j_validate.sh @@ -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 \ - --name "$SERVER_NAME" \ - --network "$NET" \ - secsgem-secs4j-server >/dev/null +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 diff --git a/tests/test_cpp_client.cpp b/tests/test_cpp_client.cpp new file mode 100644 index 0000000..df1bf32 --- /dev/null +++ b/tests/test_cpp_client.cpp @@ -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 + +#include +#include +#include + +#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 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(vals.at("ChamberPressure")) == doctest::Approx(2.5)); + CHECK(std::get(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 ran{false}; + std::string seen_ppid; + eq.on("START", [&](const secsgem_client::Command& cmd) { + seen_ppid = std::get(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(); +}