Merge B1-galaxy: v3 RawPath read-path
This commit is contained in:
@@ -289,6 +289,72 @@ public sealed class GalaxyDiscovererTests
|
||||
info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 RawPath keying: when a resolver maps a discovered Galaxy attributeRef to its authored
|
||||
/// <see cref="RawTagEntry"/>, the emitted node reference (<see cref="DriverAttributeInfo.FullName"/>)
|
||||
/// is the entry's <b>RawPath</b> — not the attributeRef — and the entry's WriteIdempotent flag flows
|
||||
/// onto the descriptor. Unmapped attributes degrade to the attributeRef with WriteIdempotent=false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_WithRawPathResolver_EmitsRawPathAsFullName_AndThreadsWriteIdempotent()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2), Attr("SP", mxDataType: 2)),
|
||||
]);
|
||||
// Authored: Tank1_Level.PV → RawPath "area/line/tank1/level" (write-idempotent). SP is NOT authored.
|
||||
var authored = new Dictionary<string, RawTagEntry>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tank1_Level.PV"] = new RawTagEntry(
|
||||
"area/line/tank1/level", "{\"attributeRef\":\"Tank1_Level.PV\"}", WriteIdempotent: true),
|
||||
};
|
||||
var discoverer = new GalaxyDiscoverer(src, attrRef => authored.GetValueOrDefault(attrRef));
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
var pv = builder.Variables.Single(v => v.AttributeName == "PV").Info;
|
||||
pv.FullName.ShouldBe("area/line/tank1/level"); // RawPath, not the attributeRef
|
||||
pv.WriteIdempotent.ShouldBeTrue();
|
||||
|
||||
var sp = builder.Variables.Single(v => v.AttributeName == "SP").Info;
|
||||
sp.FullName.ShouldBe("Tank1_Level.SP"); // unmapped → attributeRef fallback
|
||||
sp.WriteIdempotent.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 alarm keying: the alarm condition identity (<see cref="AlarmConditionInfo.SourceName"/>, and
|
||||
/// the ConditionId the driver reports) is the tag's <b>RawPath</b>, while the five live-state /
|
||||
/// ack sub-refs are dialled off the Galaxy <b>attributeRef</b> — so the server subscribes the real
|
||||
/// MXAccess attributes while correlating the condition to its RawPath-keyed node.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_WithRawPathResolver_AlarmConditionUsesRawPathIdentity_AndAttributeRefSubRefs()
|
||||
{
|
||||
var src = new FakeHierarchySource([
|
||||
Obj("Tank1_Level", "Level", Attr("HiHi", mxDataType: 0, isAlarm: true)),
|
||||
]);
|
||||
var authored = new Dictionary<string, RawTagEntry>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tank1_Level.HiHi"] = new RawTagEntry(
|
||||
"area/line/tank1/hihi", "{\"attributeRef\":\"Tank1_Level.HiHi\"}", WriteIdempotent: false),
|
||||
};
|
||||
var discoverer = new GalaxyDiscoverer(src, attrRef => authored.GetValueOrDefault(attrRef));
|
||||
var builder = new FakeBuilder();
|
||||
|
||||
await discoverer.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
// Alarm declaration is keyed (by the fake builder) on the variable's FullReference = RawPath.
|
||||
builder.AlarmDeclarations.ShouldContainKey("area/line/tank1/hihi");
|
||||
var info = builder.AlarmDeclarations["area/line/tank1/hihi"];
|
||||
info.SourceName.ShouldBe("area/line/tank1/hihi"); // RawPath identity
|
||||
// Sub-refs dial off the Galaxy attributeRef (tag_name.AttributeName), NOT the RawPath.
|
||||
info.InAlarmRef.ShouldBe("Tank1_Level.HiHi.InAlarm");
|
||||
info.PriorityRef.ShouldBe("Tank1_Level.HiHi.Priority");
|
||||
info.DescAttrNameRef.ShouldBe("Tank1_Level.HiHi.DescAttrName");
|
||||
info.AckedRef.ShouldBe("Tank1_Level.HiHi.Acked");
|
||||
info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-alarm attributes are not marked as alarm conditions.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition()
|
||||
|
||||
@@ -79,6 +79,38 @@ public sealed class GalaxyDriverFactoryTests
|
||||
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the factory binds the v3 RawTags array from the driver config JSON.</summary>
|
||||
[Fact]
|
||||
public void CreateInstance_BindsRawTags_FromConfig()
|
||||
{
|
||||
const string configWithRawTags = """
|
||||
{
|
||||
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
|
||||
"MxAccess": { "ClientName": "OtOpcUa-A" },
|
||||
"RawTags": [
|
||||
{ "RawPath": "area/line/tank1/level", "TagConfig": "{\"attributeRef\":\"Tank1.Level\"}", "WriteIdempotent": true },
|
||||
{ "RawPath": "area/line/tank1/sp", "TagConfig": "{\"attributeRef\":\"Tank1.SP\"}", "WriteIdempotent": false }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-rawtags", configWithRawTags);
|
||||
|
||||
driver.Options.RawTags.Count.ShouldBe(2);
|
||||
driver.Options.RawTags[0].RawPath.ShouldBe("area/line/tank1/level");
|
||||
driver.Options.RawTags[0].TagConfig.ShouldContain("Tank1.Level");
|
||||
driver.Options.RawTags[0].WriteIdempotent.ShouldBeTrue();
|
||||
driver.Options.RawTags[1].WriteIdempotent.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that config without a RawTags array binds to an empty list (identity path).</summary>
|
||||
[Fact]
|
||||
public void CreateInstance_NoRawTags_DefaultsToEmpty()
|
||||
{
|
||||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-none", MinimalConfig);
|
||||
driver.Options.RawTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that missing endpoint throws an exception.</summary>
|
||||
[Fact]
|
||||
public void CreateInstance_MissingEndpoint_Throws()
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// v3 RawPath identity: the server addresses Galaxy nodes by RawPath, while MXAccess dials the
|
||||
/// Galaxy attributeRef (<c>tag_name.AttributeName</c>). These tests pin the driver-side boundary
|
||||
/// translation built from <see cref="GalaxyDriverOptions.RawTags"/> — RawPath → attributeRef on
|
||||
/// read / write / subscribe / ack (forward dial), and attributeRef → RawPath on the value fan-out
|
||||
/// and alarm ConditionId (reverse), so the alarm resolves back to the same RawPath the value path
|
||||
/// surfaces. Each entry's <c>TagConfig</c> carries the dialled address under the camelCase key
|
||||
/// <c>attributeRef</c>.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverRawPathKeyingTests
|
||||
{
|
||||
private const string RawPath = "area/line/tank1/level";
|
||||
private const string AttributeRef = "Tank1.Level";
|
||||
|
||||
private static GalaxyDriverOptions OptsWithRawTags(bool writeIdempotent = false) => new(
|
||||
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
||||
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
||||
new GalaxyRepositoryOptions(),
|
||||
new GalaxyReconnectOptions())
|
||||
{
|
||||
RawTags = [new RawTagEntry(RawPath, $"{{\"attributeRef\":\"{AttributeRef}\"}}", writeIdempotent)],
|
||||
};
|
||||
|
||||
// ===== Read: RawPath in → attributeRef dialled =====
|
||||
|
||||
/// <summary>ReadAsync translates the incoming RawPath to the Galaxy attributeRef before dialling.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_TranslatesRawPath_ToAttributeRef()
|
||||
{
|
||||
var reader = new RecordingReader();
|
||||
using var driver = new GalaxyDriver("g", OptsWithRawTags(), hierarchySource: null, dataReader: reader);
|
||||
|
||||
await driver.ReadAsync([RawPath], CancellationToken.None);
|
||||
|
||||
reader.LastRequest.ShouldBe([AttributeRef]);
|
||||
}
|
||||
|
||||
/// <summary>An unmapped RawPath dials as-is (identity) — the gateway then rejects an unknown address.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_UnmappedRawPath_DialsIdentity()
|
||||
{
|
||||
var reader = new RecordingReader();
|
||||
using var driver = new GalaxyDriver("g", OptsWithRawTags(), hierarchySource: null, dataReader: reader);
|
||||
|
||||
await driver.ReadAsync(["not/authored"], CancellationToken.None);
|
||||
|
||||
reader.LastRequest.ShouldBe(["not/authored"]);
|
||||
}
|
||||
|
||||
// ===== Write: RawPath in → attributeRef dialled, security resolved by RawPath =====
|
||||
|
||||
/// <summary>
|
||||
/// WriteAsync dials the attributeRef, and the security the writer resolves is the one captured at
|
||||
/// discovery under the tag's RawPath (the discoverer emitted FullName = RawPath) — proving the
|
||||
/// reverse mapping the resolver applies (attributeRef → RawPath) lands on the right classification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteAsync_DialsAttributeRef_AndResolvesSecurityByRawPath()
|
||||
{
|
||||
var writer = new RecordingWriter();
|
||||
// Hierarchy: Tank1.Level classified Operate (sec 1). Discovery keys security by the RawPath.
|
||||
var src = new FakeHierarchySource([Obj("Tank1", Attr("Level", sec: 1))]);
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", OptsWithRawTags(), hierarchySource: src, dataReader: null, dataWriter: writer);
|
||||
|
||||
await driver.DiscoverAsync(new CapturingBuilder(), CancellationToken.None);
|
||||
await driver.WriteAsync([new WriteRequest(RawPath, 42.0)], CancellationToken.None);
|
||||
|
||||
writer.Calls.ShouldHaveSingleItem();
|
||||
writer.Calls[0].FullRef.ShouldBe(AttributeRef); // dialled address
|
||||
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.Operate); // resolved by RawPath
|
||||
}
|
||||
|
||||
// ===== Subscribe + value fan-out: RawPath in → attributeRef dialled → RawPath out =====
|
||||
|
||||
/// <summary>
|
||||
/// SubscribeAsync dials the attributeRef; the resulting value fan-out surfaces on the public
|
||||
/// OnDataChange keyed by the tag's RawPath — the same identity the alarm ConditionId uses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_DialsAttributeRef_AndValueFanOutSurfacesRawPath()
|
||||
{
|
||||
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", OptsWithRawTags(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var captured = new List<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, args) => captured.Add(args);
|
||||
|
||||
await driver.SubscribeAsync([RawPath], TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
|
||||
// The gateway saw the dialled attributeRef, not the RawPath.
|
||||
subscriber.Map.ShouldContainKey(AttributeRef);
|
||||
subscriber.Map.ShouldNotContainKey(RawPath);
|
||||
|
||||
await subscriber.EmitOnDataChangeAsync(subscriber.Map[AttributeRef], 42.0);
|
||||
await WaitForAsync(() => captured.Count >= 1);
|
||||
|
||||
captured.ShouldHaveSingleItem();
|
||||
captured[0].FullReference.ShouldBe(RawPath); // surfaced under the RawPath identity
|
||||
((double)captured[0].Snapshot.Value!).ShouldBe(42.0);
|
||||
}
|
||||
|
||||
// ===== Alarm: ConditionId is the RawPath; ack round-trips back to the attributeRef =====
|
||||
|
||||
/// <summary>
|
||||
/// The alarm feed reports the Galaxy attributeRef; the driver surfaces the transition with
|
||||
/// ConditionId = the tag's RawPath (matching the value path), and a subsequent ack keyed by that
|
||||
/// RawPath dials back down to the attributeRef the gateway acknowledges.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Alarm_ConditionIdIsRawPath_AndAckRoundTripsToAttributeRef()
|
||||
{
|
||||
var feed = new FakeAlarmFeed();
|
||||
var ack = new RecordingAcknowledger();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed);
|
||||
|
||||
var handle = await driver.SubscribeAlarmsAsync(["Tank1"], CancellationToken.None);
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
|
||||
// Feed reports the dialled attributeRef.
|
||||
feed.Emit(NewTransition(AttributeRef, "Tank1"));
|
||||
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].ConditionId.ShouldBe(RawPath); // reverse-mapped to the RawPath identity
|
||||
observed[0].SubscriptionHandle.ShouldBe(handle);
|
||||
|
||||
// Ack keyed by the RawPath the driver reported → dials back to the attributeRef.
|
||||
await driver.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("Tank1", RawPath, "ack it")], CancellationToken.None);
|
||||
|
||||
ack.Calls.ShouldHaveSingleItem();
|
||||
ack.Calls[0].AlarmRef.ShouldBe(AttributeRef);
|
||||
}
|
||||
|
||||
// ===== helpers / fakes =====
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static GalaxyAttribute Attr(string name, int sec)
|
||||
=> new() { AttributeName = name, MxDataType = 2 /*Float32*/, SecurityClassification = sec };
|
||||
|
||||
private static GalaxyObject Obj(string tag, params GalaxyAttribute[] attrs)
|
||||
{
|
||||
var o = new GalaxyObject { TagName = tag, ContainedName = tag };
|
||||
o.Attributes.AddRange(attrs);
|
||||
return o;
|
||||
}
|
||||
|
||||
private static GalaxyAlarmTransition NewTransition(string alarmFullReference, string source)
|
||||
=> new(
|
||||
AlarmFullReference: alarmFullReference,
|
||||
SourceObjectReference: source,
|
||||
AlarmTypeName: "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind: GalaxyAlarmTransitionKind.Raise,
|
||||
SeverityBucket: AlarmSeverity.High,
|
||||
OpcUaSeverity: 800,
|
||||
RawMxAccessSeverity: 750,
|
||||
OriginalRaiseTimestampUtc: null,
|
||||
TransitionTimestampUtc: DateTime.UtcNow,
|
||||
OperatorUser: string.Empty,
|
||||
OperatorComment: string.Empty,
|
||||
Category: "Process",
|
||||
Description: "high level");
|
||||
|
||||
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
|
||||
{
|
||||
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(objects);
|
||||
}
|
||||
|
||||
/// <summary>Minimal builder — discovery only needs the SecurityCapturingBuilder wrapper to run.</summary>
|
||||
private sealed class CapturingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new Handle(attributeInfo.FullName);
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference { get; } = fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
|
||||
private sealed class Sink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingReader : IGalaxyDataReader
|
||||
{
|
||||
public IReadOnlyList<string>? LastRequest { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = fullReferences;
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
|
||||
fullReferences.Select(_ => new DataValueSnapshot(0.0, StatusCodeMap.Good, null, DateTime.UtcNow)).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingWriter : IGalaxyDataWriter
|
||||
{
|
||||
public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
Func<string, SecurityClassification> securityResolver,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new WriteResult[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
Calls.Add((writes[i].FullReference, writes[i].Value, securityResolver(writes[i].FullReference)));
|
||||
results[i] = new WriteResult(StatusCodeMap.Good);
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<WriteResult>>(results);
|
||||
}
|
||||
|
||||
public void InvalidateHandleCaches() { }
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
|
||||
{
|
||||
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
|
||||
public void Start() { }
|
||||
public void Emit(GalaxyAlarmTransition transition) => OnAlarmTransition?.Invoke(this, transition);
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
|
||||
{
|
||||
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
|
||||
|
||||
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls.Add((alarmFullReference, comment, operatorUser));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user