Compare commits
1 Commits
phase-2-pr
...
phase-2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24f969a85 |
@@ -136,24 +136,6 @@ 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<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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,8 +39,6 @@ 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<HistoryReadEventsResponse> HistoryReadEventsAsync(HistoryReadEventsRequest req, CancellationToken ct);
|
|
||||||
|
|
||||||
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,82 +324,6 @@ 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 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 });
|
||||||
|
|
||||||
@@ -431,19 +355,11 @@ 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)
|
|
||||||
{
|
|
||||||
// Category-only mapping — mirrors QualityMapper.MapToOpcUaStatusCode for the common ranges.
|
|
||||||
// The Proxy may refine this when it decodes the wire frame.
|
|
||||||
if (q >= 192) return 0x00000000u; // Good
|
|
||||||
if (q >= 64) return 0x40000000u; // Uncertain
|
|
||||||
return 0x80000000u; // Bad
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a <see cref="HistorianAggregateSample"/> (one aggregate bucket) to the IPC wire
|
/// Maps a <see cref="HistorianAggregateSample"/> (one aggregate bucket) to the IPC wire
|
||||||
|
|||||||
@@ -94,24 +94,6 @@ 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<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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -87,20 +87,6 @@ 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.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);
|
||||||
|
|||||||
@@ -48,14 +48,10 @@ public enum MessageKind : byte
|
|||||||
AlarmEvent = 0x51,
|
AlarmEvent = 0x51,
|
||||||
AlarmAckRequest = 0x52,
|
AlarmAckRequest = 0x52,
|
||||||
|
|
||||||
HistoryReadRequest = 0x60,
|
HistoryReadRequest = 0x60,
|
||||||
HistoryReadResponse = 0x61,
|
HistoryReadResponse = 0x61,
|
||||||
HistoryReadProcessedRequest = 0x62,
|
HistoryReadProcessedRequest = 0x62,
|
||||||
HistoryReadProcessedResponse = 0x63,
|
HistoryReadProcessedResponse = 0x63,
|
||||||
HistoryReadAtTimeRequest = 0x64,
|
|
||||||
HistoryReadAtTimeResponse = 0x65,
|
|
||||||
HistoryReadEventsRequest = 0x66,
|
|
||||||
HistoryReadEventsResponse = 0x67,
|
|
||||||
|
|
||||||
HostConnectivityStatus = 0x70,
|
HostConnectivityStatus = 0x70,
|
||||||
RuntimeStatusChange = 0x71,
|
RuntimeStatusChange = 0x71,
|
||||||
|
|||||||
@@ -50,61 +50,3 @@ 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>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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() { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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() { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user