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();