Compare commits
1 Commits
phase-2-pr
...
phase-2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30ece6e22c |
@@ -127,15 +127,6 @@ 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<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 });
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ 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<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,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)
|
||||||
@@ -264,52 +282,14 @@ 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 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()
|
||||||
{
|
{
|
||||||
@@ -347,21 +327,6 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
return 0x80000000u; // Bad
|
return 0x80000000u; // Bad
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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()
|
|
||||||
{
|
|
||||||
TagReference = reference,
|
|
||||||
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value.Value),
|
|
||||||
ValueMessagePackType = 0,
|
|
||||||
StatusCode = sample.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u,
|
|
||||||
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()
|
||||||
{
|
{
|
||||||
AttributeName = row.AttributeName,
|
AttributeName = row.AttributeName,
|
||||||
|
|||||||
@@ -85,15 +85,6 @@ 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<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult(new RecycleStatusResponse
|
=> Task.FromResult(new RecycleStatusResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,13 +80,6 @@ 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.RecycleHostRequest:
|
case MessageKind.RecycleHostRequest:
|
||||||
{
|
{
|
||||||
var resp = await backend.RecycleAsync(Deserialize<RecycleHostRequest>(body), ct);
|
var resp = await backend.RecycleAsync(Deserialize<RecycleHostRequest>(body), ct);
|
||||||
|
|||||||
@@ -296,50 +296,10 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
|||||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HistoryReadResult> ReadProcessedAsync(
|
public 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,10 +16,6 @@
|
|||||||
<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"/>
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ public enum MessageKind : byte
|
|||||||
|
|
||||||
HistoryReadRequest = 0x60,
|
HistoryReadRequest = 0x60,
|
||||||
HistoryReadResponse = 0x61,
|
HistoryReadResponse = 0x61,
|
||||||
HistoryReadProcessedRequest = 0x62,
|
|
||||||
HistoryReadProcessedResponse = 0x63,
|
|
||||||
|
|
||||||
HostConnectivityStatus = 0x70,
|
HostConnectivityStatus = 0x70,
|
||||||
RuntimeStatusChange = 0x71,
|
RuntimeStatusChange = 0x71,
|
||||||
|
|||||||
@@ -26,27 +26,3 @@ 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>();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
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,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 { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||||
<Reference Include="System.ServiceProcess"/>
|
<Reference Include="System.ServiceProcess"/>
|
||||||
|
<!-- IMxProxy's delegate signatures mention ArchestrA.MxAccess.MXSTATUS_PROXY, so tests
|
||||||
|
implementing the interface must resolve that type at compile time. -->
|
||||||
|
<Reference Include="ArchestrA.MxAccess">
|
||||||
|
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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