diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs
index 9ab603e..c4a597c 100644
--- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs
+++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs
@@ -120,6 +120,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return false;
}
+ ///
+ /// Returns a point-in-time clone of the runtime status for the host identified by
+ /// , or when no probe is registered
+ /// for that object. Used by the node manager to populate the synthetic $RuntimeState
+ /// child variables on each host object. Uses the underlying state directly (not the
+ /// transport-gated rewrite), matching .
+ ///
+ public GalaxyRuntimeStatus? GetHostStatus(int gobjectId)
+ {
+ lock (_lock)
+ {
+ if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
+ && _byProbe.TryGetValue(probe, out var status))
+ {
+ return Clone(status, forceUnknown: false);
+ }
+ }
+ return null;
+ }
+
///
/// Diffs the supplied hierarchy against the active probe set, advising new hosts and
/// unadvising removed ones. The hierarchy is filtered to runtime host categories
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
index dc9e46a..6b704b7 100644
--- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
+++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
@@ -62,6 +62,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
// worker thread currently inside Read waiting for an MxAccess round-trip.
private readonly ConcurrentQueue<(int GobjectId, bool Stopped)> _pendingHostStateChanges =
new ConcurrentQueue<(int, bool)>();
+
+ // Synthetic $-prefixed OPC UA child variables exposed under each $WinPlatform / $AppEngine
+ // object so clients can subscribe to runtime state changes without polling the dashboard.
+ // Populated during BuildAddressSpace and updated from the dispatch-thread queue drain
+ // alongside Mark/Clear, using the same deadlock-safe path.
+ private readonly Dictionary _runtimeStatusNodes =
+ new Dictionary();
+
+ private sealed class HostRuntimeStatusNodes
+ {
+ public BaseDataVariableState RuntimeState = null!;
+ public BaseDataVariableState LastCallbackTime = null!;
+ public BaseDataVariableState LastScanState = null!;
+ public BaseDataVariableState LastStateChangeTime = null!;
+ public BaseDataVariableState FailureCount = null!;
+ public BaseDataVariableState LastError = null!;
+ }
private readonly AutoResetEvent _dataChangeSignal = new(false);
private readonly Dictionary> _gobjectToTagRefs = new();
private readonly HistoryContinuationPointManager _historyContinuations = new();
@@ -330,6 +347,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_nodeMap.Clear();
_gobjectToTagRefs.Clear();
_hostedVariables.Clear();
+ _hostIdsByTagRef.Clear();
+ _runtimeStatusNodes.Clear();
VariableNodeCount = 0;
ObjectNodeCount = 0;
@@ -387,6 +406,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
AddPredefinedNode(SystemContext, node);
_nodeMap[obj.GobjectId] = node;
+ // Attach bridge-owned $RuntimeState / $LastCallbackTime / ... synthetic child
+ // variables so OPC UA clients can subscribe to host state changes without
+ // polling the dashboard. Only $WinPlatform (1) and $AppEngine (3) get them.
+ if (_galaxyRuntimeProbeManager != null
+ && (obj.CategoryId == 1 || obj.CategoryId == 3)
+ && node is BaseObjectState hostObj)
+ {
+ _runtimeStatusNodes[obj.GobjectId] =
+ CreateHostRuntimeStatusNodes(hostObj, obj.TagName);
+ }
+
// Create variable nodes for this object's attributes
if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs))
{
@@ -675,6 +705,82 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
variables.Count, gobjectId);
}
+ ///
+ /// Creates the six $-prefixed synthetic child variables on a host object so OPC UA
+ /// clients can subscribe to runtime state changes without polling the dashboard. All
+ /// nodes are read-only and their values are refreshed by
+ /// from the dispatch-thread queue drain whenever the host transitions.
+ ///
+ private HostRuntimeStatusNodes CreateHostRuntimeStatusNodes(BaseObjectState hostNode, string hostTagName)
+ {
+ var nodes = new HostRuntimeStatusNodes
+ {
+ RuntimeState = CreateSyntheticVariable(hostNode, hostTagName, "$RuntimeState", DataTypeIds.String, "Unknown"),
+ LastCallbackTime = CreateSyntheticVariable(hostNode, hostTagName, "$LastCallbackTime", DataTypeIds.DateTime, DateTime.MinValue),
+ LastScanState = CreateSyntheticVariable(hostNode, hostTagName, "$LastScanState", DataTypeIds.Boolean, false),
+ LastStateChangeTime = CreateSyntheticVariable(hostNode, hostTagName, "$LastStateChangeTime", DataTypeIds.DateTime, DateTime.MinValue),
+ FailureCount = CreateSyntheticVariable(hostNode, hostTagName, "$FailureCount", DataTypeIds.Int64, 0L),
+ LastError = CreateSyntheticVariable(hostNode, hostTagName, "$LastError", DataTypeIds.String, "")
+ };
+ return nodes;
+ }
+
+ private BaseDataVariableState CreateSyntheticVariable(
+ BaseObjectState parent, string parentTagName, string browseName, NodeId dataType, object initialValue)
+ {
+ var v = CreateVariable(parent, browseName, browseName, dataType, ValueRanks.Scalar);
+ v.NodeId = new NodeId(parentTagName + "." + browseName, NamespaceIndex);
+ v.Value = initialValue;
+ v.StatusCode = StatusCodes.Good;
+ v.Timestamp = DateTime.UtcNow;
+ v.AccessLevel = AccessLevels.CurrentRead;
+ v.UserAccessLevel = AccessLevels.CurrentRead;
+ AddPredefinedNode(SystemContext, v);
+ return v;
+ }
+
+ ///
+ /// Refreshes the six synthetic child variables on a host from the probe manager's
+ /// current snapshot for that host. Called from the dispatch-thread queue drain after
+ /// Mark/Clear so the state values propagate to subscribed clients in the same publish
+ /// cycle. Takes the node manager internally.
+ ///
+ private void UpdateHostRuntimeStatusNodes(int gobjectId)
+ {
+ if (_galaxyRuntimeProbeManager == null)
+ return;
+
+ HostRuntimeStatusNodes? nodes;
+ lock (Lock)
+ {
+ if (!_runtimeStatusNodes.TryGetValue(gobjectId, out nodes))
+ return;
+ }
+
+ var status = _galaxyRuntimeProbeManager.GetHostStatus(gobjectId);
+ if (status == null)
+ return;
+
+ lock (Lock)
+ {
+ var now = DateTime.UtcNow;
+ SetSynthetic(nodes.RuntimeState, status.State.ToString(), now);
+ SetSynthetic(nodes.LastCallbackTime, status.LastStateCallbackTime ?? DateTime.MinValue, now);
+ SetSynthetic(nodes.LastScanState, status.LastScanState ?? false, now);
+ SetSynthetic(nodes.LastStateChangeTime, status.LastStateChangeTime ?? DateTime.MinValue, now);
+ SetSynthetic(nodes.FailureCount, status.FailureCount, now);
+ SetSynthetic(nodes.LastError, status.LastError ?? "", now);
+ }
+ }
+
+ private void SetSynthetic(BaseDataVariableState variable, object value, DateTime now)
+ {
+ variable.Value = value;
+ variable.StatusCode = StatusCodes.Good;
+ variable.Timestamp = now;
+ variable.ClearChangeMasks(SystemContext, false);
+ }
+
///
/// Resets every OPC UA variable hosted by the given Galaxy runtime object to
/// . Invoked by the runtime probe manager's Stopped → Running
@@ -2423,6 +2529,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
MarkHostVariablesBadQuality(transition.GobjectId);
else
ClearHostVariablesBadQuality(transition.GobjectId);
+
+ // Also refresh the synthetic $RuntimeState child nodes on this host so
+ // subscribed OPC UA clients see the state change in the same publish cycle.
+ UpdateHostRuntimeStatusNodes(transition.GobjectId);
}
var keys = _pendingDataChanges.Keys.ToList();