#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