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:
2026-06-10 19:33:50 +02:00
parent b0a4c331cf
commit 1daf120431
5 changed files with 204 additions and 6 deletions
+26
View File
@@ -2,12 +2,15 @@
#include <asio.hpp>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
#include <future>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <type_traits>
#include <utility>
#include <vector>
@@ -72,6 +75,29 @@ class EquipmentRuntime {
void set_alarm(uint32_t alid);
void clear_alarm(uint32_t alid);
// ---- reading mutable engine state from outside the io thread -------------
// THE standard pattern for every read of mutable state (variable values,
// alarm activity, spool depth, ...) from gRPC/binding threads: post the
// read onto the io thread — the model's single owner — and wait with a
// deadline. Always truthful (no cache invalidation), and the milliseconds
// of latency are irrelevant at SECS message rates. Returns nullopt if the
// io thread doesn't service the post in time (not running, or stalled).
// Requires run()/run_async(); in poll() mode there is nobody to serve the
// post, so a same-thread caller should read the model directly instead.
template <typename Fn>
auto read_sync(Fn&& fn,
std::chrono::milliseconds timeout = std::chrono::milliseconds(2000))
-> std::optional<std::invoke_result_t<std::decay_t<Fn>&>> {
using R = std::invoke_result_t<std::decay_t<Fn>&>;
auto prom = std::make_shared<std::promise<R>>();
auto fut = prom->get_future();
asio::post(io_, [prom, fn = std::forward<Fn>(fn)]() mutable {
prom->set_value(fn());
});
if (fut.wait_for(timeout) != std::future_status::ready) return std::nullopt;
return fut.get();
}
// ---- host-command behaviour hook -----------------------------------------
void on_command(std::string rcmd, HostCommandRegistry::Handler h) {
model_->commands.set_handler(std::move(rcmd), std::move(h));