Compare commits
15 Commits
phase-2-pr
...
phase-2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04d267d1ea | ||
| 4448db8207 | |||
| d96b513bbc | |||
| 053c4e0566 | |||
|
|
f24f969a85 | ||
|
|
ca025ebe0c | ||
|
|
d13f919112 | ||
| d2ebb91cb1 | |||
| 90ce0af375 | |||
| e250356e2a | |||
| 067ad78e06 | |||
| 6cfa8d326d | |||
|
|
70a5d06b37 | ||
|
|
3717405aa6 | ||
|
|
1c2bf74d38 |
@@ -19,10 +19,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <param name="ArrayDim">Declared array length when <see cref="IsArray"/> is true; null otherwise.</param>
|
/// <param name="ArrayDim">Declared array length when <see cref="IsArray"/> is true; null otherwise.</param>
|
||||||
/// <param name="SecurityClass">Write-authorization tier for this attribute.</param>
|
/// <param name="SecurityClass">Write-authorization tier for this attribute.</param>
|
||||||
/// <param name="IsHistorized">True when this attribute is expected to feed historian / HistoryRead.</param>
|
/// <param name="IsHistorized">True when this attribute is expected to feed historian / HistoryRead.</param>
|
||||||
|
/// <param name="IsAlarm">
|
||||||
|
/// True when this attribute represents an alarm condition (Galaxy: has an
|
||||||
|
/// <c>AlarmExtension</c> primitive). The generic node-manager enriches the variable with an
|
||||||
|
/// OPC UA <c>AlarmConditionState</c> when true. Defaults to false so existing non-Galaxy
|
||||||
|
/// drivers aren't forced to flow a flag they don't produce.
|
||||||
|
/// </param>
|
||||||
public sealed record DriverAttributeInfo(
|
public sealed record DriverAttributeInfo(
|
||||||
string FullName,
|
string FullName,
|
||||||
DriverDataType DriverDataType,
|
DriverDataType DriverDataType,
|
||||||
bool IsArray,
|
bool IsArray,
|
||||||
uint? ArrayDim,
|
uint? ArrayDim,
|
||||||
SecurityClassification SecurityClass,
|
SecurityClassification SecurityClass,
|
||||||
bool IsHistorized);
|
bool IsHistorized,
|
||||||
|
bool IsAlarm = false);
|
||||||
|
|||||||
@@ -127,6 +127,33 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy
|
|||||||
Tags = System.Array.Empty<HistoryTagValues>(),
|
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||||
|
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new HistoryReadProcessedResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||||
|
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||||
|
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new HistoryReadAtTimeResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||||
|
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||||
|
HistoryReadEventsRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new HistoryReadEventsResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||||
|
Events = System.Array.Empty<GalaxyHistoricalEvent>(),
|
||||||
|
});
|
||||||
|
|
||||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||||
|
|
||||||
@@ -138,6 +165,7 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy
|
|||||||
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||||
SecurityClassification = row.SecurityClassification,
|
SecurityClassification = row.SecurityClassification,
|
||||||
IsHistorized = row.IsHistorized,
|
IsHistorized = row.IsHistorized,
|
||||||
|
IsAlarm = row.IsAlarm,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
|
||||||
|
/// to an OPC UA <c>StatusCode</c> uint. Preserves specific codes (BadNotConnected,
|
||||||
|
/// UncertainSubNormal, etc.) instead of collapsing to Good/Uncertain/Bad categories.
|
||||||
|
/// Mirrors v1 <c>QualityMapper.MapToOpcUaStatusCode</c> without pulling in OPC UA types —
|
||||||
|
/// the returned value is the 32-bit OPC UA <c>StatusCode</c> wire encoding that the Proxy
|
||||||
|
/// surfaces directly as <c>DataValueSnapshot.StatusCode</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class HistorianQualityMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Map an 8-bit OPC DA quality byte to the corresponding OPC UA StatusCode. The byte
|
||||||
|
/// family bits decide the category (Good >= 192, Uncertain 64-191, Bad 0-63); the
|
||||||
|
/// low-nibble subcode selects the specific code.
|
||||||
|
/// </summary>
|
||||||
|
public static uint Map(byte q) => q switch
|
||||||
|
{
|
||||||
|
// Good family (192+)
|
||||||
|
192 => 0x00000000u, // Good
|
||||||
|
216 => 0x00D80000u, // Good_LocalOverride
|
||||||
|
|
||||||
|
// Uncertain family (64-191)
|
||||||
|
64 => 0x40000000u, // Uncertain
|
||||||
|
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||||
|
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||||
|
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||||
|
88 => 0x40950000u, // Uncertain_SubNormal
|
||||||
|
|
||||||
|
// Bad family (0-63)
|
||||||
|
0 => 0x80000000u, // Bad
|
||||||
|
4 => 0x80890000u, // Bad_ConfigurationError
|
||||||
|
8 => 0x808A0000u, // Bad_NotConnected
|
||||||
|
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||||
|
16 => 0x808C0000u, // Bad_SensorFailure
|
||||||
|
20 => 0x80050000u, // Bad_CommunicationError
|
||||||
|
24 => 0x808D0000u, // Bad_OutOfService
|
||||||
|
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||||
|
|
||||||
|
// Unknown code — fall back to the category so callers still get a sensible bucket.
|
||||||
|
_ when q >= 192 => 0x00000000u,
|
||||||
|
_ when q >= 64 => 0x40000000u,
|
||||||
|
_ => 0x80000000u,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ public interface IGalaxyBackend
|
|||||||
Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct);
|
Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct);
|
||||||
|
|
||||||
Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct);
|
Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct);
|
||||||
|
Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct);
|
||||||
|
Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(HistoryReadAtTimeRequest req, CancellationToken ct);
|
||||||
|
Task<HistoryReadEventsResponse> HistoryReadEventsAsync(HistoryReadEventsRequest req, CancellationToken ct);
|
||||||
|
|
||||||
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ArchestrA.MxAccess;
|
using ArchestrA.MxAccess;
|
||||||
|
using Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
@@ -18,6 +19,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MxAccessClient : IDisposable
|
public sealed class MxAccessClient : IDisposable
|
||||||
{
|
{
|
||||||
|
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
||||||
|
|
||||||
private readonly StaPump _pump;
|
private readonly StaPump _pump;
|
||||||
private readonly IMxProxy _proxy;
|
private readonly IMxProxy _proxy;
|
||||||
private readonly string _clientName;
|
private readonly string _clientName;
|
||||||
@@ -40,6 +43,16 @@ public sealed class MxAccessClient : IDisposable
|
|||||||
/// <summary>Fires whenever the connection transitions Connected ↔ Disconnected.</summary>
|
/// <summary>Fires whenever the connection transitions Connected ↔ Disconnected.</summary>
|
||||||
public event EventHandler<bool>? ConnectionStateChanged;
|
public event EventHandler<bool>? ConnectionStateChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires once per failed subscription replay after a reconnect. Carries the tag reference
|
||||||
|
/// and the exception so the backend can propagate the degradation signal (e.g. mark the
|
||||||
|
/// subscription bad on the Proxy side rather than silently losing its callback). Added for
|
||||||
|
/// PR 6 low finding #2 — the replay loop previously ate per-tag failures silently and an
|
||||||
|
/// operator would only find out that a specific subscription stopped updating through a
|
||||||
|
/// data-quality complaint from downstream.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<SubscriptionReplayFailedEventArgs>? SubscriptionReplayFailed;
|
||||||
|
|
||||||
public MxAccessClient(StaPump pump, IMxProxy proxy, string clientName, MxAccessClientOptions? options = null)
|
public MxAccessClient(StaPump pump, IMxProxy proxy, string clientName, MxAccessClientOptions? options = null)
|
||||||
{
|
{
|
||||||
_pump = pump;
|
_pump = pump;
|
||||||
@@ -124,16 +137,29 @@ public sealed class MxAccessClient : IDisposable
|
|||||||
if (idle <= _options.StaleThreshold) continue;
|
if (idle <= _options.StaleThreshold) continue;
|
||||||
|
|
||||||
// Probe: try a no-op COM call. If the proxy is dead, the call will throw — that's
|
// Probe: try a no-op COM call. If the proxy is dead, the call will throw — that's
|
||||||
// our reconnect signal.
|
// our reconnect signal. PR 6 low finding #1: AddItem allocates an MXAccess item
|
||||||
|
// handle; we must RemoveItem it on the same pump turn or the long-running monitor
|
||||||
|
// leaks one handle per probe cycle (one every MonitorInterval seconds, indefinitely).
|
||||||
bool probeOk;
|
bool probeOk;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
probeOk = await _pump.InvokeAsync(() =>
|
probeOk = await _pump.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
// AddItem on the connection handle is cheap and round-trips through COM.
|
int probeHandle = 0;
|
||||||
// We use a sentinel "$Heartbeat" reference; if it fails the connection is gone.
|
try
|
||||||
try { _proxy.AddItem(_connectionHandle, "$Heartbeat"); return true; }
|
{
|
||||||
|
probeHandle = _proxy.AddItem(_connectionHandle, "$Heartbeat");
|
||||||
|
return probeHandle > 0;
|
||||||
|
}
|
||||||
catch { return false; }
|
catch { return false; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (probeHandle > 0)
|
||||||
|
{
|
||||||
|
try { _proxy.RemoveItem(_connectionHandle, probeHandle); }
|
||||||
|
catch { /* proxy is dying; best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch { probeOk = false; }
|
catch { probeOk = false; }
|
||||||
@@ -162,16 +188,33 @@ public sealed class MxAccessClient : IDisposable
|
|||||||
_reconnectCount++;
|
_reconnectCount++;
|
||||||
ConnectionStateChanged?.Invoke(this, true);
|
ConnectionStateChanged?.Invoke(this, true);
|
||||||
|
|
||||||
// Replay every subscription that was active before the disconnect.
|
// Replay every subscription that was active before the disconnect. PR 6 low
|
||||||
|
// finding #2: surface per-tag failures — log them and raise
|
||||||
|
// SubscriptionReplayFailed so the backend can propagate the degraded state
|
||||||
|
// (previously swallowed silently; downstream quality dropped without a signal).
|
||||||
var snapshot = _addressToHandle.Keys.ToArray();
|
var snapshot = _addressToHandle.Keys.ToArray();
|
||||||
_addressToHandle.Clear();
|
_addressToHandle.Clear();
|
||||||
_handleToAddress.Clear();
|
_handleToAddress.Clear();
|
||||||
|
var failed = 0;
|
||||||
foreach (var fullRef in snapshot)
|
foreach (var fullRef in snapshot)
|
||||||
{
|
{
|
||||||
try { await SubscribeOnPumpAsync(fullRef); }
|
try { await SubscribeOnPumpAsync(fullRef); }
|
||||||
catch { /* skip — operator can re-subscribe */ }
|
catch (Exception subEx)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
Log.Warning(subEx,
|
||||||
|
"MXAccess subscription replay failed for {TagReference} after reconnect #{Reconnect}",
|
||||||
|
fullRef, _reconnectCount);
|
||||||
|
SubscriptionReplayFailed?.Invoke(this,
|
||||||
|
new SubscriptionReplayFailedEventArgs(fullRef, subEx));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (failed > 0)
|
||||||
|
Log.Warning("Subscription replay completed — {Failed} of {Total} failed", failed, snapshot.Length);
|
||||||
|
else
|
||||||
|
Log.Information("Subscription replay completed — {Total} re-subscribed cleanly", snapshot.Length);
|
||||||
|
|
||||||
_lastObservedActivityUtc = DateTime.UtcNow;
|
_lastObservedActivityUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired by <see cref="MxAccessClient.SubscriptionReplayFailed"/> when a previously-active
|
||||||
|
/// subscription fails to be restored after a reconnect. The backend should treat the tag as
|
||||||
|
/// unhealthy until the next successful resubscribe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SubscriptionReplayFailedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public SubscriptionReplayFailedEventArgs(string tagReference, Exception exception)
|
||||||
|
{
|
||||||
|
TagReference = tagReference;
|
||||||
|
Exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TagReference { get; }
|
||||||
|
public Exception Exception { get; }
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using MessagePack;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||||
@@ -40,6 +41,8 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||||
|
|
||||||
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
||||||
|
private readonly GalaxyRuntimeProbeManager _probeManager;
|
||||||
|
private readonly System.EventHandler<HostStateTransition> _onProbeStateChanged;
|
||||||
|
|
||||||
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
||||||
{
|
{
|
||||||
@@ -62,8 +65,39 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
_mx.ConnectionStateChanged += _onConnectionStateChanged;
|
_mx.ConnectionStateChanged += _onConnectionStateChanged;
|
||||||
|
|
||||||
|
// PR 13: per-platform runtime probes. ScanState subscriptions fire OnProbeCallback,
|
||||||
|
// which runs the state machine and raises StateChanged on transitions we care about.
|
||||||
|
// We forward each transition through the same OnHostStatusChanged IPC event that the
|
||||||
|
// gateway-level ConnectionStateChanged uses — tagged with the platform's TagName so the
|
||||||
|
// Admin UI can show per-host health independently from the top-level transport status.
|
||||||
|
_probeManager = new GalaxyRuntimeProbeManager(
|
||||||
|
subscribe: (probe, cb) => _mx.SubscribeAsync(probe, cb),
|
||||||
|
unsubscribe: probe => _mx.UnsubscribeAsync(probe));
|
||||||
|
_onProbeStateChanged = (_, t) =>
|
||||||
|
{
|
||||||
|
OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
|
||||||
|
{
|
||||||
|
HostName = t.TagName,
|
||||||
|
RuntimeStatus = t.NewState switch
|
||||||
|
{
|
||||||
|
HostRuntimeState.Running => "Running",
|
||||||
|
HostRuntimeState.Stopped => "Stopped",
|
||||||
|
_ => "Unknown",
|
||||||
|
},
|
||||||
|
LastObservedUtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
_probeManager.StateChanged += _onProbeStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposed for tests. Production flow: DiscoverAsync completes → backend calls
|
||||||
|
/// <c>SyncProbesAsync</c> with the runtime hosts (WinPlatform + AppEngine gobjects) to
|
||||||
|
/// advise ScanState per host.
|
||||||
|
/// </summary>
|
||||||
|
internal GalaxyRuntimeProbeManager ProbeManager => _probeManager;
|
||||||
|
|
||||||
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -103,6 +137,21 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
|
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
|
// PR 13: Sync the per-platform probe manager against the just-discovered hierarchy
|
||||||
|
// so ScanState subscriptions track the current runtime set. Best-effort — probe
|
||||||
|
// failures don't block Discover from returning, since the gateway-level signal from
|
||||||
|
// MxAccessClient.ConnectionStateChanged still flows and the Admin UI degrades to
|
||||||
|
// that level if any per-host probe couldn't advise.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targets = hierarchy
|
||||||
|
.Where(o => o.CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||||
|
|| o.CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine)
|
||||||
|
.Select(o => new HostProbeTarget(o.TagName, o.CategoryId));
|
||||||
|
await _probeManager.SyncAsync(targets).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch { /* swallow — Discover succeeded; probes are a diagnostic enrichment */ }
|
||||||
|
|
||||||
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -282,11 +331,131 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||||
|
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_historian is null)
|
||||||
|
return new HistoryReadProcessedResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||||
|
Values = Array.Empty<GalaxyDataValue>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.IntervalMs <= 0)
|
||||||
|
return new HistoryReadProcessedResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "HistoryReadProcessed requires IntervalMs > 0",
|
||||||
|
Values = Array.Empty<GalaxyDataValue>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||||
|
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var samples = await _historian.ReadAggregateAsync(
|
||||||
|
req.TagReference, start, end, req.IntervalMs, req.AggregateColumn, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray();
|
||||||
|
return new HistoryReadProcessedResponse { Success = true, Values = wire };
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HistoryReadProcessedResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = $"Historian aggregate read failed: {ex.Message}",
|
||||||
|
Values = Array.Empty<GalaxyDataValue>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||||
|
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_historian is null)
|
||||||
|
return new HistoryReadAtTimeResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||||
|
Values = Array.Empty<GalaxyDataValue>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.TimestampsUtcUnixMs.Length == 0)
|
||||||
|
return new HistoryReadAtTimeResponse { Success = true, Values = Array.Empty<GalaxyDataValue>() };
|
||||||
|
|
||||||
|
var timestamps = req.TimestampsUtcUnixMs
|
||||||
|
.Select(ms => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var samples = await _historian.ReadAtTimeAsync(req.TagReference, timestamps, ct).ConfigureAwait(false);
|
||||||
|
var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray();
|
||||||
|
return new HistoryReadAtTimeResponse { Success = true, Values = wire };
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HistoryReadAtTimeResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = $"Historian at-time read failed: {ex.Message}",
|
||||||
|
Values = Array.Empty<GalaxyDataValue>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||||
|
HistoryReadEventsRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_historian is null)
|
||||||
|
return new HistoryReadEventsResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||||
|
Events = Array.Empty<GalaxyHistoricalEvent>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||||
|
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var events = await _historian.ReadEventsAsync(req.SourceName, start, end, req.MaxEvents, ct).ConfigureAwait(false);
|
||||||
|
var wire = events.Select(e => new GalaxyHistoricalEvent
|
||||||
|
{
|
||||||
|
EventId = e.Id.ToString(),
|
||||||
|
SourceName = e.Source,
|
||||||
|
EventTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.EventTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
ReceivedTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.ReceivedTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
DisplayText = e.DisplayText,
|
||||||
|
Severity = e.Severity,
|
||||||
|
}).ToArray();
|
||||||
|
return new HistoryReadEventsResponse { Success = true, Events = wire };
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HistoryReadEventsResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = $"Historian event read failed: {ex.Message}",
|
||||||
|
Events = Array.Empty<GalaxyHistoricalEvent>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_probeManager.StateChanged -= _onProbeStateChanged;
|
||||||
|
_probeManager.Dispose();
|
||||||
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
||||||
_historian?.Dispose();
|
_historian?.Dispose();
|
||||||
}
|
}
|
||||||
@@ -313,19 +482,26 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
TagReference = reference,
|
TagReference = reference,
|
||||||
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value),
|
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value),
|
||||||
ValueMessagePackType = 0,
|
ValueMessagePackType = 0,
|
||||||
StatusCode = MapHistorianQualityToOpcUa(sample.Quality),
|
StatusCode = HistorianQualityMapper.Map(sample.Quality),
|
||||||
SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static uint MapHistorianQualityToOpcUa(byte q)
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a <see cref="HistorianAggregateSample"/> (one aggregate bucket) to the IPC wire
|
||||||
|
/// shape. A null <see cref="HistorianAggregateSample.Value"/> means the aggregate was
|
||||||
|
/// unavailable for the bucket — the Proxy translates that to OPC UA <c>BadNoData</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static GalaxyDataValue ToWire(string reference, HistorianAggregateSample sample) => new()
|
||||||
{
|
{
|
||||||
// Category-only mapping — mirrors QualityMapper.MapToOpcUaStatusCode for the common ranges.
|
TagReference = reference,
|
||||||
// The Proxy may refine this when it decodes the wire frame.
|
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value.Value),
|
||||||
if (q >= 192) return 0x00000000u; // Good
|
ValueMessagePackType = 0,
|
||||||
if (q >= 64) return 0x40000000u; // Uncertain
|
StatusCode = sample.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u,
|
||||||
return 0x80000000u; // Bad
|
SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
}
|
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
};
|
||||||
|
|
||||||
private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
|
private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
|
||||||
{
|
{
|
||||||
@@ -335,6 +511,7 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||||
SecurityClassification = row.SecurityClassification,
|
SecurityClassification = row.SecurityClassification,
|
||||||
IsHistorized = row.IsHistorized,
|
IsHistorized = row.IsHistorized,
|
||||||
|
IsAlarm = row.IsAlarm,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string MapCategory(int categoryId) => categoryId switch
|
private static string MapCategory(int categoryId) => categoryId switch
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-platform + per-AppEngine runtime probe. Subscribes to <c><TagName>.ScanState</c>
|
||||||
|
/// for each $WinPlatform and $AppEngine gobject, tracks Unknown → Running → Stopped
|
||||||
|
/// transitions, and fires <see cref="StateChanged"/> so <see cref="Backend.MxAccessGalaxyBackend"/>
|
||||||
|
/// can forward per-host events through the existing IPC <c>OnHostStatusChanged</c> event.
|
||||||
|
/// Pure-logic state machine with an injected clock so it's deterministically testable —
|
||||||
|
/// port of v1 <c>GalaxyRuntimeProbeManager</c> without the OPC UA node-manager coupling.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// State machine rules (documented in v1's <c>runtimestatus.md</c> and preserved here):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>ScanState</c> is on-change-only — a stably-Running host may go hours without a
|
||||||
|
/// callback. Running → Stopped is driven by an explicit <c>ScanState=false</c> callback,
|
||||||
|
/// never by starvation.</item>
|
||||||
|
/// <item>Unknown → Running is a startup transition and does NOT fire StateChanged (would
|
||||||
|
/// paint every host as "just recovered" at startup, which is noise).</item>
|
||||||
|
/// <item>Stopped → Running and Running → Stopped fire StateChanged. Unknown → Stopped
|
||||||
|
/// fires StateChanged because that's a first-known-bad signal operators need.</item>
|
||||||
|
/// <item>All public methods are thread-safe. Callbacks fire outside the internal lock to
|
||||||
|
/// avoid lock inversion with caller-owned state.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class GalaxyRuntimeProbeManager : IDisposable
|
||||||
|
{
|
||||||
|
public const int CategoryWinPlatform = 1;
|
||||||
|
public const int CategoryAppEngine = 3;
|
||||||
|
public const string ProbeAttribute = ".ScanState";
|
||||||
|
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
||||||
|
private readonly Func<string, Task> _unsubscribe;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
// probe tag → per-host state
|
||||||
|
private readonly Dictionary<string, HostProbeState> _byProbe = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
// tag name → probe tag (for reverse lookup on the desired-set diff)
|
||||||
|
private readonly Dictionary<string, string> _probeByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires on every state transition that operators should react to. See class remarks
|
||||||
|
/// for the rules on which transitions fire.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<HostStateTransition>? StateChanged;
|
||||||
|
|
||||||
|
public GalaxyRuntimeProbeManager(
|
||||||
|
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||||
|
Func<string, Task> unsubscribe)
|
||||||
|
: this(subscribe, unsubscribe, () => DateTime.UtcNow) { }
|
||||||
|
|
||||||
|
internal GalaxyRuntimeProbeManager(
|
||||||
|
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||||
|
Func<string, Task> unsubscribe,
|
||||||
|
Func<DateTime> clock)
|
||||||
|
{
|
||||||
|
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
||||||
|
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of probes currently advised. Test/dashboard hook.</summary>
|
||||||
|
public int ActiveProbeCount
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _byProbe.Count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot every currently-tracked host's state. One entry per probe.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<HostProbeSnapshot> SnapshotStates()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _byProbe.Select(kv => new HostProbeSnapshot(
|
||||||
|
TagName: kv.Value.TagName,
|
||||||
|
State: kv.Value.State,
|
||||||
|
LastChangedUtc: kv.Value.LastStateChangeUtc)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query the current runtime state for <paramref name="tagName"/>. Returns
|
||||||
|
/// <see cref="HostRuntimeState.Unknown"/> when the host is not tracked.
|
||||||
|
/// </summary>
|
||||||
|
public HostRuntimeState GetState(string tagName)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_probeByTagName.TryGetValue(tagName, out var probe)
|
||||||
|
&& _byProbe.TryGetValue(probe, out var state))
|
||||||
|
return state.State;
|
||||||
|
return HostRuntimeState.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diff the desired host set (filtered $WinPlatform / $AppEngine from the latest Discover)
|
||||||
|
/// against the currently-tracked set and advise / unadvise as needed. Idempotent:
|
||||||
|
/// calling twice with the same set does nothing.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SyncAsync(IEnumerable<HostProbeTarget> desiredHosts)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
var desired = desiredHosts
|
||||||
|
.Where(h => !string.IsNullOrWhiteSpace(h.TagName))
|
||||||
|
.ToDictionary(h => h.TagName, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
List<string> toAdvise;
|
||||||
|
List<string> toUnadvise;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
toAdvise = desired.Keys
|
||||||
|
.Where(tag => !_probeByTagName.ContainsKey(tag))
|
||||||
|
.ToList();
|
||||||
|
toUnadvise = _probeByTagName.Keys
|
||||||
|
.Where(tag => !desired.ContainsKey(tag))
|
||||||
|
.Select(tag => _probeByTagName[tag])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var tag in toAdvise)
|
||||||
|
{
|
||||||
|
var probe = tag + ProbeAttribute;
|
||||||
|
_probeByTagName[tag] = probe;
|
||||||
|
_byProbe[probe] = new HostProbeState
|
||||||
|
{
|
||||||
|
TagName = tag,
|
||||||
|
State = HostRuntimeState.Unknown,
|
||||||
|
LastStateChangeUtc = _clock(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var probe in toUnadvise)
|
||||||
|
{
|
||||||
|
_byProbe.Remove(probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var removedTag in _probeByTagName.Keys.Where(t => !desired.ContainsKey(t)).ToList())
|
||||||
|
{
|
||||||
|
_probeByTagName.Remove(removedTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tag in toAdvise)
|
||||||
|
{
|
||||||
|
var probe = tag + ProbeAttribute;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _subscribe(probe, OnProbeCallback);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Rollback on subscribe failure so a later Tick can't transition a never-advised
|
||||||
|
// probe into a false Stopped state. Callers can re-Sync later to retry.
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_byProbe.Remove(probe);
|
||||||
|
_probeByTagName.Remove(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var probe in toUnadvise)
|
||||||
|
{
|
||||||
|
try { await _unsubscribe(probe); } catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public entry point for tests and internal callbacks. Production flow: MxAccessClient's
|
||||||
|
/// SubscribeAsync delivers VTQ updates through the callback wired in <see cref="SyncAsync"/>,
|
||||||
|
/// which calls this method under the lock to update state and fires
|
||||||
|
/// <see cref="StateChanged"/> outside the lock for any transition that matters.
|
||||||
|
/// </summary>
|
||||||
|
public void OnProbeCallback(string probeTag, Vtq vtq)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
HostStateTransition? transition = null;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_byProbe.TryGetValue(probeTag, out var state)) return;
|
||||||
|
|
||||||
|
var isRunning = vtq.Quality >= 192 && vtq.Value is bool b && b;
|
||||||
|
var now = _clock();
|
||||||
|
var previous = state.State;
|
||||||
|
state.LastCallbackUtc = now;
|
||||||
|
|
||||||
|
if (isRunning)
|
||||||
|
{
|
||||||
|
state.GoodUpdateCount++;
|
||||||
|
if (previous != HostRuntimeState.Running)
|
||||||
|
{
|
||||||
|
state.State = HostRuntimeState.Running;
|
||||||
|
state.LastStateChangeUtc = now;
|
||||||
|
if (previous == HostRuntimeState.Stopped)
|
||||||
|
{
|
||||||
|
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Running, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state.FailureCount++;
|
||||||
|
if (previous != HostRuntimeState.Stopped)
|
||||||
|
{
|
||||||
|
state.State = HostRuntimeState.Stopped;
|
||||||
|
state.LastStateChangeUtc = now;
|
||||||
|
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Stopped, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transition is { } t)
|
||||||
|
{
|
||||||
|
StateChanged?.Invoke(this, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_byProbe.Clear();
|
||||||
|
_probeByTagName.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HostProbeState
|
||||||
|
{
|
||||||
|
public string TagName { get; set; } = "";
|
||||||
|
public HostRuntimeState State { get; set; }
|
||||||
|
public DateTime LastStateChangeUtc { get; set; }
|
||||||
|
public DateTime? LastCallbackUtc { get; set; }
|
||||||
|
public long GoodUpdateCount { get; set; }
|
||||||
|
public long FailureCount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HostRuntimeState
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HostStateTransition(
|
||||||
|
string TagName,
|
||||||
|
HostRuntimeState OldState,
|
||||||
|
HostRuntimeState NewState,
|
||||||
|
DateTime AtUtc);
|
||||||
|
|
||||||
|
public sealed record HostProbeSnapshot(
|
||||||
|
string TagName,
|
||||||
|
HostRuntimeState State,
|
||||||
|
DateTime LastChangedUtc);
|
||||||
|
|
||||||
|
public readonly record struct HostProbeTarget(string TagName, int CategoryId)
|
||||||
|
{
|
||||||
|
public bool IsRuntimeHost =>
|
||||||
|
CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||||
|
|| CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine;
|
||||||
|
}
|
||||||
@@ -85,6 +85,33 @@ public sealed class StubGalaxyBackend : IGalaxyBackend
|
|||||||
Tags = System.Array.Empty<HistoryTagValues>(),
|
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||||
|
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new HistoryReadProcessedResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||||
|
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||||
|
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new HistoryReadAtTimeResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||||
|
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||||
|
HistoryReadEventsRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new HistoryReadEventsResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||||
|
Events = System.Array.Empty<GalaxyHistoricalEvent>(),
|
||||||
|
});
|
||||||
|
|
||||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult(new RecycleStatusResponse
|
=> Task.FromResult(new RecycleStatusResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,6 +80,27 @@ public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) :
|
|||||||
await writer.WriteAsync(MessageKind.HistoryReadResponse, resp, ct);
|
await writer.WriteAsync(MessageKind.HistoryReadResponse, resp, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case MessageKind.HistoryReadProcessedRequest:
|
||||||
|
{
|
||||||
|
var resp = await backend.HistoryReadProcessedAsync(
|
||||||
|
Deserialize<HistoryReadProcessedRequest>(body), ct);
|
||||||
|
await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case MessageKind.HistoryReadAtTimeRequest:
|
||||||
|
{
|
||||||
|
var resp = await backend.HistoryReadAtTimeAsync(
|
||||||
|
Deserialize<HistoryReadAtTimeRequest>(body), ct);
|
||||||
|
await writer.WriteAsync(MessageKind.HistoryReadAtTimeResponse, resp, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case MessageKind.HistoryReadEventsRequest:
|
||||||
|
{
|
||||||
|
var resp = await backend.HistoryReadEventsAsync(
|
||||||
|
Deserialize<HistoryReadEventsRequest>(body), ct);
|
||||||
|
await writer.WriteAsync(MessageKind.HistoryReadEventsResponse, resp, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
case MessageKind.RecycleHostRequest:
|
case MessageKind.RecycleHostRequest:
|
||||||
{
|
{
|
||||||
var resp = await backend.RecycleAsync(Deserialize<RecycleHostRequest>(body), ct);
|
var resp = await backend.RecycleAsync(Deserialize<RecycleHostRequest>(body), ct);
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
|||||||
IsArray: attr.IsArray,
|
IsArray: attr.IsArray,
|
||||||
ArrayDim: attr.ArrayDim,
|
ArrayDim: attr.ArrayDim,
|
||||||
SecurityClass: MapSecurity(attr.SecurityClassification),
|
SecurityClass: MapSecurity(attr.SecurityClassification),
|
||||||
IsHistorized: attr.IsHistorized));
|
IsHistorized: attr.IsHistorized,
|
||||||
|
IsAlarm: attr.IsAlarm));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,10 +297,50 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
|||||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<HistoryReadResult> ReadProcessedAsync(
|
public async Task<HistoryReadResult> ReadProcessedAsync(
|
||||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||||
=> throw new NotSupportedException("Galaxy historian processed reads are not supported in v2; use ReadRawAsync.");
|
{
|
||||||
|
var client = RequireClient();
|
||||||
|
var column = MapAggregateToColumn(aggregate);
|
||||||
|
|
||||||
|
var resp = await client.CallAsync<HistoryReadProcessedRequest, HistoryReadProcessedResponse>(
|
||||||
|
MessageKind.HistoryReadProcessedRequest,
|
||||||
|
new HistoryReadProcessedRequest
|
||||||
|
{
|
||||||
|
SessionId = _sessionId,
|
||||||
|
TagReference = fullReference,
|
||||||
|
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
IntervalMs = (long)interval.TotalMilliseconds,
|
||||||
|
AggregateColumn = column,
|
||||||
|
},
|
||||||
|
MessageKind.HistoryReadProcessedResponse,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!resp.Success)
|
||||||
|
throw new InvalidOperationException($"Galaxy.Host HistoryReadProcessed failed: {resp.Error}");
|
||||||
|
|
||||||
|
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
|
||||||
|
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian
|
||||||
|
/// AnalogSummaryQuery column names consumed by <c>HistorianDataSource.ReadAggregateAsync</c>.
|
||||||
|
/// Kept on the Proxy side so Galaxy.Host stays OPC-UA-free.
|
||||||
|
/// </summary>
|
||||||
|
internal static string MapAggregateToColumn(HistoryAggregateType aggregate) => aggregate switch
|
||||||
|
{
|
||||||
|
HistoryAggregateType.Average => "Average",
|
||||||
|
HistoryAggregateType.Minimum => "Minimum",
|
||||||
|
HistoryAggregateType.Maximum => "Maximum",
|
||||||
|
HistoryAggregateType.Count => "ValueCount",
|
||||||
|
HistoryAggregateType.Total => throw new NotSupportedException(
|
||||||
|
"HistoryAggregateType.Total is not supported by the Wonderware Historian AnalogSummary " +
|
||||||
|
"query — use Average × Count on the caller side, or switch to Average/Minimum/Maximum/Count."),
|
||||||
|
_ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"),
|
||||||
|
};
|
||||||
|
|
||||||
// ---- IRediscoverable ----
|
// ---- IRediscoverable ----
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ public sealed class GalaxyAttributeInfo
|
|||||||
[Key(3)] public uint? ArrayDim { get; set; }
|
[Key(3)] public uint? ArrayDim { get; set; }
|
||||||
[Key(4)] public int SecurityClassification { get; set; }
|
[Key(4)] public int SecurityClassification { get; set; }
|
||||||
[Key(5)] public bool IsHistorized { get; set; }
|
[Key(5)] public bool IsHistorized { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the attribute has an AlarmExtension primitive in the Galaxy repository
|
||||||
|
/// (<c>primitive_definition.primitive_name = 'AlarmExtension'</c>). The generic
|
||||||
|
/// node-manager uses this to enrich the variable's OPC UA node with an
|
||||||
|
/// <c>AlarmConditionState</c> during address-space build. Added in PR 9 as the
|
||||||
|
/// discovery-side foundation for the alarm event wire-up that follows in PR 10+.
|
||||||
|
/// </summary>
|
||||||
|
[Key(6)] public bool IsAlarm { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
|
|||||||
@@ -48,8 +48,14 @@ public enum MessageKind : byte
|
|||||||
AlarmEvent = 0x51,
|
AlarmEvent = 0x51,
|
||||||
AlarmAckRequest = 0x52,
|
AlarmAckRequest = 0x52,
|
||||||
|
|
||||||
HistoryReadRequest = 0x60,
|
HistoryReadRequest = 0x60,
|
||||||
HistoryReadResponse = 0x61,
|
HistoryReadResponse = 0x61,
|
||||||
|
HistoryReadProcessedRequest = 0x62,
|
||||||
|
HistoryReadProcessedResponse = 0x63,
|
||||||
|
HistoryReadAtTimeRequest = 0x64,
|
||||||
|
HistoryReadAtTimeResponse = 0x65,
|
||||||
|
HistoryReadEventsRequest = 0x66,
|
||||||
|
HistoryReadEventsResponse = 0x67,
|
||||||
|
|
||||||
HostConnectivityStatus = 0x70,
|
HostConnectivityStatus = 0x70,
|
||||||
RuntimeStatusChange = 0x71,
|
RuntimeStatusChange = 0x71,
|
||||||
|
|||||||
@@ -26,3 +26,85 @@ public sealed class HistoryReadResponse
|
|||||||
[Key(1)] public string? Error { get; set; }
|
[Key(1)] public string? Error { get; set; }
|
||||||
[Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty<HistoryTagValues>();
|
[Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty<HistoryTagValues>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processed (aggregated) historian read — OPC UA HistoryReadProcessed service. The
|
||||||
|
/// aggregate column is a string (e.g. "Average", "Minimum") mapped by the Proxy from the
|
||||||
|
/// OPC UA HistoryAggregateType enum so Galaxy.Host stays OPC-UA-free.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HistoryReadProcessedRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public string TagReference { get; set; } = string.Empty;
|
||||||
|
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||||
|
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||||
|
[Key(4)] public long IntervalMs { get; set; }
|
||||||
|
[Key(5)] public string AggregateColumn { get; set; } = "Average";
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HistoryReadProcessedResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// At-time historian read — OPC UA HistoryReadAtTime service. Returns one sample per
|
||||||
|
/// requested timestamp (interpolated when no exact match exists). The per-timestamp array
|
||||||
|
/// is flow-encoded as Unix milliseconds to avoid MessagePack DateTime quirks.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HistoryReadAtTimeRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public string TagReference { get; set; } = string.Empty;
|
||||||
|
[Key(2)] public long[] TimestampsUtcUnixMs { get; set; } = System.Array.Empty<long>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HistoryReadAtTimeResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Historical events read — OPC UA HistoryReadEvents service and Alarm & Condition
|
||||||
|
/// history. <c>SourceName</c> null means "all sources". Distinct from the live
|
||||||
|
/// <see cref="GalaxyAlarmEvent"/> stream because historical rows carry both
|
||||||
|
/// <c>EventTime</c> (when the event occurred in the process) and <c>ReceivedTime</c>
|
||||||
|
/// (when the Historian persisted it) and have no StateTransition — the Historian logs
|
||||||
|
/// the instantaneous event, not the OPC UA alarm lifecycle.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HistoryReadEventsRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public string? SourceName { get; set; }
|
||||||
|
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||||
|
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||||
|
[Key(4)] public int MaxEvents { get; set; } = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class GalaxyHistoricalEvent
|
||||||
|
{
|
||||||
|
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public string? SourceName { get; set; }
|
||||||
|
[Key(2)] public long EventTimeUtcUnixMs { get; set; }
|
||||||
|
[Key(3)] public long ReceivedTimeUtcUnixMs { get; set; }
|
||||||
|
[Key(4)] public string? DisplayText { get; set; }
|
||||||
|
[Key(5)] public ushort Severity { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HistoryReadEventsResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
[Key(2)] public GalaxyHistoricalEvent[] Events { get; set; } = System.Array.Empty<GalaxyHistoricalEvent>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System;
|
||||||
|
using MessagePack;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AlarmDiscoveryTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PR 9 — IsAlarm must survive the MessagePack round-trip at Key=6 position.
|
||||||
|
/// Regression guard: any reorder of keys in GalaxyAttributeInfo would silently corrupt
|
||||||
|
/// the flag in the wire payload since MessagePack encodes by key number, not field name.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void GalaxyAttributeInfo_IsAlarm_round_trips_true_through_MessagePack()
|
||||||
|
{
|
||||||
|
var input = new GalaxyAttributeInfo
|
||||||
|
{
|
||||||
|
AttributeName = "TankLevel",
|
||||||
|
MxDataType = 2,
|
||||||
|
IsArray = false,
|
||||||
|
ArrayDim = null,
|
||||||
|
SecurityClassification = 1,
|
||||||
|
IsHistorized = true,
|
||||||
|
IsAlarm = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var bytes = MessagePackSerializer.Serialize(input);
|
||||||
|
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||||
|
|
||||||
|
decoded.IsAlarm.ShouldBeTrue();
|
||||||
|
decoded.IsHistorized.ShouldBeTrue();
|
||||||
|
decoded.AttributeName.ShouldBe("TankLevel");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GalaxyAttributeInfo_IsAlarm_round_trips_false_through_MessagePack()
|
||||||
|
{
|
||||||
|
var input = new GalaxyAttributeInfo { AttributeName = "ColorRgb", IsAlarm = false };
|
||||||
|
var bytes = MessagePackSerializer.Serialize(input);
|
||||||
|
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||||
|
decoded.IsAlarm.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-compat guard: payloads serialized before PR 9 (which omit Key=6) must still
|
||||||
|
/// deserialize cleanly — MessagePack treats missing keys as default. This lets a newer
|
||||||
|
/// Proxy talk to an older Host during a rolling upgrade without a crash.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Pre_PR9_payload_without_IsAlarm_key_deserializes_with_default_false()
|
||||||
|
{
|
||||||
|
// Build a 6-field payload (keys 0..5) matching the pre-PR9 shape by serializing a
|
||||||
|
// stand-in class with the same key layout but no Key=6.
|
||||||
|
var pre = new PrePR9Shape
|
||||||
|
{
|
||||||
|
AttributeName = "Legacy",
|
||||||
|
MxDataType = 1,
|
||||||
|
IsArray = false,
|
||||||
|
ArrayDim = null,
|
||||||
|
SecurityClassification = 0,
|
||||||
|
IsHistorized = false,
|
||||||
|
};
|
||||||
|
var bytes = MessagePackSerializer.Serialize(pre);
|
||||||
|
|
||||||
|
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||||
|
decoded.AttributeName.ShouldBe("Legacy");
|
||||||
|
decoded.IsAlarm.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class PrePR9Shape
|
||||||
|
{
|
||||||
|
[Key(0)] public string AttributeName { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public int MxDataType { get; set; }
|
||||||
|
[Key(2)] public bool IsArray { get; set; }
|
||||||
|
[Key(3)] public uint? ArrayDim { get; set; }
|
||||||
|
[Key(4)] public int SecurityClassification { get; set; }
|
||||||
|
[Key(5)] public bool IsHistorized { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class GalaxyRuntimeProbeManagerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeSubscriber
|
||||||
|
{
|
||||||
|
public readonly ConcurrentDictionary<string, Action<string, Vtq>> Subs = new();
|
||||||
|
public readonly ConcurrentQueue<string> UnsubCalls = new();
|
||||||
|
public bool FailSubscribeFor { get; set; }
|
||||||
|
public string? FailSubscribeTag { get; set; }
|
||||||
|
|
||||||
|
public Task Subscribe(string probe, Action<string, Vtq> cb)
|
||||||
|
{
|
||||||
|
if (FailSubscribeFor && string.Equals(probe, FailSubscribeTag, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("subscribe refused");
|
||||||
|
Subs[probe] = cb;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Unsubscribe(string probe)
|
||||||
|
{
|
||||||
|
UnsubCalls.Enqueue(probe);
|
||||||
|
Subs.TryRemove(probe, out _);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vtq Good(bool scanState) => new(scanState, DateTime.UtcNow, 192);
|
||||||
|
private static Vtq Bad() => new(null, DateTime.UtcNow, 0);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sync_subscribes_to_ScanState_per_host()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[]
|
||||||
|
{
|
||||||
|
new HostProbeTarget("PlatformA", GalaxyRuntimeProbeManager.CategoryWinPlatform),
|
||||||
|
new HostProbeTarget("EngineB", GalaxyRuntimeProbeManager.CategoryAppEngine),
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(2);
|
||||||
|
subs.Subs.ShouldContainKey("PlatformA.ScanState");
|
||||||
|
subs.Subs.ShouldContainKey("EngineB.ScanState");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sync_is_idempotent_on_repeat_call_with_same_set()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
var targets = new[] { new HostProbeTarget("PlatformA", 1) };
|
||||||
|
|
||||||
|
await mgr.SyncAsync(targets);
|
||||||
|
await mgr.SyncAsync(targets);
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(1);
|
||||||
|
subs.Subs.Count.ShouldBe(1);
|
||||||
|
subs.UnsubCalls.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sync_unadvises_removed_hosts()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[]
|
||||||
|
{
|
||||||
|
new HostProbeTarget("PlatformA", 1),
|
||||||
|
new HostProbeTarget("PlatformB", 1),
|
||||||
|
});
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(1);
|
||||||
|
subs.UnsubCalls.ShouldContain("PlatformB.ScanState");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_failure_rolls_back_host_entry_so_later_transitions_do_not_fire_stale_events()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber { FailSubscribeFor = true, FailSubscribeTag = "PlatformA.ScanState" };
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(0); // rolled back
|
||||||
|
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_to_Running_does_not_fire_StateChanged()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||||
|
|
||||||
|
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Running);
|
||||||
|
transitions.Count.ShouldBe(0); // startup transition, operators don't care
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Running_to_Stopped_fires_StateChanged_with_both_states()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||||
|
|
||||||
|
transitions.Count.ShouldBe(1);
|
||||||
|
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||||
|
t!.TagName.ShouldBe("PlatformA");
|
||||||
|
t.OldState.ShouldBe(HostRuntimeState.Running);
|
||||||
|
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stopped_to_Running_fires_StateChanged_for_recovery()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Stopped→Running (fires)
|
||||||
|
|
||||||
|
transitions.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_to_Stopped_fires_StateChanged_for_first_known_bad_signal()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
// First callback is bad-quality — we must flag the host Stopped so operators see it.
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Bad());
|
||||||
|
|
||||||
|
transitions.Count.ShouldBe(1);
|
||||||
|
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||||
|
t!.OldState.ShouldBe(HostRuntimeState.Unknown);
|
||||||
|
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repeated_Good_Running_callbacks_do_not_fire_duplicate_events()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var count = 0;
|
||||||
|
mgr.StateChanged += (_, _) => Interlocked.Increment(ref count);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||||
|
|
||||||
|
count.ShouldBe(0); // only the silent Unknown→Running on the first, no events after
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_callback_for_non_tracked_probe_is_dropped()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
mgr.OnProbeCallback("ProbeForSomeoneElse.ScanState", Good(true));
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Snapshot_reports_current_state_for_every_tracked_host()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[]
|
||||||
|
{
|
||||||
|
new HostProbeTarget("PlatformA", 1),
|
||||||
|
new HostProbeTarget("EngineB", 3),
|
||||||
|
});
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Running
|
||||||
|
subs.Subs["EngineB.ScanState"]("EngineB.ScanState", Bad()); // Stopped
|
||||||
|
|
||||||
|
var snap = mgr.SnapshotStates();
|
||||||
|
snap.Count.ShouldBe(2);
|
||||||
|
snap.ShouldContain(s => s.TagName == "PlatformA" && s.State == HostRuntimeState.Running);
|
||||||
|
snap.ShouldContain(s => s.TagName == "EngineB" && s.State == HostRuntimeState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsRuntimeHost_recognizes_WinPlatform_and_AppEngine_category_ids()
|
||||||
|
{
|
||||||
|
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryWinPlatform).IsRuntimeHost.ShouldBeTrue();
|
||||||
|
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryAppEngine).IsRuntimeHost.ShouldBeTrue();
|
||||||
|
new HostProbeTarget("X", 4 /* $Area */).IsRuntimeHost.ShouldBeFalse();
|
||||||
|
new HostProbeTarget("X", 11 /* $ApplicationObject */).IsRuntimeHost.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class HistorianQualityMapperTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Rich mapping preserves specific OPC DA subcodes through the historian ToWire path.
|
||||||
|
/// Before PR 12 the category-only fallback collapsed e.g. BadNotConnected(8) to
|
||||||
|
/// Bad(0x80000000) so downstream OPC UA clients could not distinguish transport issues
|
||||||
|
/// from sensor issues. After PR 12 every known subcode round-trips to its canonical
|
||||||
|
/// uint32 StatusCode and Proxy translation stays byte-for-byte with v1 QualityMapper.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData((byte)192, 0x00000000u)] // Good
|
||||||
|
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
|
||||||
|
[InlineData((byte)64, 0x40000000u)] // Uncertain
|
||||||
|
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
|
||||||
|
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
|
||||||
|
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
|
||||||
|
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
|
||||||
|
[InlineData((byte)0, 0x80000000u)] // Bad
|
||||||
|
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
|
||||||
|
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
|
||||||
|
[InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure
|
||||||
|
[InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure
|
||||||
|
[InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError
|
||||||
|
[InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService
|
||||||
|
[InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData
|
||||||
|
public void Maps_specific_OPC_DA_codes_to_canonical_StatusCode(byte quality, uint expected)
|
||||||
|
{
|
||||||
|
HistorianQualityMapper.Map(quality).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData((byte)200)] // Good — unknown subcode in Good family
|
||||||
|
[InlineData((byte)255)] // Good — unknown
|
||||||
|
public void Unknown_good_family_codes_fall_back_to_plain_Good(byte q)
|
||||||
|
{
|
||||||
|
HistorianQualityMapper.Map(q).ShouldBe(0x00000000u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData((byte)100)] // Uncertain — unknown subcode
|
||||||
|
[InlineData((byte)150)] // Uncertain — unknown
|
||||||
|
public void Unknown_uncertain_family_codes_fall_back_to_plain_Uncertain(byte q)
|
||||||
|
{
|
||||||
|
HistorianQualityMapper.Map(q).ShouldBe(0x40000000u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData((byte)1)] // Bad — unknown subcode
|
||||||
|
[InlineData((byte)50)] // Bad — unknown
|
||||||
|
public void Unknown_bad_family_codes_fall_back_to_plain_Bad(byte q)
|
||||||
|
{
|
||||||
|
HistorianQualityMapper.Map(q).ShouldBe(0x80000000u);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class HistoryReadAtTimeTests
|
||||||
|
{
|
||||||
|
private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? historian, StaPump pump) =>
|
||||||
|
new(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
new MxAccessClient(pump, new MxProxyAdapter(), "attime-test"),
|
||||||
|
historian);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Returns_disabled_error_when_no_historian_configured()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
using var backend = BuildBackend(null, pump);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||||
|
{
|
||||||
|
TagReference = "T",
|
||||||
|
TimestampsUtcUnixMs = new[] { 1L, 2L },
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeFalse();
|
||||||
|
resp.Error.ShouldContain("Historian disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Empty_timestamp_list_short_circuits_to_success_with_no_values()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var fake = new FakeHistorian();
|
||||||
|
using var backend = BuildBackend(fake, pump);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||||
|
{
|
||||||
|
TagReference = "T",
|
||||||
|
TimestampsUtcUnixMs = Array.Empty<long>(),
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.Values.ShouldBeEmpty();
|
||||||
|
fake.Calls.ShouldBe(0); // no round-trip to SDK for empty timestamp list
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Timestamps_survive_Unix_ms_round_trip_to_DateTime()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var t1 = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var t2 = new DateTime(2026, 4, 18, 10, 5, 0, DateTimeKind.Utc);
|
||||||
|
var fake = new FakeHistorian(
|
||||||
|
new HistorianSample { Value = 100.0, Quality = 192, TimestampUtc = t1 },
|
||||||
|
new HistorianSample { Value = 101.5, Quality = 192, TimestampUtc = t2 });
|
||||||
|
using var backend = BuildBackend(fake, pump);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||||
|
{
|
||||||
|
TagReference = "TankLevel",
|
||||||
|
TimestampsUtcUnixMs = new[]
|
||||||
|
{
|
||||||
|
new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
new DateTimeOffset(t2, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
},
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.Values.Length.ShouldBe(2);
|
||||||
|
resp.Values[0].SourceTimestampUtcUnixMs.ShouldBe(new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||||
|
resp.Values[0].StatusCode.ShouldBe(0u); // Good (quality 192)
|
||||||
|
MessagePackSerializer.Deserialize<double>(resp.Values[0].ValueBytes!).ShouldBe(100.0);
|
||||||
|
|
||||||
|
fake.Calls.ShouldBe(1);
|
||||||
|
fake.LastTimestamps.Length.ShouldBe(2);
|
||||||
|
fake.LastTimestamps[0].ShouldBe(t1);
|
||||||
|
fake.LastTimestamps[1].ShouldBe(t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Missing_sample_maps_to_Bad_category()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
// Quality=0 means no sample at that timestamp per HistorianDataSource.ReadAtTimeAsync.
|
||||||
|
var fake = new FakeHistorian(new HistorianSample
|
||||||
|
{
|
||||||
|
Value = null,
|
||||||
|
Quality = 0,
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
using var backend = BuildBackend(fake, pump);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||||
|
{
|
||||||
|
TagReference = "T",
|
||||||
|
TimestampsUtcUnixMs = new[] { 1L },
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.Values.Length.ShouldBe(1);
|
||||||
|
resp.Values[0].StatusCode.ShouldBe(0x80000000u); // Bad category
|
||||||
|
resp.Values[0].ValueBytes.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHistorian : IHistorianDataSource
|
||||||
|
{
|
||||||
|
private readonly HistorianSample[] _samples;
|
||||||
|
public int Calls { get; private set; }
|
||||||
|
public DateTime[] LastTimestamps { get; private set; } = Array.Empty<DateTime>();
|
||||||
|
|
||||||
|
public FakeHistorian(params HistorianSample[] samples) => _samples = samples;
|
||||||
|
|
||||||
|
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
LastTimestamps = ts;
|
||||||
|
return Task.FromResult(new List<HistorianSample>(_samples));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianSample>());
|
||||||
|
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||||
|
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianEventDto>());
|
||||||
|
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class HistoryReadEventsTests
|
||||||
|
{
|
||||||
|
private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? h, StaPump pump) =>
|
||||||
|
new(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
new MxAccessClient(pump, new MxProxyAdapter(), "events-test"),
|
||||||
|
h);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Returns_disabled_error_when_no_historian_configured()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
using var backend = BuildBackend(null, pump);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||||
|
{
|
||||||
|
SourceName = "TankA",
|
||||||
|
StartUtcUnixMs = 0,
|
||||||
|
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
MaxEvents = 100,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeFalse();
|
||||||
|
resp.Error.ShouldContain("Historian disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Maps_HistorianEventDto_to_GalaxyHistoricalEvent_wire_shape()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
|
||||||
|
var eventId = Guid.NewGuid();
|
||||||
|
var eventTime = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var receivedTime = eventTime.AddMilliseconds(150);
|
||||||
|
var fake = new FakeHistorian(new HistorianEventDto
|
||||||
|
{
|
||||||
|
Id = eventId,
|
||||||
|
Source = "TankA.Level.HiHi",
|
||||||
|
EventTime = eventTime,
|
||||||
|
ReceivedTime = receivedTime,
|
||||||
|
DisplayText = "HiHi alarm tripped",
|
||||||
|
Severity = 900,
|
||||||
|
});
|
||||||
|
using var backend = BuildBackend(fake, pump);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||||
|
{
|
||||||
|
SourceName = "TankA.Level.HiHi",
|
||||||
|
StartUtcUnixMs = 0,
|
||||||
|
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
MaxEvents = 50,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.Events.Length.ShouldBe(1);
|
||||||
|
var got = resp.Events[0];
|
||||||
|
got.EventId.ShouldBe(eventId.ToString());
|
||||||
|
got.SourceName.ShouldBe("TankA.Level.HiHi");
|
||||||
|
got.DisplayText.ShouldBe("HiHi alarm tripped");
|
||||||
|
got.Severity.ShouldBe<ushort>(900);
|
||||||
|
got.EventTimeUtcUnixMs.ShouldBe(new DateTimeOffset(eventTime, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||||
|
got.ReceivedTimeUtcUnixMs.ShouldBe(new DateTimeOffset(receivedTime, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||||
|
|
||||||
|
fake.LastSourceName.ShouldBe("TankA.Level.HiHi");
|
||||||
|
fake.LastMaxEvents.ShouldBe(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Null_source_name_is_passed_through_as_all_sources()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var fake = new FakeHistorian();
|
||||||
|
using var backend = BuildBackend(fake, pump);
|
||||||
|
|
||||||
|
await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||||
|
{
|
||||||
|
SourceName = null,
|
||||||
|
StartUtcUnixMs = 0,
|
||||||
|
EndUtcUnixMs = 1,
|
||||||
|
MaxEvents = 10,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
fake.LastSourceName.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHistorian : IHistorianDataSource
|
||||||
|
{
|
||||||
|
private readonly HistorianEventDto[] _events;
|
||||||
|
public string? LastSourceName { get; private set; } = "<unset>";
|
||||||
|
public int LastMaxEvents { get; private set; }
|
||||||
|
|
||||||
|
public FakeHistorian(params HistorianEventDto[] events) => _events = events;
|
||||||
|
|
||||||
|
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||||
|
{
|
||||||
|
LastSourceName = src;
|
||||||
|
LastMaxEvents = max;
|
||||||
|
return Task.FromResult(new List<HistorianEventDto>(_events));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianSample>());
|
||||||
|
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||||
|
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianSample>());
|
||||||
|
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class HistoryReadProcessedTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnsDisabledError_When_NoHistorianConfigured()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||||
|
using var backend = new MxAccessGalaxyBackend(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
mx,
|
||||||
|
historian: null);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||||
|
{
|
||||||
|
TagReference = "T",
|
||||||
|
StartUtcUnixMs = 0,
|
||||||
|
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
IntervalMs = 1000,
|
||||||
|
AggregateColumn = "Average",
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeFalse();
|
||||||
|
resp.Error.ShouldContain("Historian disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_NonPositiveInterval()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||||
|
var fake = new FakeHistorianDataSource();
|
||||||
|
using var backend = new MxAccessGalaxyBackend(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
mx,
|
||||||
|
fake);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||||
|
{
|
||||||
|
TagReference = "T",
|
||||||
|
IntervalMs = 0,
|
||||||
|
AggregateColumn = "Average",
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeFalse();
|
||||||
|
resp.Error.ShouldContain("IntervalMs");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Maps_AggregateSample_With_Value_To_Good()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||||
|
var fake = new FakeHistorianDataSource(new HistorianAggregateSample
|
||||||
|
{
|
||||||
|
Value = 12.34,
|
||||||
|
TimestampUtc = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc),
|
||||||
|
});
|
||||||
|
using var backend = new MxAccessGalaxyBackend(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
mx,
|
||||||
|
fake);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||||
|
{
|
||||||
|
TagReference = "T",
|
||||||
|
StartUtcUnixMs = 0,
|
||||||
|
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
IntervalMs = 60_000,
|
||||||
|
AggregateColumn = "Average",
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.Values.Length.ShouldBe(1);
|
||||||
|
resp.Values[0].StatusCode.ShouldBe(0u); // Good
|
||||||
|
resp.Values[0].ValueBytes.ShouldNotBeNull();
|
||||||
|
MessagePackSerializer.Deserialize<double>(resp.Values[0].ValueBytes!).ShouldBe(12.34);
|
||||||
|
fake.LastAggregateColumn.ShouldBe("Average");
|
||||||
|
fake.LastIntervalMs.ShouldBe(60_000d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Maps_Null_Bucket_To_BadNoData()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||||
|
var fake = new FakeHistorianDataSource(new HistorianAggregateSample
|
||||||
|
{
|
||||||
|
Value = null,
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
using var backend = new MxAccessGalaxyBackend(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
mx,
|
||||||
|
fake);
|
||||||
|
|
||||||
|
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||||
|
{
|
||||||
|
TagReference = "T",
|
||||||
|
IntervalMs = 1000,
|
||||||
|
AggregateColumn = "Minimum",
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.Values.Length.ShouldBe(1);
|
||||||
|
resp.Values[0].StatusCode.ShouldBe(0x800E0000u); // BadNoData
|
||||||
|
resp.Values[0].ValueBytes.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeHistorianDataSource : IHistorianDataSource
|
||||||
|
{
|
||||||
|
private readonly HistorianAggregateSample[] _samples;
|
||||||
|
public string? LastAggregateColumn { get; private set; }
|
||||||
|
public double LastIntervalMs { get; private set; }
|
||||||
|
|
||||||
|
public FakeHistorianDataSource(params HistorianAggregateSample[] samples) => _samples = samples;
|
||||||
|
|
||||||
|
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianSample>());
|
||||||
|
|
||||||
|
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||||
|
string tag, DateTime s, DateTime e, double intervalMs, string col, CancellationToken ct)
|
||||||
|
{
|
||||||
|
LastAggregateColumn = col;
|
||||||
|
LastIntervalMs = intervalMs;
|
||||||
|
return Task.FromResult(new List<HistorianAggregateSample>(_samples));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianSample>());
|
||||||
|
|
||||||
|
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new List<HistorianEventDto>());
|
||||||
|
|
||||||
|
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ArchestrA.MxAccess;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class MxAccessClientMonitorLoopTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PR 6 low finding #1 — every $Heartbeat probe must RemoveItem the item handle it
|
||||||
|
/// allocated. Without that, the monitor leaks one handle per MonitorInterval seconds,
|
||||||
|
/// which over a 24h uptime becomes thousands of leaked MXAccess handles and can
|
||||||
|
/// eventually exhaust the runtime proxy's handle table.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Heartbeat_probe_calls_RemoveItem_for_every_AddItem()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Monitor.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
|
||||||
|
var proxy = new CountingProxy();
|
||||||
|
var client = new MxAccessClient(pump, proxy, "probe-test", new MxAccessClientOptions
|
||||||
|
{
|
||||||
|
AutoReconnect = true,
|
||||||
|
MonitorInterval = TimeSpan.FromMilliseconds(150),
|
||||||
|
StaleThreshold = TimeSpan.FromMilliseconds(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.ConnectAsync();
|
||||||
|
|
||||||
|
// Wait past StaleThreshold, then let several monitor cycles fire.
|
||||||
|
await Task.Delay(700);
|
||||||
|
|
||||||
|
client.Dispose();
|
||||||
|
|
||||||
|
// One Heartbeat probe fires per monitor tick once the connection looks stale.
|
||||||
|
proxy.HeartbeatAddCount.ShouldBeGreaterThan(1);
|
||||||
|
// Every AddItem("$Heartbeat") must be matched by a RemoveItem on the same handle.
|
||||||
|
proxy.HeartbeatAddCount.ShouldBe(proxy.HeartbeatRemoveCount);
|
||||||
|
proxy.OutstandingHeartbeatHandles.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 6 low finding #2 — after reconnect, per-subscription replay failures must raise
|
||||||
|
/// SubscriptionReplayFailed so the backend can propagate the degradation, not get
|
||||||
|
/// silently eaten.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task SubscriptionReplayFailed_fires_for_each_tag_that_fails_to_replay()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Replay.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
|
||||||
|
var proxy = new ReplayFailingProxy(failOnReplayForTags: new[] { "BadTag.A", "BadTag.B" });
|
||||||
|
var client = new MxAccessClient(pump, proxy, "replay-test", new MxAccessClientOptions
|
||||||
|
{
|
||||||
|
AutoReconnect = true,
|
||||||
|
MonitorInterval = TimeSpan.FromMilliseconds(120),
|
||||||
|
StaleThreshold = TimeSpan.FromMilliseconds(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
var failures = new ConcurrentBag<SubscriptionReplayFailedEventArgs>();
|
||||||
|
client.SubscriptionReplayFailed += (_, e) => failures.Add(e);
|
||||||
|
|
||||||
|
await client.ConnectAsync();
|
||||||
|
await client.SubscribeAsync("GoodTag.X", (_, _) => { });
|
||||||
|
await client.SubscribeAsync("BadTag.A", (_, _) => { });
|
||||||
|
await client.SubscribeAsync("BadTag.B", (_, _) => { });
|
||||||
|
|
||||||
|
proxy.TriggerProbeFailureOnNextCall();
|
||||||
|
|
||||||
|
// Wait for the monitor loop to probe → fail → reconnect → replay.
|
||||||
|
await Task.Delay(800);
|
||||||
|
|
||||||
|
client.Dispose();
|
||||||
|
|
||||||
|
failures.Count.ShouldBe(2);
|
||||||
|
var names = new HashSet<string>();
|
||||||
|
foreach (var f in failures) names.Add(f.TagReference);
|
||||||
|
names.ShouldContain("BadTag.A");
|
||||||
|
names.ShouldContain("BadTag.B");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- test doubles -----
|
||||||
|
|
||||||
|
private sealed class CountingProxy : IMxProxy
|
||||||
|
{
|
||||||
|
private int _next = 1;
|
||||||
|
private readonly ConcurrentDictionary<int, string> _live = new();
|
||||||
|
|
||||||
|
public int HeartbeatAddCount;
|
||||||
|
public int HeartbeatRemoveCount;
|
||||||
|
public int OutstandingHeartbeatHandles => _live.Count;
|
||||||
|
|
||||||
|
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||||
|
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||||
|
|
||||||
|
public int Register(string _) => 42;
|
||||||
|
public void Unregister(int _) { }
|
||||||
|
|
||||||
|
public int AddItem(int _, string address)
|
||||||
|
{
|
||||||
|
var h = Interlocked.Increment(ref _next);
|
||||||
|
_live[h] = address;
|
||||||
|
if (address == "$Heartbeat") Interlocked.Increment(ref HeartbeatAddCount);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveItem(int _, int itemHandle)
|
||||||
|
{
|
||||||
|
if (_live.TryRemove(itemHandle, out var addr) && addr == "$Heartbeat")
|
||||||
|
Interlocked.Increment(ref HeartbeatRemoveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AdviseSupervisory(int _, int __) { }
|
||||||
|
public void UnAdviseSupervisory(int _, int __) { }
|
||||||
|
public void Write(int _, int __, object ___, int ____) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock that lets us exercise the reconnect + replay path. TriggerProbeFailureOnNextCall
|
||||||
|
/// flips a one-shot flag so the very next AddItem("$Heartbeat") throws — that drives the
|
||||||
|
/// monitor loop into the reconnect-with-replay branch. During the replay, AddItem for the
|
||||||
|
/// tags listed in failOnReplayForTags throws so SubscriptionReplayFailed should fire once
|
||||||
|
/// per failing tag.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ReplayFailingProxy : IMxProxy
|
||||||
|
{
|
||||||
|
private int _next = 1;
|
||||||
|
private readonly HashSet<string> _failOnReplay;
|
||||||
|
private int _probeFailOnce;
|
||||||
|
private readonly ConcurrentDictionary<string, bool> _replayedOnce = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public ReplayFailingProxy(IEnumerable<string> failOnReplayForTags)
|
||||||
|
{
|
||||||
|
_failOnReplay = new HashSet<string>(failOnReplayForTags, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TriggerProbeFailureOnNextCall() => Interlocked.Exchange(ref _probeFailOnce, 1);
|
||||||
|
|
||||||
|
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||||
|
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||||
|
|
||||||
|
public int Register(string _) => 42;
|
||||||
|
public void Unregister(int _) { }
|
||||||
|
|
||||||
|
public int AddItem(int _, string address)
|
||||||
|
{
|
||||||
|
if (address == "$Heartbeat" && Interlocked.Exchange(ref _probeFailOnce, 0) == 1)
|
||||||
|
throw new InvalidOperationException("simulated probe failure");
|
||||||
|
|
||||||
|
// Fail only on the *replay* AddItem for listed tags — not the initial subscribe.
|
||||||
|
if (_failOnReplay.Contains(address) && _replayedOnce.ContainsKey(address))
|
||||||
|
throw new InvalidOperationException($"simulated replay failure for {address}");
|
||||||
|
|
||||||
|
if (_failOnReplay.Contains(address)) _replayedOnce[address] = true;
|
||||||
|
return Interlocked.Increment(ref _next);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveItem(int _, int __) { }
|
||||||
|
public void AdviseSupervisory(int _, int __) { }
|
||||||
|
public void UnAdviseSupervisory(int _, int __) { }
|
||||||
|
public void Write(int _, int __, object ___, int ____) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AggregateColumnMappingTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HistoryAggregateType.Average, "Average")]
|
||||||
|
[InlineData(HistoryAggregateType.Minimum, "Minimum")]
|
||||||
|
[InlineData(HistoryAggregateType.Maximum, "Maximum")]
|
||||||
|
[InlineData(HistoryAggregateType.Count, "ValueCount")]
|
||||||
|
public void Maps_OpcUa_enum_to_AnalogSummary_column(HistoryAggregateType aggregate, string expected)
|
||||||
|
{
|
||||||
|
GalaxyProxyDriver.MapAggregateToColumn(aggregate).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Total_is_not_supported()
|
||||||
|
{
|
||||||
|
Should.Throw<System.NotSupportedException>(
|
||||||
|
() => GalaxyProxyDriver.MapAggregateToColumn(HistoryAggregateType.Total));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user