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
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:
@@ -201,6 +201,12 @@ jobs:
|
|||||||
- name: secs4j cross-validation
|
- name: secs4j cross-validation
|
||||||
run: bash interop/secs4j_validate.sh
|
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:
|
python-interop:
|
||||||
# secsgem-py (the Python reference implementation) judging our wire
|
# secsgem-py (the Python reference implementation) judging our wire
|
||||||
# behaviour: its GemHostHandler drives our secs_server (31 checks), the
|
# behaviour: its GemHostHandler drives our secs_server (31 checks), the
|
||||||
|
|||||||
+11
-1
@@ -170,6 +170,15 @@ if(SECSGEM_DAEMON)
|
|||||||
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
||||||
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
|
target_include_directories(secs_gemd PRIVATE ${PROTO_OUT} ${Protobuf_INCLUDE_DIRS})
|
||||||
target_link_libraries(secs_gemd PRIVATE secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES})
|
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)
|
set(SECSGEM_DAEMON_BUILT TRUE)
|
||||||
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
|
message(STATUS "secs_gemd daemon enabled (grpc++ ${GRPCPP_VERSION})")
|
||||||
else()
|
else()
|
||||||
@@ -266,10 +275,11 @@ add_test(NAME secsgem_tests COMMAND secsgem_tests)
|
|||||||
if(SECSGEM_DAEMON_BUILT)
|
if(SECSGEM_DAEMON_BUILT)
|
||||||
add_executable(secs_gemd_tests
|
add_executable(secs_gemd_tests
|
||||||
tests/test_daemon_service.cpp
|
tests/test_daemon_service.cpp
|
||||||
|
tests/test_cpp_client.cpp
|
||||||
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
|
${PROTO_OUT}/secsgem/v1/equipment.pb.cc
|
||||||
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
${PROTO_OUT}/secsgem/v1/equipment.grpc.pb.cc)
|
||||||
target_include_directories(secs_gemd_tests PRIVATE
|
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
|
target_link_libraries(secs_gemd_tests PRIVATE
|
||||||
secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES} doctest::doctest)
|
secsgem PkgConfig::GRPCPP ${Protobuf_LIBRARIES} doctest::doctest)
|
||||||
target_compile_definitions(secs_gemd_tests PRIVATE
|
target_compile_definitions(secs_gemd_tests PRIVATE
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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 |
|
| 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) |
|
| `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 **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) |
|
| `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 |
|
| 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 |
|
| 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
|
START` → `S2F42 HCACK=4` → tool receives Command(name=START, id) on the
|
||||||
stream → `CompleteCommand` → tool fires the event → host receives `S6F11`.
|
stream → `CompleteCommand` → tool fires the event → host receives `S6F11`.
|
||||||
(interop now 20 checks.)
|
(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
|
### Phase C — the beautiful Python client
|
||||||
8. ✅ `clients/python/` — pip-installable `secsgem-client`, pure Python,
|
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,
|
S5F1 set+clear on the wire, HCACK-4 command loop through the decorator,
|
||||||
operator offline). Conversion layer unit-tested (bool-before-int etc).
|
operator offline). Conversion layer unit-tested (bool-before-int etc).
|
||||||
Wired into tools/run_interop.sh as the `pyclient` step.
|
Wired into tools/run_interop.sh as the `pyclient` step.
|
||||||
9. 🚧 `clients/python/examples/mini_tool.py` — a complete GEM tool in ~25
|
9. ✅ `clients/python/examples/mini_tool.py` (~25 lines) and the C++
|
||||||
lines ✅. Migrating the C++ `pvd_tool` to EquipmentRuntime + capability
|
`pvd_tool` migrated to EquipmentRuntime + register_default_handlers +
|
||||||
registration + `set_handler` ⬜.
|
`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)
|
### Phase D — GEM300 in-the-loop (process/carrier tools)
|
||||||
10. ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe —
|
10. ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe —
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential cmake ninja-build \
|
build-essential cmake ninja-build \
|
||||||
libasio-dev libyaml-cpp-dev \
|
libasio-dev libyaml-cpp-dev \
|
||||||
|
libprotobuf-dev protobuf-compiler protobuf-compiler-grpc libgrpc++-dev \
|
||||||
python3 python3-yaml \
|
python3 python3-yaml \
|
||||||
git ca-certificates \
|
git ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -24,20 +25,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . /src
|
COPY . /src
|
||||||
RUN cmake -S /src -B /src/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
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
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
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 \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# WORKDIR must be the directory holding `data/` because secs_server's
|
# WORKDIR must be the directory holding `data/` because secs_server's
|
||||||
# default --config / --state-table paths are relative ("data/...").
|
# default --config / --state-table paths are relative ("data/...").
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/build/secs_server /usr/local/bin/secs_server
|
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
|
COPY data /app/data
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
# filesystem. Wired into CI via .gitea/workflows/ci.yml.
|
# filesystem. Wired into CI via .gitea/workflows/ci.yml.
|
||||||
#
|
#
|
||||||
# Usage: bash interop/secs4j_validate.sh
|
# 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:
|
# Exit codes:
|
||||||
# 0 — every check the harness defines passed
|
# 0 — every check the harness defines passed
|
||||||
# 1 — one or more checks failed
|
# 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 rm "$NET" >/dev/null 2>&1 || true
|
||||||
docker network create "$NET" >/dev/null
|
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
|
# --name doubles as the DNS hostname on the user-defined network, so
|
||||||
# the harness reaches it as "secs4j-interop-server:5000".
|
# the harness reaches it as "secs4j-interop-server:5000".
|
||||||
docker run -d --rm \
|
if [ "${TARGET:-server}" = "gemd" ]; then
|
||||||
--name "$SERVER_NAME" \
|
docker run -d --rm \
|
||||||
--network "$NET" \
|
--name "$SERVER_NAME" \
|
||||||
secsgem-secs4j-server >/dev/null
|
--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.
|
# Give the server a moment to bind.
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user