Expose per-host runtime status as synthetic OPC UA variables so clients can observe Platform/Engine ScanState transitions without the status dashboard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-13 17:07:16 -04:00
parent 0003984c1a
commit 4b209f64bb
2 changed files with 130 additions and 0 deletions

View File

@@ -120,6 +120,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
return false;
}
/// <summary>
/// Returns a point-in-time clone of the runtime status for the host identified by
/// <paramref name="gobjectId"/>, or <see langword="null"/> when no probe is registered
/// for that object. Used by the node manager to populate the synthetic <c>$RuntimeState</c>
/// child variables on each host object. Uses the underlying state directly (not the
/// transport-gated rewrite), matching <see cref="IsHostStopped"/>.
/// </summary>
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;
}
/// <summary>
/// Diffs the supplied hierarchy against the active probe set, advising new hosts and
/// unadvising removed ones. The hierarchy is filtered to runtime host categories

View File

@@ -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<int, HostRuntimeStatusNodes> _runtimeStatusNodes =
new Dictionary<int, HostRuntimeStatusNodes>();
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<int, List<string>> _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);
}
/// <summary>
/// Creates the six <c>$</c>-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 <see cref="UpdateHostRuntimeStatusNodes"/>
/// from the dispatch-thread queue drain whenever the host transitions.
/// </summary>
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;
}
/// <summary>
/// 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 <see cref="Lock"/> internally.
/// </summary>
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);
}
/// <summary>
/// Resets every OPC UA variable hosted by the given Galaxy runtime object to
/// <see cref="StatusCodes.Good"/>. 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();