feat(daemon): GetVariables + read_sync — the standard mutable-read pattern
EquipmentRuntime::read_sync establishes THE pattern for reading mutable engine state from gRPC/binding threads (Phase 0 item 6): post the read onto the io thread (the model's single owner), wait on a future with a deadline, nullopt => UNAVAILABLE at the RPC edge. Always truthful, no cache to invalidate; milliseconds are irrelevant at SECS rates. GetVariables: name resolution against the service snapshot (empty query = all; unknown name => INVALID_ARGUMENT naming the offender), values read via read_sync, converted by the new from_item reverse conversion (single-element numeric arrays => scalars, multi-element => List; Boolean/Binary/text per format; C2-as-integer and U8>2^63 wrap documented as TODOs). Tests run the engine in run_async — the daemon's PRODUCTION threading mode, previously untested — and round-trip through both conversions: SetVariables (declared-format write) then GetVariables (read) over a real in-process channel. Daemon suite 41 -> 61 assertions. daemon_interop.py gains a live GetVariables round-trip check vs the running daemon (verified green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/runtime.hpp"
|
||||
#include "secsgem/secs2/item.hpp"
|
||||
@@ -78,6 +80,68 @@ inline bool value_is_set(const pb::Value& v) {
|
||||
return v.kind_case() != pb::Value::KIND_NOT_SET;
|
||||
}
|
||||
|
||||
// SECS-II Item -> proto Value (the read direction). Single-element numeric
|
||||
// arrays — the overwhelmingly common case — become scalars; multi-element
|
||||
// arrays become a List so nothing is lost.
|
||||
// TODO(daemon): C2 (2-byte Unicode) currently surfaces as integer code
|
||||
// points (it shares storage with U2); convert to text when a tool needs it.
|
||||
// TODO(daemon): U8 values above 2^63-1 wrap through the sint64 integer
|
||||
// field; switch Value to a dedicated uint64 arm if that ever bites.
|
||||
inline pb::Value from_item(const s2::Item& item) {
|
||||
pb::Value out;
|
||||
switch (item.format()) {
|
||||
case s2::Format::List: {
|
||||
auto* list = out.mutable_list();
|
||||
for (const auto& child : item.as_list()) *list->add_items() = from_item(child);
|
||||
return out;
|
||||
}
|
||||
case s2::Format::ASCII:
|
||||
case s2::Format::JIS8:
|
||||
out.set_text(item.as_ascii());
|
||||
return out;
|
||||
case s2::Format::Binary: {
|
||||
const auto& b = item.as_bytes();
|
||||
out.set_binary(std::string(b.begin(), b.end()));
|
||||
return out;
|
||||
}
|
||||
case s2::Format::Boolean: {
|
||||
const auto& b = item.as_bytes();
|
||||
if (b.size() == 1) {
|
||||
out.set_boolean(b[0] != 0);
|
||||
} else {
|
||||
auto* list = out.mutable_list();
|
||||
for (auto v : b) list->add_items()->set_boolean(v != 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
break; // numeric formats: handled generically below
|
||||
}
|
||||
return std::visit(
|
||||
[&](const auto& vec) -> pb::Value {
|
||||
using V = std::decay_t<decltype(vec)>;
|
||||
if constexpr (std::is_same_v<V, s2::Item::List> ||
|
||||
std::is_same_v<V, std::string>) {
|
||||
return out; // unreachable: handled above
|
||||
} else {
|
||||
auto set_one = [](pb::Value& dst, auto v) {
|
||||
if constexpr (std::is_floating_point_v<decltype(v)>)
|
||||
dst.set_real(static_cast<double>(v));
|
||||
else
|
||||
dst.set_integer(static_cast<int64_t>(v));
|
||||
};
|
||||
if (vec.size() == 1) {
|
||||
set_one(out, vec[0]);
|
||||
} else {
|
||||
auto* list = out.mutable_list();
|
||||
for (auto v : vec) set_one(*list->add_items(), v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
},
|
||||
item.storage());
|
||||
}
|
||||
|
||||
inline pb::ControlState::State to_proto_state(gem::ControlState s) {
|
||||
switch (s) {
|
||||
case gem::ControlState::EquipmentOffline: return pb::ControlState::EQUIPMENT_OFFLINE;
|
||||
@@ -150,6 +214,43 @@ class EquipmentService final : public pb::Equipment::Service {
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
grpc::Status GetVariables(grpc::ServerContext*, const pb::VariableQuery* req,
|
||||
pb::VariableSnapshot* resp) override {
|
||||
// Resolve names against the snapshot maps (empty query = everything).
|
||||
std::vector<std::pair<std::string, uint32_t>> wanted;
|
||||
if (req->names().empty()) {
|
||||
wanted.reserve(vars_.size());
|
||||
for (const auto& [name, ref] : vars_) wanted.emplace_back(name, ref.vid);
|
||||
} else {
|
||||
for (const auto& name : req->names()) {
|
||||
auto it = vars_.find(name);
|
||||
if (it == vars_.end())
|
||||
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
||||
"no variable named '" + name + "'");
|
||||
wanted.emplace_back(name, it->second.vid);
|
||||
}
|
||||
}
|
||||
// Mutable values live on the io thread — read them there (read_sync is
|
||||
// the standard pattern; see runtime.hpp).
|
||||
std::vector<uint32_t> vids;
|
||||
vids.reserve(wanted.size());
|
||||
for (const auto& w : wanted) vids.push_back(w.second);
|
||||
auto values = rt_.read_sync([this, vids]() {
|
||||
std::vector<std::optional<s2::Item>> out;
|
||||
out.reserve(vids.size());
|
||||
for (auto vid : vids) out.push_back(rt_.model().vid_value(vid));
|
||||
return out;
|
||||
});
|
||||
if (!values)
|
||||
return grpc::Status(grpc::StatusCode::UNAVAILABLE,
|
||||
"engine io thread did not answer (not running?)");
|
||||
for (std::size_t i = 0; i < wanted.size(); ++i) {
|
||||
if (!(*values)[i]) continue; // vid vanished — defensive, can't happen
|
||||
(*resp->mutable_values())[wanted[i].first] = from_item(*(*values)[i]);
|
||||
}
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
grpc::Status GetControlState(grpc::ServerContext*, const pb::Empty*,
|
||||
pb::ControlState* resp) override {
|
||||
// Thread-safe: control_state() reads the runtime's atomic mirror.
|
||||
|
||||
Reference in New Issue
Block a user