using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
///
/// Subscribes the ScanState attribute of every $WinPlatform /
/// $AppEngine object the discoverer surfaced and translates ScanState
/// value-changes into per-host updates.
/// Ports the state machine in
/// Driver.Galaxy.Host/Backend/Stability/GalaxyRuntimeProbeManager.cs onto the
/// gateway subscription path.
///
///
/// Address grammar: each platform tag's probe address is
/// {platformTagName}.ScanState. The watcher subscribes that address through
/// ; the EventPump (PR 4.4) routes inbound
/// OnDataChange events back via . State decoding:
///
/// - Quality < 192 (Good) → .
/// - Value 1, true, or "Running" → .
/// - Value 0, false, or "Stopped" → .
/// - Anything else with Good quality → .
///
/// is idempotent — call it after every
/// Discover / Rediscover. Newly-added platforms are subscribed; removed ones are
/// unsubscribed and dropped from the aggregator.
///
public sealed class PerPlatformProbeWatcher : IDisposable
{
public const string ProbeSuffix = ".ScanState";
private readonly IGalaxySubscriber _subscriber;
private readonly HostStatusAggregator _aggregator;
private readonly ILogger _logger;
private readonly int _bufferedUpdateIntervalMs;
// Tracked platform → gw item handle. Item handle 0 means the gw rejected the subscribe;
// we keep the entry so SyncPlatformsAsync doesn't try to subscribe it again on every call.
private readonly ConcurrentDictionary _itemHandlesByPlatform =
new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _syncLock = new();
private bool _disposed;
/// Initializes a new instance of the PerPlatformProbeWatcher class.
/// The Galaxy subscriber for managing probe subscriptions.
/// The host status aggregator for tracking platform connectivity.
/// Optional logger for diagnostic messages.
/// Buffered update interval in milliseconds; must be >= 0.
public PerPlatformProbeWatcher(
IGalaxySubscriber subscriber,
HostStatusAggregator aggregator,
ILogger? logger = null,
int bufferedUpdateIntervalMs = 0)
{
_subscriber = subscriber ?? throw new ArgumentNullException(nameof(subscriber));
_aggregator = aggregator ?? throw new ArgumentNullException(nameof(aggregator));
_logger = logger ?? NullLogger.Instance;
if (bufferedUpdateIntervalMs < 0)
{
throw new ArgumentOutOfRangeException(nameof(bufferedUpdateIntervalMs),
"bufferedUpdateIntervalMs must be >= 0; 0 means use the gw's default cadence.");
}
_bufferedUpdateIntervalMs = bufferedUpdateIntervalMs;
}
/// Snapshot of platform tag names currently watched.
public IReadOnlyCollection WatchedPlatforms => [.. _itemHandlesByPlatform.Keys];
///
/// Reconcile the watched platform set against .
/// Subscribes new entries, unsubscribes dropped ones. Calling with the same set is
/// a no-op.
///
/// The platform tag names to synchronize.
/// A cancellation token to cancel the operation.
/// A task that represents the asynchronous operation.
public async Task SyncPlatformsAsync(
IEnumerable platformTagNames, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(platformTagNames);
var desired = new HashSet(platformTagNames, StringComparer.OrdinalIgnoreCase);
// Compute deltas under the lock so concurrent SyncPlatformsAsync calls don't
// race on the membership view.
List toAdd;
List<(string Platform, int ItemHandle)> toRemove;
lock (_syncLock)
{
toAdd = [.. desired.Where(p => !_itemHandlesByPlatform.ContainsKey(p))];
toRemove = [.. _itemHandlesByPlatform
.Where(kvp => !desired.Contains(kvp.Key) && kvp.Value > 0)
.Select(kvp => (kvp.Key, kvp.Value))];
// Drop removed entries from the membership map up-front so a concurrent
// OnProbeValueChanged for them is silently ignored. The unsubscribe RPC
// runs outside the lock.
foreach (var (platform, _) in toRemove)
{
_itemHandlesByPlatform.TryRemove(platform, out _);
_aggregator.Remove(platform);
}
}
if (toRemove.Count > 0)
{
try
{
await _subscriber.UnsubscribeBulkAsync(
[.. toRemove.Select(t => t.ItemHandle)], cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PerPlatformProbeWatcher unsubscribe failed for {Count} probe(s); aggregator entries already cleared.",
toRemove.Count);
}
}
if (toAdd.Count == 0) return;
var probeAddresses = toAdd.Select(p => p + ProbeSuffix).ToArray();
// PR 6.3 — use the configured bufferedUpdateIntervalMs (defaults to 0 = gw cadence
// when the driver hasn't overridden MxAccess.PublishingIntervalMs). Probe ScanState
// changes are rare so a coarser interval is usually fine; deployments that need
// tighter health visibility can dial it down through GalaxyDriverOptions.
var results = await _subscriber.SubscribeBulkAsync(
probeAddresses, _bufferedUpdateIntervalMs, cancellationToken).ConfigureAwait(false);
for (var i = 0; i < toAdd.Count; i++)
{
var platform = toAdd[i];
var match = results.FirstOrDefault(r => string.Equals(
r.TagAddress, probeAddresses[i], StringComparison.OrdinalIgnoreCase));
var itemHandle = match is { WasSuccessful: true } ? match.ItemHandle : 0;
_itemHandlesByPlatform[platform] = itemHandle;
if (itemHandle <= 0)
{
_logger.LogWarning(
"PerPlatformProbeWatcher subscribe failed for {Platform}: {Error}",
platform, match?.ErrorMessage ?? "");
}
}
}
///
/// Route an OnDataChange for a probe address into the aggregator. The EventPump
/// (PR 4.4) calls this; tests can drive it directly to exercise the state machine
/// without spinning a real gw. Foreign references (anything not ending in
/// , or a probe for a platform we're not tracking) are
/// silently dropped.
///
/// The full reference path of the probe attribute.
/// The probe value to decode.
/// The quality byte for the value.
public void OnProbeValueChanged(string fullReference, object? value, byte qualityByte)
{
if (_disposed) return;
ArgumentNullException.ThrowIfNull(fullReference);
if (!fullReference.EndsWith(ProbeSuffix, StringComparison.OrdinalIgnoreCase)) return;
var platform = fullReference[..^ProbeSuffix.Length];
if (!_itemHandlesByPlatform.ContainsKey(platform)) return;
var state = DecodeState(value, qualityByte);
_aggregator.Update(new HostConnectivityStatus(platform, state, DateTime.UtcNow));
}
///
/// Decode a ScanState value + raw quality byte to a .
/// Public for tests that want to pin the decoding table.
///
/// The probe value to decode.
/// The quality byte for the value.
/// The decoded host state.
public static HostState DecodeState(object? value, byte qualityByte)
{
if (qualityByte < 192) return HostState.Unknown;
return value switch
{
bool b => b ? HostState.Running : HostState.Stopped,
int i => i == 1 ? HostState.Running : i == 0 ? HostState.Stopped : HostState.Faulted,
short s => s == 1 ? HostState.Running : s == 0 ? HostState.Stopped : HostState.Faulted,
long l => l == 1 ? HostState.Running : l == 0 ? HostState.Stopped : HostState.Faulted,
string str when string.Equals(str, "Running", StringComparison.OrdinalIgnoreCase) => HostState.Running,
string str when string.Equals(str, "Stopped", StringComparison.OrdinalIgnoreCase) => HostState.Stopped,
_ => HostState.Faulted,
};
}
/// Disposes the probe watcher and unsubscribes all tracked platforms.
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// Best-effort unsubscribe everything we know about. Run synchronously through
// GetAwaiter().GetResult() since Dispose is sync; transport errors are swallowed.
var liveHandles = _itemHandlesByPlatform.Values.Where(h => h > 0).ToArray();
_itemHandlesByPlatform.Clear();
if (liveHandles.Length > 0)
{
try { _subscriber.UnsubscribeBulkAsync(liveHandles, CancellationToken.None).GetAwaiter().GetResult(); }
catch (Exception ex) { _logger.LogWarning(ex, "PerPlatformProbeWatcher dispose unsubscribe failed"); }
}
}
}