Compare commits
5 Commits
e250356e2a
...
phase-2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13f919112 | ||
| d2ebb91cb1 | |||
| 90ce0af375 | |||
|
|
70a5d06b37 | ||
|
|
30ece6e22c |
@@ -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);
|
||||||
|
|||||||
@@ -136,6 +136,15 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy
|
|||||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
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<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 });
|
||||||
|
|
||||||
@@ -147,6 +156,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>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public interface IGalaxyBackend
|
|||||||
|
|
||||||
Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct);
|
Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct);
|
||||||
Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct);
|
Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct);
|
||||||
|
Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(HistoryReadAtTimeRequest req, CancellationToken ct);
|
||||||
|
|
||||||
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ public sealed class MxAccessClient : IDisposable
|
|||||||
public int SubscriptionCount => _subscriptions.Count;
|
public int SubscriptionCount => _subscriptions.Count;
|
||||||
public int ReconnectCount => _reconnectCount;
|
public int ReconnectCount => _reconnectCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wonderware client identity used when registering with the LMXProxyServer. Surfaced so
|
||||||
|
/// <see cref="Backend.MxAccessGalaxyBackend"/> can tag its <c>OnHostStatusChanged</c> IPC
|
||||||
|
/// pushes with a stable gateway name per PR 8.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientName => _clientName;
|
||||||
|
|
||||||
/// <summary>Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call.</summary>
|
/// <summary>Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call.</summary>
|
||||||
public async Task<int> ConnectAsync()
|
public async Task<int> ConnectAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,16 +34,34 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
|
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||||
#pragma warning disable CS0067 // event not yet raised — alarm + host-status wire-up in PR #4 follow-up
|
#pragma warning disable CS0067 // alarm wire-up deferred to PR 9
|
||||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
|
||||||
#pragma warning restore CS0067
|
#pragma warning restore CS0067
|
||||||
|
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||||
|
|
||||||
|
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
||||||
|
|
||||||
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_mx = mx;
|
_mx = mx;
|
||||||
_historian = historian;
|
_historian = historian;
|
||||||
|
|
||||||
|
// PR 8: gateway-level host-status push. When the MXAccess COM proxy transitions
|
||||||
|
// connected↔disconnected, raise OnHostStatusChanged with a synthetic host entry named
|
||||||
|
// after the Wonderware client identity so the Admin UI surfaces top-level transport
|
||||||
|
// health even before per-platform/per-engine probing lands (deferred to a later PR that
|
||||||
|
// ports v1's GalaxyRuntimeProbeManager with ScanState subscriptions).
|
||||||
|
_onConnectionStateChanged = (_, connected) =>
|
||||||
|
{
|
||||||
|
OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
|
||||||
|
{
|
||||||
|
HostName = _mx.ClientName,
|
||||||
|
RuntimeStatus = connected ? "Running" : "Stopped",
|
||||||
|
LastObservedUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
_mx.ConnectionStateChanged += _onConnectionStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||||
@@ -306,10 +324,50 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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() => _historian?.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
||||||
|
_historian?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private static GalaxyDataValue ToWire(string reference, Vtq vtq) => new()
|
private static GalaxyDataValue ToWire(string reference, Vtq vtq) => new()
|
||||||
{
|
{
|
||||||
@@ -370,6 +428,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
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ public sealed class StubGalaxyBackend : IGalaxyBackend
|
|||||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
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<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult(new RecycleStatusResponse
|
=> Task.FromResult(new RecycleStatusResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) :
|
|||||||
await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct);
|
await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case MessageKind.HistoryReadAtTimeRequest:
|
||||||
|
{
|
||||||
|
var resp = await backend.HistoryReadAtTimeAsync(
|
||||||
|
Deserialize<HistoryReadAtTimeRequest>(body), ct);
|
||||||
|
await writer.WriteAsync(MessageKind.HistoryReadAtTimeResponse, 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ public enum MessageKind : byte
|
|||||||
HistoryReadResponse = 0x61,
|
HistoryReadResponse = 0x61,
|
||||||
HistoryReadProcessedRequest = 0x62,
|
HistoryReadProcessedRequest = 0x62,
|
||||||
HistoryReadProcessedResponse = 0x63,
|
HistoryReadProcessedResponse = 0x63,
|
||||||
|
HistoryReadAtTimeRequest = 0x64,
|
||||||
|
HistoryReadAtTimeResponse = 0x65,
|
||||||
|
|
||||||
HostConnectivityStatus = 0x70,
|
HostConnectivityStatus = 0x70,
|
||||||
RuntimeStatusChange = 0x71,
|
RuntimeStatusChange = 0x71,
|
||||||
|
|||||||
@@ -50,3 +50,24 @@ public sealed class HistoryReadProcessedResponse
|
|||||||
[Key(1)] public string? Error { get; set; }
|
[Key(1)] public string? Error { get; set; }
|
||||||
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
[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>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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,91 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ArchestrA.MxAccess;
|
||||||
|
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.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 HostStatusPushTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PR 8 — when MxAccessClient.ConnectionStateChanged fires false→true→false,
|
||||||
|
/// MxAccessGalaxyBackend raises OnHostStatusChanged once per transition with
|
||||||
|
/// HostName=ClientName, RuntimeStatus="Running"/"Stopped", and a timestamp.
|
||||||
|
/// This is the gateway-level signal; per-platform ScanState probes are deferred.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectionStateChanged_raises_OnHostStatusChanged_with_gateway_name()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var proxy = new FakeProxy();
|
||||||
|
var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
|
||||||
|
using var backend = new MxAccessGalaxyBackend(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
mx,
|
||||||
|
historian: null);
|
||||||
|
|
||||||
|
var notifications = new ConcurrentQueue<HostConnectivityStatus>();
|
||||||
|
backend.OnHostStatusChanged += (_, s) => notifications.Enqueue(s);
|
||||||
|
|
||||||
|
await mx.ConnectAsync();
|
||||||
|
await mx.DisconnectAsync();
|
||||||
|
|
||||||
|
notifications.Count.ShouldBe(2);
|
||||||
|
notifications.TryDequeue(out var first).ShouldBeTrue();
|
||||||
|
first!.HostName.ShouldBe("GatewayClient");
|
||||||
|
first.RuntimeStatus.ShouldBe("Running");
|
||||||
|
first.LastObservedUtcUnixMs.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
notifications.TryDequeue(out var second).ShouldBeTrue();
|
||||||
|
second!.HostName.ShouldBe("GatewayClient");
|
||||||
|
second.RuntimeStatus.ShouldBe("Stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_unsubscribes_so_post_dispose_state_changes_do_not_fire_events()
|
||||||
|
{
|
||||||
|
using var pump = new StaPump("Test.Sta");
|
||||||
|
await pump.WaitForStartedAsync();
|
||||||
|
var proxy = new FakeProxy();
|
||||||
|
var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
|
||||||
|
var backend = new MxAccessGalaxyBackend(
|
||||||
|
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||||
|
mx,
|
||||||
|
historian: null);
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
backend.OnHostStatusChanged += (_, _) => Interlocked.Increment(ref count);
|
||||||
|
|
||||||
|
await mx.ConnectAsync();
|
||||||
|
count.ShouldBe(1);
|
||||||
|
|
||||||
|
backend.Dispose();
|
||||||
|
await mx.DisconnectAsync();
|
||||||
|
|
||||||
|
count.ShouldBe(1); // no second notification after Dispose
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeProxy : IMxProxy
|
||||||
|
{
|
||||||
|
private int _next = 1;
|
||||||
|
public int Register(string _) => 42;
|
||||||
|
public void Unregister(int _) { }
|
||||||
|
public int AddItem(int _, string __) => 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 ____) { }
|
||||||
|
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||||
|
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user