Compare commits
15 Commits
adr-001-eq
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe96fca9b | |||
|
|
4e80db4844 | ||
| f6d5763448 | |||
|
|
780358c790 | ||
| 1ac87f1fac | |||
|
|
432173c5c4 | ||
| f6d98cfa6b | |||
|
|
a29828e41e | ||
| f5076b4cdd | |||
|
|
2d97f241c0 | ||
| 5811ede744 | |||
|
|
1bf3938cdf | ||
| 7a42f6d84c | |||
|
|
2b2991c593 | ||
| 9711d0c097 |
173
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
Normal file
173
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
|
||||
/// <see cref="Configuration.Entities.Namespace"/> from the Config DB's
|
||||
/// <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
|
||||
/// address-space build per <see cref="IDriver"/> whose
|
||||
/// <c>Namespace.Kind = Equipment</c>; SystemPlatform-kind namespaces (Galaxy) are
|
||||
/// exempt per decision #120 and reach this walker only indirectly through
|
||||
/// <see cref="ITagDiscovery.DiscoverAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Composition strategy.</b> ADR-001 (2026-04-20) accepted Option A — Config
|
||||
/// primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
|
||||
/// snapshot as the authoritative published surface. Every Equipment row becomes a
|
||||
/// folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
|
||||
/// Equipment (non-null <see cref="Tag.EquipmentId"/>) becomes a variable node under
|
||||
/// it. Driver-discovered tags that have no Config-DB row are not added by this
|
||||
/// walker — the ITagDiscovery path continues to exist for the SystemPlatform case +
|
||||
/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Under each Equipment node.</b> Five identifier properties per decision #121
|
||||
/// (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
|
||||
/// <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
|
||||
/// resolve equipment by whichever identifier they natively use without a sidecar.
|
||||
/// <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
|
||||
/// Identification sub-folder with the nine decision-#139 fields when at least one
|
||||
/// is non-null; when all nine are null the sub-folder is omitted rather than
|
||||
/// appearing empty.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Address resolution.</b> Variable nodes carry the driver-side full reference
|
||||
/// in <see cref="DriverAttributeInfo.FullName"/> copied from <c>Tag.TagConfig</c>
|
||||
/// (the wire-level address JSON blob whose interpretation is driver-specific). At
|
||||
/// runtime the dispatch layer routes Read/Write calls through the configured
|
||||
/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
|
||||
/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
|
||||
/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
|
||||
/// UI + OPC UA client inspection of node status.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Pure function.</b> This class has no dependency on the OPC UA SDK, no
|
||||
/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
|
||||
/// into the supplied <see cref="IAddressSpaceBuilder"/>. The server-side wiring
|
||||
/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
|
||||
/// PR alongside <c>NodeScopeResolver</c>'s Config-DB join.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class EquipmentNodeWalker
|
||||
{
|
||||
/// <summary>
|
||||
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>.
|
||||
/// The builder is scoped to the Equipment-kind namespace root; the walker emits
|
||||
/// Area → Line → Equipment folders under it, then identifier properties + the
|
||||
/// Identification sub-folder + variable nodes per bound Tag under each Equipment.
|
||||
/// </summary>
|
||||
/// <param name="namespaceBuilder">
|
||||
/// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
|
||||
/// creating this (e.g. <c>rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)</c>).
|
||||
/// </param>
|
||||
/// <param name="content">Pre-loaded + pre-filtered rows for a single published generation.</param>
|
||||
public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(namespaceBuilder);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
// Group lines by area + equipment by line + tags by equipment up-front. Avoids an
|
||||
// O(N·M) re-scan at each UNS level on large fleets.
|
||||
var linesByArea = content.Lines
|
||||
.GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var equipmentByLine = content.Equipment
|
||||
.GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tagsByEquipment = content.Tags
|
||||
.Where(t => !string.IsNullOrEmpty(t.EquipmentId))
|
||||
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
||||
if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue;
|
||||
|
||||
foreach (var line in areaLines)
|
||||
{
|
||||
var lineBuilder = areaBuilder.Folder(line.Name, line.Name);
|
||||
if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue;
|
||||
|
||||
foreach (var equipment in lineEquipment)
|
||||
{
|
||||
var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name);
|
||||
AddIdentifierProperties(equipmentBuilder, equipment);
|
||||
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
||||
|
||||
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the five operator-facing identifiers from decision #121 as OPC UA properties
|
||||
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
|
||||
/// MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
|
||||
/// the data model so they're skipped when null to avoid empty-string noise in the
|
||||
/// browse tree.
|
||||
/// </summary>
|
||||
private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
|
||||
{
|
||||
equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId);
|
||||
equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString());
|
||||
equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode);
|
||||
if (!string.IsNullOrEmpty(equipment.ZTag))
|
||||
equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag);
|
||||
if (!string.IsNullOrEmpty(equipment.SAPID))
|
||||
equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
|
||||
/// full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
|
||||
/// JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
|
||||
/// Unreachable-address behavior per ADR-001 Option A: the variable is created; the
|
||||
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
|
||||
/// </summary>
|
||||
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: tag.TagConfig,
|
||||
DriverDataType: ParseDriverDataType(tag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: false);
|
||||
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
|
||||
/// name string, decision #138) into the enum value. Unknown names fall back to
|
||||
/// <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
|
||||
/// abort the whole walk; the underlying driver still sees the original TagConfig
|
||||
/// address + can surface its own typed value via the OPC UA variant at read time.
|
||||
/// </summary>
|
||||
private static DriverDataType ParseDriverDataType(string raw) =>
|
||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
|
||||
/// DB rows. All four collections are scoped to the same
|
||||
/// <see cref="Configuration.Entities.ConfigGeneration"/> + the same
|
||||
/// <see cref="Configuration.Entities.Namespace"/> row. The walker assumes this filter
|
||||
/// was applied by the caller + does no cross-generation or cross-namespace validation.
|
||||
/// </summary>
|
||||
public sealed record EquipmentNamespaceContent(
|
||||
IReadOnlyList<UnsArea> Areas,
|
||||
IReadOnlyList<UnsLine> Lines,
|
||||
IReadOnlyList<Equipment> Equipment,
|
||||
IReadOnlyList<Tag> Tags);
|
||||
232
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs
Normal file
232
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
|
||||
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
|
||||
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
|
||||
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
|
||||
/// <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
|
||||
/// get a no-op subscribe path so capability negotiation still works.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
|
||||
/// its threshold + limit semantics need more design — ALMD's "is the alarm active + has
|
||||
/// the operator acked" shape maps cleanly onto the driver-agnostic
|
||||
/// <see cref="IAlarmSource"/> contract without concessions.</para>
|
||||
///
|
||||
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the #194
|
||||
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
|
||||
/// One poll loop per subscription call; the loop batches every
|
||||
/// member read across the full source-node set into a single ReadAsync per tick.</para>
|
||||
///
|
||||
/// <para>ALMD <c>Acked</c> write semantics on Logix are rising-edge sensitive at the
|
||||
/// instruction level — writing <c>Acked=1</c> directly is honored by FT View + the
|
||||
/// standard HMI templates, but some PLC programs read <c>AckCmd</c> + look for the edge
|
||||
/// themselves. We pick the simpler <c>Acked</c> write for first pass; operators whose
|
||||
/// ladder watches <c>AckCmd</c> can wire a follow-up "AckCmd 0→1→0" pulse on the client
|
||||
/// side until a driver-level knob lands.</para>
|
||||
/// </remarks>
|
||||
internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriver _driver;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval)
|
||||
{
|
||||
_driver = driver;
|
||||
_pollInterval = pollInterval;
|
||||
}
|
||||
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new AbCipAlarmSubscriptionHandle(id);
|
||||
var cts = new CancellationTokenSource();
|
||||
var sub = new Subscription(handle, [..sourceNodeIds], cts);
|
||||
|
||||
lock (_subsLock) _subs[id] = sub;
|
||||
|
||||
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
||||
await Task.CompletedTask;
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not AbCipAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
}
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
if (acknowledgements.Count == 0) return;
|
||||
|
||||
// Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
|
||||
// the driver's public interface — delegating instead of re-implementing the write path
|
||||
// keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
|
||||
var requests = acknowledgements
|
||||
.Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
|
||||
.ToArray();
|
||||
// Best-effort — the driver's WriteAsync returns per-item status; individual ack
|
||||
// failures don't poison the batch. Swallow the return so a single faulted ack
|
||||
// doesn't bubble out of the caller's batch expectation.
|
||||
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
|
||||
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
|
||||
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
|
||||
/// </summary>
|
||||
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
|
||||
{
|
||||
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
|
||||
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
|
||||
{
|
||||
var nodeId = sub.SourceNodeIds[i];
|
||||
var inFaultedDv = results[i * 2];
|
||||
var severityDv = results[i * 2 + 1];
|
||||
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
|
||||
var nowFaulted = ToBool(inFaultedDv.Value);
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
sub.LastInFaulted[nodeId] = nowFaulted;
|
||||
|
||||
if (!wasFaulted && nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} raised",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
else if (wasFaulted && !nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} cleared",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
var refs = new List<string>(sub.SourceNodeIds.Count * 2);
|
||||
foreach (var nodeId in sub.SourceNodeIds)
|
||||
{
|
||||
refs.Add($"{nodeId}.InFaulted");
|
||||
refs.Add($"{nodeId}.Severity");
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await _driver.ReadAsync(refs, ct).ConfigureAwait(false);
|
||||
Tick(sub, results);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* per-tick failures are non-fatal; next tick retries */ }
|
||||
|
||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
internal static AlarmSeverity MapSeverity(int raw) => raw switch
|
||||
{
|
||||
<= 250 => AlarmSeverity.Low,
|
||||
<= 500 => AlarmSeverity.Medium,
|
||||
<= 750 => AlarmSeverity.High,
|
||||
_ => AlarmSeverity.Critical,
|
||||
};
|
||||
|
||||
private static bool ToBool(object? v) => v switch
|
||||
{
|
||||
bool b => b,
|
||||
int i => i != 0,
|
||||
long l => l != 0,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static int ToInt(object? v) => v switch
|
||||
{
|
||||
int i => i,
|
||||
long l => (int)l,
|
||||
short s => s,
|
||||
byte b => b,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
internal sealed class Subscription
|
||||
{
|
||||
public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
|
||||
{
|
||||
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
|
||||
}
|
||||
public AbCipAlarmSubscriptionHandle Handle { get; }
|
||||
public IReadOnlyList<string> SourceNodeIds { get; }
|
||||
public CancellationTokenSource Cts { get; }
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"abcip-alarm-sub-{Id}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the ALMD / ALMA signature in an <see cref="AbCipTagDefinition"/>'s declared
|
||||
/// members. Used by both discovery (to stamp <c>IsAlarm=true</c> on the emitted
|
||||
/// variable) + initial driver setup (to decide which tags the alarm projection owns).
|
||||
/// </summary>
|
||||
public static class AbCipAlarmDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
|
||||
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
|
||||
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
|
||||
/// ships as a follow-up.
|
||||
/// </summary>
|
||||
public static bool IsAlmd(AbCipTagDefinition tag)
|
||||
{
|
||||
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
|
||||
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return names.Contains("InFaulted") && names.Contains("Acked");
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
@@ -32,10 +32,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly AbCipAlarmProjection _alarmProjection;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
@@ -52,6 +57,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -162,6 +168,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
@@ -187,6 +194,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IAlarmSource (ALMD projection, #177) ----
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to ALMD alarm transitions on <paramref name="sourceNodeIds"/>. Each id
|
||||
/// names a declared ALMD UDT tag; the projection polls the tag's <c>InFaulted</c> +
|
||||
/// <c>Severity</c> members at <see cref="AbCipDriverOptions.AlarmPollInterval"/> and
|
||||
/// fires <see cref="OnAlarmEvent"/> on 0→1 (raise) + 1→0 (clear) transitions.
|
||||
/// Feature-gated — when <see cref="AbCipDriverOptions.EnableAlarmProjection"/> is
|
||||
/// <c>false</c> (the default), returns a handle wrapping a no-op subscription so
|
||||
/// capability negotiation still works; <see cref="OnAlarmEvent"/> never fires.
|
||||
/// </summary>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.EnableAlarmProjection)
|
||||
{
|
||||
var disabled = new AbCipAlarmSubscriptionHandle(0);
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(disabled);
|
||||
}
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_options.EnableAlarmProjection
|
||||
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
|
||||
: Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_options.EnableAlarmProjection
|
||||
? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
|
||||
: Task.CompletedTask;
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
@@ -287,56 +327,127 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
// Task #194 — plan the batch: members of the same parent UDT get collapsed into one
|
||||
// whole-UDT read + in-memory member decode; every other reference falls back to the
|
||||
// per-tag path that's been here since PR 3. Planner is a pure function over the
|
||||
// current tag map; BOOL/String/Structure members stay on the fallback path because
|
||||
// declaration-only offsets can't place them under Logix alignment rules.
|
||||
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var group in plan.Groups)
|
||||
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
foreach (var fb in plan.Fallbacks)
|
||||
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task ReadSingleAsync(
|
||||
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
return;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
||||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
|
||||
/// the mapped fault across every grouped member only — sibling groups + the
|
||||
/// per-tag fallback list are unaffected.
|
||||
/// </summary>
|
||||
private async Task ReadGroupAsync(
|
||||
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var parent = group.ParentDefinition;
|
||||
|
||||
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
|
||||
StampGroupStatus(group, results, now, mapped);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading UDT {group.ParentName}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var member in group.Members)
|
||||
{
|
||||
var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void StampGroupStatus(
|
||||
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
|
||||
{
|
||||
foreach (var member in group.Members)
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,6 +38,24 @@ public sealed class AbCipDriverOptions
|
||||
/// should appear in the address space.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — when <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// via <see cref="Core.Abstractions.IAlarmSource"/>; the driver polls each subscribed
|
||||
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// state transitions. Default <c>false</c> — operators explicitly opt in because
|
||||
/// projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops
|
||||
/// running FT Live should keep this off + take alarms through the native route.
|
||||
/// </summary>
|
||||
public bool EnableAlarmProjection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Poll interval for the ALMD projection loop. Shorter intervals catch faster edges
|
||||
/// at the cost of PLC round-trips; edges shorter than this interval are invisible to
|
||||
/// the projection (a 0→1→0 transition within one tick collapses to no event). Default
|
||||
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
||||
/// </summary>
|
||||
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
78
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
Normal file
78
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Computes byte offsets for declared UDT members under Logix natural-alignment rules so
|
||||
/// a single whole-UDT read (task #194) can decode each member from one buffer without
|
||||
/// re-reading per member. Declaration-driven — the caller supplies
|
||||
/// <see cref="AbCipStructureMember"/> rows; this helper produces the offset each member
|
||||
/// sits at in the parent tag's read buffer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Alignment rules applied per Rockwell "Logix 5000 Data Access" manual + the
|
||||
/// libplctag test fixtures: each member aligns to its natural boundary (SInt 1, Int 2,
|
||||
/// DInt/Real/Dt 4, LInt/ULInt/LReal 8), padding inserted before the member as needed.
|
||||
/// The total size is padded to the alignment of the largest member so arrays-of-UDT also
|
||||
/// work at element stride — though this helper is used only on single instances today.</para>
|
||||
///
|
||||
/// <para><see cref="TryBuild"/> returns <c>null</c> on unsupported member types
|
||||
/// (<see cref="AbCipDataType.Bool"/>, <see cref="AbCipDataType.String"/>,
|
||||
/// <see cref="AbCipDataType.Structure"/>). Whole-UDT grouping opts out of those groups
|
||||
/// and falls back to the per-tag read path — BOOL members are packed into a hidden host
|
||||
/// byte at the top of the UDT under Logix, so their offset can't be computed from
|
||||
/// declared-member order alone. The CIP Template Object reader produces a
|
||||
/// <see cref="AbCipUdtShape"/> that carries real offsets for BOOL + nested structs; when
|
||||
/// that shape is cached the driver can take the richer path instead.</para>
|
||||
/// </remarks>
|
||||
public static class AbCipUdtMemberLayout
|
||||
{
|
||||
/// <summary>
|
||||
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
|
||||
/// if any member type is unsupported for declaration-only layout.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, int>? TryBuild(
|
||||
IReadOnlyList<AbCipStructureMember> members)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(members);
|
||||
if (members.Count == 0) return null;
|
||||
|
||||
var offsets = new Dictionary<string, int>(members.Count, StringComparer.OrdinalIgnoreCase);
|
||||
var cursor = 0;
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!TryGetSizeAlign(member.DataType, out var size, out var align))
|
||||
return null;
|
||||
|
||||
if (cursor % align != 0)
|
||||
cursor += align - (cursor % align);
|
||||
|
||||
offsets[member.Name] = cursor;
|
||||
cursor += size;
|
||||
}
|
||||
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Natural size + alignment for a Logix atomic type. <c>false</c> for types excluded
|
||||
/// from declaration-only grouping (Bool / String / Structure).
|
||||
/// </summary>
|
||||
private static bool TryGetSizeAlign(AbCipDataType type, out int size, out int align)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.SInt: case AbCipDataType.USInt:
|
||||
size = 1; align = 1; return true;
|
||||
case AbCipDataType.Int: case AbCipDataType.UInt:
|
||||
size = 2; align = 2; return true;
|
||||
case AbCipDataType.DInt: case AbCipDataType.UDInt:
|
||||
case AbCipDataType.Real: case AbCipDataType.Dt:
|
||||
size = 4; align = 4; return true;
|
||||
case AbCipDataType.LInt: case AbCipDataType.ULInt:
|
||||
case AbCipDataType.LReal:
|
||||
size = 8; align = 8; return true;
|
||||
default:
|
||||
size = 0; align = 0; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
Normal file
109
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — groups a ReadAsync batch of full-references into whole-UDT reads where
|
||||
/// possible. A group is emitted for every parent UDT tag whose declared
|
||||
/// <see cref="AbCipStructureMember"/>s produced a valid offset map AND at least two of
|
||||
/// its members appear in the batch; every other reference stays in the per-tag fallback
|
||||
/// list that <see cref="AbCipDriver.ReadAsync"/> runs through its existing read path.
|
||||
/// Pure function — the planner never touches the runtime + never reads the PLC.
|
||||
/// </summary>
|
||||
public static class AbCipUdtReadPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
|
||||
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
|
||||
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
|
||||
/// to match the driver's dictionary semantics.
|
||||
/// </summary>
|
||||
public static AbCipUdtReadPlan Build(
|
||||
IReadOnlyList<string> requests,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
ArgumentNullException.ThrowIfNull(tagsByName);
|
||||
|
||||
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
||||
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < requests.Count; i++)
|
||||
{
|
||||
var name = requests[i];
|
||||
if (!tagsByName.TryGetValue(name, out var def))
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
var (parentName, memberName) = SplitParentMember(name);
|
||||
if (parentName is null || memberName is null
|
||||
|| !tagsByName.TryGetValue(parentName, out var parent)
|
||||
|| parent.DataType != AbCipDataType.Structure
|
||||
|| parent.Members is not { Count: > 0 })
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
|
||||
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byParent.TryGetValue(parentName, out var members))
|
||||
{
|
||||
members = new List<AbCipUdtReadMember>();
|
||||
byParent[parentName] = members;
|
||||
}
|
||||
members.Add(new AbCipUdtReadMember(i, def, offset));
|
||||
}
|
||||
|
||||
// A single-member group saves nothing (one whole-UDT read replaces one per-member read)
|
||||
// — demote to fallback to avoid paying the cost of reading the full UDT buffer only to
|
||||
// pull one field out.
|
||||
var groups = new List<AbCipUdtReadGroup>(byParent.Count);
|
||||
foreach (var (parentName, members) in byParent)
|
||||
{
|
||||
if (members.Count < 2)
|
||||
{
|
||||
foreach (var m in members)
|
||||
fallback.Add(new AbCipUdtReadFallback(m.OriginalIndex, m.Definition.Name));
|
||||
continue;
|
||||
}
|
||||
groups.Add(new AbCipUdtReadGroup(parentName, tagsByName[parentName], members));
|
||||
}
|
||||
|
||||
return new AbCipUdtReadPlan(groups, fallback);
|
||||
}
|
||||
|
||||
private static (string? Parent, string? Member) SplitParentMember(string reference)
|
||||
{
|
||||
var dot = reference.IndexOf('.');
|
||||
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
|
||||
return (reference[..dot], reference[(dot + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A planner output: grouped UDT reads + per-tag fallbacks.</summary>
|
||||
public sealed record AbCipUdtReadPlan(
|
||||
IReadOnlyList<AbCipUdtReadGroup> Groups,
|
||||
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
|
||||
|
||||
/// <summary>One UDT parent whose members were batched into a single read.</summary>
|
||||
public sealed record AbCipUdtReadGroup(
|
||||
string ParentName,
|
||||
AbCipTagDefinition ParentDefinition,
|
||||
IReadOnlyList<AbCipUdtReadMember> Members);
|
||||
|
||||
/// <summary>
|
||||
/// One member inside an <see cref="AbCipUdtReadGroup"/>. <c>OriginalIndex</c> is the
|
||||
/// slot in the caller's request list so the decoded value lands at the correct output
|
||||
/// offset. <c>Definition</c> is the fanned-out member-level tag definition. <c>Offset</c>
|
||||
/// is the byte offset within the parent UDT buffer where this member lives.
|
||||
/// </summary>
|
||||
public sealed record AbCipUdtReadMember(int OriginalIndex, AbCipTagDefinition Definition, int Offset);
|
||||
|
||||
/// <summary>A reference that falls back to the per-tag read path.</summary>
|
||||
public sealed record AbCipUdtReadFallback(int OriginalIndex, string Reference);
|
||||
@@ -31,6 +31,17 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// </summary>
|
||||
object? DecodeValue(AbCipDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Decode a value at an arbitrary byte offset in the local buffer. Task #194 —
|
||||
/// whole-UDT reads perform one <see cref="ReadAsync"/> on the parent UDT tag then
|
||||
/// call this per declared member with its computed offset, avoiding one libplctag
|
||||
/// round-trip per member. Implementations that do not support offset-aware decoding
|
||||
/// may fall back to <see cref="DecodeValue"/> when <paramref name="offset"/> is zero;
|
||||
/// offsets greater than zero against an unsupporting runtime should return <c>null</c>
|
||||
/// so the planner can skip grouping.
|
||||
/// </summary>
|
||||
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
||||
/// pair this with <see cref="WriteAsync"/>.
|
||||
|
||||
@@ -32,24 +32,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => type switch
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||
|
||||
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
: _tag.GetInt8(0) != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
|
||||
AbCipDataType.USInt => (int)_tag.GetUInt8(0),
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(0),
|
||||
AbCipDataType.UInt => (int)_tag.GetUInt16(0),
|
||||
AbCipDataType.DInt => _tag.GetInt32(0),
|
||||
AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
|
||||
AbCipDataType.LInt => _tag.GetInt64(0),
|
||||
AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
|
||||
AbCipDataType.Real => _tag.GetFloat32(0),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(0),
|
||||
AbCipDataType.String => _tag.GetString(0),
|
||||
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
|
||||
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
|
||||
: _tag.GetInt8(offset) != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
|
||||
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(offset),
|
||||
AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
|
||||
AbCipDataType.DInt => _tag.GetInt32(offset),
|
||||
AbCipDataType.UDInt => (int)_tag.GetUInt32(offset),
|
||||
AbCipDataType.LInt => _tag.GetInt64(offset),
|
||||
AbCipDataType.ULInt => (long)_tag.GetUInt64(offset),
|
||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||
AbCipDataType.String => _tag.GetString(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
||||
AbCipDataType.Structure => null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Holds pre-loaded <see cref="EquipmentNamespaceContent"/> snapshots keyed by
|
||||
/// <c>DriverInstanceId</c>. Populated once during <see cref="OpcUaServerService"/> startup
|
||||
/// (after <see cref="NodeBootstrap"/> resolves the generation) so the synchronous lookup
|
||||
/// delegate on <see cref="OpcUaApplicationHost"/> can serve the walker from memory without
|
||||
/// blocking on async DB I/O mid-dispatch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The registry is intentionally a shared mutable singleton with set-once-per-bootstrap
|
||||
/// semantics rather than an immutable map passed by value — the composition in Program.cs
|
||||
/// builds <see cref="OpcUaApplicationHost"/> before <see cref="NodeBootstrap"/> runs, so the
|
||||
/// registry must exist at DI-compose time but be empty until the generation is known. A
|
||||
/// driver registered after the initial populate pass simply returns null from
|
||||
/// <see cref="Get"/> + the wire-in falls back to the "no UNS content, let DiscoverAsync own
|
||||
/// it" path that PR #155 established.</para>
|
||||
/// </remarks>
|
||||
public sealed class DriverEquipmentContentRegistry
|
||||
{
|
||||
private readonly Dictionary<string, EquipmentNamespaceContent> _content =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public EquipmentNamespaceContent? Get(string driverInstanceId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _content.TryGetValue(driverInstanceId, out var c) ? c : null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string driverInstanceId, EquipmentNamespaceContent content)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_content[driverInstanceId] = content;
|
||||
}
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _content.Count; } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="EquipmentNamespaceContent"/> snapshot the
|
||||
/// <see cref="EquipmentNodeWalker"/> consumes, scoped to a single
|
||||
/// (driverInstanceId, generationId) pair. Joins the four row sets the walker expects:
|
||||
/// UnsAreas for the driver's cluster, UnsLines under those areas, Equipment bound to
|
||||
/// this driver + its lines, and Tags bound to this driver + its equipment — all at the
|
||||
/// supplied generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The walker is driver-instance-scoped (decisions #116–#121 put the UNS in the
|
||||
/// Equipment-kind namespace owned by one driver instance at a time), so this loader is
|
||||
/// too — a single call returns one driver's worth of rows, never the whole fleet.</para>
|
||||
///
|
||||
/// <para>Returns <c>null</c> when the driver instance has no Equipment rows at the
|
||||
/// supplied generation. The wire-in in <see cref="OpcUaApplicationHost"/> treats null as
|
||||
/// "this driver has no UNS content, skip the walker and let DiscoverAsync own the whole
|
||||
/// address space" — the backward-compat path for drivers whose namespace kind is not
|
||||
/// Equipment (Modbus / AB CIP / TwinCAT / FOCAS).</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentNamespaceContentLoader
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
public EquipmentNamespaceContentLoader(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the walker-shaped snapshot for <paramref name="driverInstanceId"/> at
|
||||
/// <paramref name="generationId"/>. Returns <c>null</c> when the driver has no
|
||||
/// Equipment rows at that generation.
|
||||
/// </summary>
|
||||
public async Task<EquipmentNamespaceContent?> LoadAsync(
|
||||
string driverInstanceId, long generationId, CancellationToken ct)
|
||||
{
|
||||
var equipment = await _db.Equipment
|
||||
.AsNoTracking()
|
||||
.Where(e => e.DriverInstanceId == driverInstanceId && e.GenerationId == generationId && e.Enabled)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (equipment.Count == 0)
|
||||
return null;
|
||||
|
||||
// Filter UNS tree to only the lines + areas that host at least one Equipment bound to
|
||||
// this driver — skips loading unrelated UNS branches from the cluster. LinesByArea
|
||||
// grouping is driven off the Equipment rows so an empty line (no equipment) doesn't
|
||||
// pull a pointless folder into the walker output.
|
||||
var lineIds = equipment.Select(e => e.UnsLineId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
var lines = await _db.UnsLines
|
||||
.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId && lineIds.Contains(l.UnsLineId))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var areaIds = lines.Select(l => l.UnsAreaId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
var areas = await _db.UnsAreas
|
||||
.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && areaIds.Contains(a.UnsAreaId))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Tags belonging to this driver at this generation. Walker skips Tags with null
|
||||
// EquipmentId (those are SystemPlatform-kind Galaxy tags per decision #120) but we
|
||||
// load them anyway so the same rowset can drive future non-Equipment-kind walks
|
||||
// without re-hitting the DB. Filtering here is a future optimization; today the
|
||||
// per-tag cost is bounded by driver scope.
|
||||
var tags = await _db.Tags
|
||||
.AsNoTracking()
|
||||
.Where(t => t.DriverInstanceId == driverInstanceId && t.GenerationId == generationId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return new EquipmentNamespaceContent(
|
||||
Areas: areas,
|
||||
Lines: lines,
|
||||
Equipment: equipment,
|
||||
Tags: tags);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly StaleConfigFlag? _staleConfigFlag;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
@@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
NodeScopeResolver? scopeResolver = null,
|
||||
StaleConfigFlag? staleConfigFlag = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null)
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
@@ -54,6 +56,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_staleConfigFlag = staleConfigFlag;
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_equipmentContentLookup = equipmentContentLookup;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -103,11 +106,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
// Drive each driver's discovery through its node manager. The node manager IS the
|
||||
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
|
||||
// its internal map and wires OnAlarmEvent → sink routing.
|
||||
//
|
||||
// ADR-001 Option A — when an EquipmentNamespaceContent is supplied for an
|
||||
// Equipment-kind driver, run the EquipmentNodeWalker BEFORE the driver's DiscoverAsync
|
||||
// so the UNS folder skeleton (Area/Line/Equipment) + Identification sub-folders +
|
||||
// the five identifier properties (decision #121) are in place. DiscoverAsync then
|
||||
// streams the driver's native shape on top; Tag rows bound to Equipment already
|
||||
// materialized via the walker don't get duplicated because the driver's DiscoverAsync
|
||||
// output is authoritative for its own native references only.
|
||||
foreach (var nodeManager in _server.DriverNodeManagers)
|
||||
{
|
||||
var driverId = nodeManager.Driver.DriverInstanceId;
|
||||
try
|
||||
{
|
||||
if (_equipmentContentLookup is not null)
|
||||
{
|
||||
var content = _equipmentContentLookup(driverId);
|
||||
if (content is not null)
|
||||
{
|
||||
ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNodeWalker.Walk(nodeManager, content);
|
||||
_logger.LogInformation(
|
||||
"UNS walker populated {Areas} area(s), {Lines} line(s), {Equipment} equipment, {Tags} tag(s) for driver {Driver}",
|
||||
content.Areas.Count, content.Lines.Count, content.Equipment.Count, content.Tags.Count, driverId);
|
||||
}
|
||||
}
|
||||
|
||||
var generic = new GenericDriverNodeManager(nodeManager.Driver);
|
||||
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
@@ -15,6 +16,8 @@ public sealed class OpcUaServerService(
|
||||
NodeBootstrap bootstrap,
|
||||
DriverHost driverHost,
|
||||
OpcUaApplicationHost applicationHost,
|
||||
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -24,6 +27,15 @@ public sealed class OpcUaServerService(
|
||||
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
|
||||
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
|
||||
|
||||
// ADR-001 Option A — populate per-driver Equipment namespace snapshots into the
|
||||
// registry before StartAsync walks the address space. The walker on the OPC UA side
|
||||
// reads synchronously from the registry; pre-loading here means the hot path stays
|
||||
// non-blocking + each driver pays at most one Config-DB query at bootstrap time.
|
||||
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
|
||||
// address space until the first publish, then the registry fills on next restart.
|
||||
if (result.GenerationId is { } gen)
|
||||
await PopulateEquipmentContentAsync(gen, stoppingToken);
|
||||
|
||||
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
|
||||
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
|
||||
// extension once the central config DB query + per-driver factory land; for now the
|
||||
@@ -48,4 +60,30 @@ public sealed class OpcUaServerService(
|
||||
await applicationHost.DisposeAsync();
|
||||
await driverHost.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-load an <c>EquipmentNamespaceContent</c> snapshot for each registered driver at
|
||||
/// the bootstrapped generation. Null results (driver has no Equipment rows —
|
||||
/// Modbus/AB CIP/TwinCAT/FOCAS today per decisions #116–#121) are skipped: the walker
|
||||
/// wire-in sees Get(driverId) return null + falls back to DiscoverAsync-owns-it.
|
||||
/// Opens one scope so the scoped <c>OtOpcUaConfigDbContext</c> is shared across all
|
||||
/// per-driver queries rather than paying scope-setup overhead per driver.
|
||||
/// </summary>
|
||||
private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var loader = scope.ServiceProvider.GetRequiredService<EquipmentNamespaceContentLoader>();
|
||||
|
||||
var loaded = 0;
|
||||
foreach (var driverId in driverHost.RegisteredDriverIds)
|
||||
{
|
||||
var content = await loader.LoadAsync(driverId, generationId, ct).ConfigureAwait(false);
|
||||
if (content is null) continue;
|
||||
equipmentContentRegistry.Set(driverId, content);
|
||||
loaded++;
|
||||
}
|
||||
logger.LogInformation(
|
||||
"Equipment namespace snapshots loaded for {Count}/{Total} driver(s) at generation {Gen}",
|
||||
loaded, driverHost.RegisteredDriverIds.Count, generationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,25 @@ builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
|
||||
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddSingleton<OpcUaApplicationHost>();
|
||||
|
||||
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
|
||||
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
|
||||
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
|
||||
// added to OpcUaApplicationHost's ctor seam.
|
||||
builder.Services.AddSingleton<DriverEquipmentContentRegistry>();
|
||||
builder.Services.AddScoped<EquipmentNamespaceContentLoader>();
|
||||
|
||||
builder.Services.AddSingleton<OpcUaApplicationHost>(sp =>
|
||||
{
|
||||
var registry = sp.GetRequiredService<DriverEquipmentContentRegistry>();
|
||||
return new OpcUaApplicationHost(
|
||||
sp.GetRequiredService<OpcUaServerOptions>(),
|
||||
sp.GetRequiredService<DriverHost>(),
|
||||
sp.GetRequiredService<IUserAuthenticator>(),
|
||||
sp.GetRequiredService<ILoggerFactory>(),
|
||||
sp.GetRequiredService<ILogger<OpcUaApplicationHost>>(),
|
||||
equipmentContentLookup: registry.Get);
|
||||
});
|
||||
builder.Services.AddHostedService<OpcUaServerService>();
|
||||
|
||||
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context
|
||||
|
||||
@@ -1,42 +1,83 @@
|
||||
using System.Collections.Frozen;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the
|
||||
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Today a simplified resolver that
|
||||
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
|
||||
/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
|
||||
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Supports two modes:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>Cluster-only (pre-ADR-001)</b> — when no path index is supplied the resolver
|
||||
/// returns a flat <c>ClusterId + TagId</c> scope. Sufficient while the
|
||||
/// Config-DB-driven Equipment walker isn't live; Cluster-level grants cascade to every
|
||||
/// tag below per decision #129, so finer per-Equipment grants are effectively
|
||||
/// cluster-wide at dispatch.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Full-path (post-ADR-001 Task B)</b> — when an index is supplied, the resolver
|
||||
/// joins the full reference against the index to produce a complete
|
||||
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> scope. Unblocks
|
||||
/// per-Equipment / per-UnsLine ACL grants at the dispatch layer.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
|
||||
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
|
||||
/// finer hierarchy only matters when operators want per-area or per-equipment grants;
|
||||
/// those still work for Cluster-level grants, and landing the finer resolution in a
|
||||
/// follow-up doesn't regress the base security model.</para>
|
||||
/// <para>The index is pre-loaded by the Server bootstrap against the published generation;
|
||||
/// the resolver itself does no live DB access. Resolve is O(1) dictionary lookup on the
|
||||
/// hot path; the fallback for unknown fullReference strings produces the same cluster-only
|
||||
/// scope the pre-ADR-001 resolver returned — new tags picked up via driver discovery but
|
||||
/// not yet indexed (e.g. between a DiscoverAsync result and the next generation publish)
|
||||
/// stay addressable without a scope-resolver crash.</para>
|
||||
///
|
||||
/// <para>Thread-safety: the resolver is stateless once constructed. Callers may cache a
|
||||
/// single instance per DriverNodeManager without locks.</para>
|
||||
/// <para>Thread-safety: both constructor paths freeze inputs into immutable state. Callers
|
||||
/// may cache a single instance per DriverNodeManager without locks. Swap atomically on
|
||||
/// generation change via the server's publish pipeline.</para>
|
||||
/// </remarks>
|
||||
public sealed class NodeScopeResolver
|
||||
{
|
||||
private readonly string _clusterId;
|
||||
private readonly FrozenDictionary<string, NodeScope>? _index;
|
||||
|
||||
/// <summary>Cluster-only resolver — pre-ADR-001 behavior. Kept for Server processes that
|
||||
/// haven't wired the Config-DB snapshot flow yet.</summary>
|
||||
public NodeScopeResolver(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
_clusterId = clusterId;
|
||||
_index = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full-path resolver (ADR-001 Task B). <paramref name="pathIndex"/> maps each known
|
||||
/// driver-side full reference to its pre-resolved <see cref="NodeScope"/> carrying
|
||||
/// every UNS level populated. Entries are typically produced by joining
|
||||
/// <c>Tag → Equipment → UnsLine → UnsArea</c> rows of the published generation against
|
||||
/// the driver's discovered full references (or against <c>Tag.TagConfig</c> directly
|
||||
/// when the walker is config-primary per ADR-001 Option A).
|
||||
/// </summary>
|
||||
public NodeScopeResolver(string clusterId, IReadOnlyDictionary<string, NodeScope> pathIndex)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentNullException.ThrowIfNull(pathIndex);
|
||||
_clusterId = clusterId;
|
||||
_index = pathIndex.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
|
||||
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only;
|
||||
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
|
||||
/// join against the Configuration DB to populate the full path.
|
||||
/// Returns the indexed full-path scope when available; falls back to cluster-only
|
||||
/// (TagId populated only) when the index is absent or the reference isn't indexed.
|
||||
/// The fallback is the same shape the pre-ADR-001 resolver produced, so the authz
|
||||
/// evaluator behaves identically for un-indexed references.
|
||||
/// </summary>
|
||||
public NodeScope Resolve(string fullReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
|
||||
|
||||
if (_index is not null && _index.TryGetValue(fullReference, out var indexed))
|
||||
return indexed;
|
||||
|
||||
return new NodeScope
|
||||
{
|
||||
ClusterId = _clusterId,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="NodeScope"/> path index consumed by <see cref="NodeScopeResolver"/>
|
||||
/// from a Config-DB snapshot of a single published generation. Runs once per generation
|
||||
/// (or on every generation change) at the Server bootstrap layer; the produced index is
|
||||
/// immutable + hot-path readable per ADR-001 Task B.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The index key is the driver-side full reference (<c>Tag.TagConfig</c>) — the same
|
||||
/// string the dispatch layer passes to <see cref="NodeScopeResolver.Resolve"/>. The value
|
||||
/// is a <see cref="NodeScope"/> with every UNS level populated:
|
||||
/// <c>ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId</c>. Tag rows
|
||||
/// with null <c>EquipmentId</c> (SystemPlatform-namespace Galaxy tags per decision #120)
|
||||
/// are excluded from the index — the cluster-only fallback path in the resolver handles
|
||||
/// them without needing an index entry.</para>
|
||||
///
|
||||
/// <para>Duplicate keys are not expected but would be indicative of corrupt data — the
|
||||
/// builder throws <see cref="InvalidOperationException"/> on collision so a config drift
|
||||
/// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch.</para>
|
||||
/// </remarks>
|
||||
public static class ScopePathIndexBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a fullReference → NodeScope index from the four Config-DB collections for a
|
||||
/// single namespace. Callers must filter inputs to a single
|
||||
/// <see cref="Namespace"/> + the same <see cref="ConfigGeneration"/> upstream.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">Owning cluster — populates <see cref="NodeScope.ClusterId"/>.</param>
|
||||
/// <param name="namespaceId">Owning namespace — populates <see cref="NodeScope.NamespaceId"/>.</param>
|
||||
/// <param name="content">Pre-loaded rows for the namespace.</param>
|
||||
public static IReadOnlyDictionary<string, NodeScope> Build(
|
||||
string clusterId,
|
||||
string namespaceId,
|
||||
EquipmentNamespaceContent content)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase);
|
||||
var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var index = new Dictionary<string, NodeScope>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var tag in content.Tags)
|
||||
{
|
||||
// Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the
|
||||
// cluster-only resolver fallback handles those without needing an index entry.
|
||||
if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
|
||||
|
||||
// Broken FK — Tag references a missing Equipment row. Skip rather than crash;
|
||||
// sp_ValidateDraft should have caught this at publish, so any drift here is
|
||||
// unexpected but non-fatal.
|
||||
if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue;
|
||||
if (!areaByLine.TryGetValue(lineId, out var areaId)) continue;
|
||||
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
NamespaceId = namespaceId,
|
||||
UnsAreaId = areaId,
|
||||
UnsLineId = lineId,
|
||||
EquipmentId = tag.EquipmentId,
|
||||
TagId = tag.TagConfig,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
if (!index.TryAdd(tag.TagConfig, scope))
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " +
|
||||
"Config data is corrupt — two Tag rows produced the same wire-level address.");
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentNodeWalkerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Walk_EmptyContent_EmitsNothing()
|
||||
{
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], []));
|
||||
|
||||
rec.Children.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
|
||||
Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
|
||||
Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")],
|
||||
Tags: []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name
|
||||
var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
|
||||
warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
|
||||
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.EquipmentUuid = uuid;
|
||||
eq.MachineCode = "MC-42";
|
||||
eq.ZTag = null;
|
||||
eq.SAPID = null;
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
|
||||
props.ShouldContain("EquipmentId");
|
||||
props.ShouldContain("EquipmentUuid");
|
||||
props.ShouldContain("MachineCode");
|
||||
props.ShouldNotContain("ZTag");
|
||||
props.ShouldNotContain("SAPID");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Adds_ZTag_And_SAPID_When_Present()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.ZTag = "ZT-0042";
|
||||
eq.SAPID = "10000042";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.Manufacturer = "Trumpf";
|
||||
eq.Model = "TruLaser-3030";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
|
||||
identification.ShouldNotBeNull();
|
||||
identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
|
||||
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1");
|
||||
var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1");
|
||||
var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag1, tag2, unboundTag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Variables.Count.ShouldBe(2);
|
||||
equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]);
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01");
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var variable = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
// ----- builders for test seed rows -----
|
||||
|
||||
private static UnsArea Area(string id, string name) => new()
|
||||
{
|
||||
UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static UnsLine Line(string id, string areaId, string name) => new()
|
||||
{
|
||||
UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static Equipment Eq(string equipmentId, string lineId, string name) => new()
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv",
|
||||
UnsLineId = lineId,
|
||||
Name = name,
|
||||
MachineCode = "MC-" + name,
|
||||
};
|
||||
|
||||
private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
TagId = tagId,
|
||||
DriverInstanceId = "drv",
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite,
|
||||
TagConfig = address,
|
||||
};
|
||||
|
||||
// ----- recording IAddressSpaceBuilder -----
|
||||
|
||||
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
|
||||
{
|
||||
public string BrowseName { get; } = browseName;
|
||||
public List<RecordingBuilder> Children { get; } = new();
|
||||
public List<RecordingVariable> Variables { get; } = new();
|
||||
public List<RecordingProperty> Properties { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string name, string _)
|
||||
{
|
||||
var child = new RecordingBuilder(name);
|
||||
Children.Add(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
|
||||
{
|
||||
var v = new RecordingVariable(name, attr);
|
||||
Variables.Add(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType _, object? value) =>
|
||||
Properties.Add(new RecordingProperty(name, value));
|
||||
}
|
||||
|
||||
private sealed record RecordingProperty(string BrowseName, object? Value);
|
||||
|
||||
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
|
||||
{
|
||||
public string FullReference => AttributeInfo.FullName;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
|
||||
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipAlarmProjectionTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static AbCipTagDefinition AlmdTag(string name) => new(
|
||||
name, Device, name, AbCipDataType.Structure, Members:
|
||||
[
|
||||
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
|
||||
new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Severity", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("In", AbCipDataType.DInt),
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
|
||||
{
|
||||
var almd = AlmdTag("HighTemp");
|
||||
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
|
||||
|
||||
var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
|
||||
[new AbCipStructureMember("X", AbCipDataType.DInt)]);
|
||||
AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();
|
||||
|
||||
var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
|
||||
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_Mapping_Matches_OPC_UA_Convention()
|
||||
{
|
||||
// Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing.
|
||||
AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
|
||||
AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
|
||||
AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
|
||||
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = false, // explicit; also the default
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
|
||||
|
||||
// Wait a touch — if polling were active, a fake member-read would be triggered.
|
||||
await Task.Delay(100);
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
|
||||
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
|
||||
// gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick,
|
||||
// then flip to 1 (fault) + wait for the raise event.
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
|
||||
await Task.Delay(80); // let a tick seed the "last-seen false" state
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
|
||||
await Task.Delay(200); // allow several polls to be safe
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
|
||||
&& e.Message.Contains("raised"));
|
||||
}
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_Event_Fires_On_1_to_0_Transition()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
await Task.Delay(80); // observe raise
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
|
||||
await Task.Delay(200);
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.Message.Contains("raised"));
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
}
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_Stops_The_Poll_Loop()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await Task.Delay(100); // well past several poll intervals if the loop were still alive
|
||||
|
||||
var postDelayReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
// Allow at most one straggler read between the unsubscribe-cancel + the loop exit.
|
||||
(postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (factory.Tags.ContainsKey(tagName)) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — ReadAsync integration tests for the whole-UDT grouping path. The fake
|
||||
/// runtime records ReadCount + surfaces member values by byte offset so we can assert
|
||||
/// both "one read per parent UDT" and "each member decoded at the correct offset."
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverWholeUdtReadTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = tags,
|
||||
};
|
||||
return (new AbCipDriver(opts, "drv-1", factory), factory);
|
||||
}
|
||||
|
||||
private static AbCipTagDefinition MotorUdt() => new(
|
||||
"Motor", Device, "Motor", AbCipDataType.Structure, Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt), // offset 0
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public async Task Two_members_of_same_udt_trigger_one_parent_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Factory should have created ONE runtime (for the parent "Motor") + issued ONE read.
|
||||
// Without the optimization two runtimes (one per member) + two reads would appear.
|
||||
factory.Tags.Count.ShouldBe(1);
|
||||
factory.Tags.ShouldContainKey("Motor");
|
||||
factory.Tags["Motor"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_member_decodes_at_its_own_offset()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Arrange the offset-keyed values before the read fires — the planner places
|
||||
// Speed at offset 0 (DInt) and Torque at offset 4 (Real).
|
||||
// The fake records CreationParams so we fetch it up front by the parent name.
|
||||
var snapshotsTask = drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
// The factory creates the runtime inside ReadAsync; we need to set the offset map
|
||||
// AFTER creation. Easier path: create the runtime on demand by reading once then
|
||||
// re-arming. Instead: seed via a pre-read by constructing the fake in the factory's
|
||||
// customise hook.
|
||||
var snapshots = await snapshotsTask;
|
||||
|
||||
// First run establishes the runtime + gives the fake a chance to hold its reference.
|
||||
factory.Tags["Motor"].ValuesByOffset[0] = 1234; // Speed
|
||||
factory.Tags["Motor"].ValuesByOffset[4] = 9.5f; // Torque
|
||||
|
||||
snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe(1234);
|
||||
snapshots[1].Value.ShouldBe(9.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Prime runtime existence via a first (successful) read so we can flip it to error.
|
||||
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots[0].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
|
||||
snapshots[0].Value.ShouldBeNull();
|
||||
snapshots[1].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
|
||||
{
|
||||
var plain = new AbCipTagDefinition("PlainDint", Device, "PlainDint", AbCipDataType.DInt);
|
||||
var (drv, factory) = NewDriver(MotorUdt(), plain);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["Motor.Speed", "PlainDint", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
// Motor parent ran one read, PlainDint ran its own read = 2 runtimes, 2 reads total.
|
||||
factory.Tags.Count.ShouldBe(2);
|
||||
factory.Tags.ShouldContainKey("Motor");
|
||||
factory.Tags.ShouldContainKey("PlainDint");
|
||||
factory.Tags["Motor"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_member_of_Udt_uses_per_tag_read_path()
|
||||
{
|
||||
// One member of a UDT doesn't benefit from grouping — the planner demotes to
|
||||
// fallback so the member-level runtime (distinct from the parent runtime) is used,
|
||||
// matching pre-#194 behavior.
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Motor.Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags.ShouldContainKey("Motor.Speed");
|
||||
factory.Tags.ShouldNotContainKey("Motor");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUdtMemberLayoutTests
|
||||
{
|
||||
[Fact]
|
||||
public void Packed_Atomics_Get_Natural_Alignment_Offsets()
|
||||
{
|
||||
// DInt (4 align) + Real (4) + Int (2) + LInt (8 — forces 2-byte pad before it)
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("A", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("B", AbCipDataType.Real),
|
||||
new AbCipStructureMember("C", AbCipDataType.Int),
|
||||
new AbCipStructureMember("D", AbCipDataType.LInt),
|
||||
};
|
||||
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(members);
|
||||
offsets.ShouldNotBeNull();
|
||||
offsets!["A"].ShouldBe(0);
|
||||
offsets["B"].ShouldBe(4);
|
||||
offsets["C"].ShouldBe(8);
|
||||
// cursor at 10 after Int; LInt needs 8-byte alignment → pad to 16
|
||||
offsets["D"].ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SInt_Packed_Without_Padding()
|
||||
{
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("X", AbCipDataType.SInt),
|
||||
new AbCipStructureMember("Y", AbCipDataType.SInt),
|
||||
new AbCipStructureMember("Z", AbCipDataType.SInt),
|
||||
};
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(members);
|
||||
offsets!["X"].ShouldBe(0);
|
||||
offsets["Y"].ShouldBe(1);
|
||||
offsets["Z"].ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_When_Member_Is_Bool()
|
||||
{
|
||||
// BOOL storage in Logix UDTs is packed into a hidden host byte; declaration-only
|
||||
// layout can't place it. Grouping opts out; per-tag read path handles the member.
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("A", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Flag", AbCipDataType.Bool),
|
||||
};
|
||||
AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_When_Member_Is_String_Or_Structure()
|
||||
{
|
||||
AbCipUdtMemberLayout.TryBuild(
|
||||
new[] { new AbCipStructureMember("Name", AbCipDataType.String) }).ShouldBeNull();
|
||||
AbCipUdtMemberLayout.TryBuild(
|
||||
new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_On_Empty_Members()
|
||||
{
|
||||
AbCipUdtMemberLayout.TryBuild(Array.Empty<AbCipStructureMember>()).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUdtReadPlannerTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.1/1,0";
|
||||
|
||||
[Fact]
|
||||
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags);
|
||||
|
||||
plan.Groups.Count.ShouldBe(1);
|
||||
plan.Groups[0].ParentName.ShouldBe("Motor");
|
||||
plan.Groups[0].Members.Count.ShouldBe(2);
|
||||
plan.Fallbacks.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
|
||||
{
|
||||
// Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
|
||||
// vs one member read is equivalent cost but more client-side work. Planner demotes.
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags);
|
||||
|
||||
plan.Groups.ShouldBeEmpty();
|
||||
plan.Fallbacks.Count.ShouldBe(1);
|
||||
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_References_Fall_Back_Without_Affecting_Groups()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(
|
||||
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
|
||||
|
||||
plan.Groups.Count.ShouldBe(1);
|
||||
plan.Groups[0].Members.Count.ShouldBe(2);
|
||||
plan.Fallbacks.Count.ShouldBe(2);
|
||||
plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist");
|
||||
plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
tags = new Dictionary<string, AbCipTagDefinition>(tags, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
|
||||
};
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
|
||||
|
||||
plan.Groups.Count.ShouldBe(1);
|
||||
plan.Fallbacks.Count.ShouldBe(1);
|
||||
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Udt_With_Bool_Member_Does_Not_Group()
|
||||
{
|
||||
// Any BOOL in the declared members disqualifies the group — offset rules for BOOL
|
||||
// can't be determined from declaration alone (Logix packs them into a hidden host
|
||||
// byte). Fallback path reads each member individually.
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("Run", AbCipDataType.Bool),
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
};
|
||||
var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
|
||||
Members: members);
|
||||
var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Motor"] = parent,
|
||||
["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool),
|
||||
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||
};
|
||||
|
||||
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
|
||||
|
||||
plan.Groups.ShouldBeEmpty();
|
||||
plan.Fallbacks.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
|
||||
{
|
||||
var tags = BuildUdtTagMap(out var _);
|
||||
var plan = AbCipUdtReadPlanner.Build(
|
||||
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
|
||||
|
||||
// Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
|
||||
// ReadAsync can write decoded values back at the right output slot.
|
||||
plan.Groups.ShouldHaveSingleItem();
|
||||
var group = plan.Groups[0];
|
||||
group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed");
|
||||
group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque");
|
||||
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other");
|
||||
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist");
|
||||
}
|
||||
|
||||
private static Dictionary<string, AbCipTagDefinition> BuildUdtTagMap(out AbCipTagDefinition parent)
|
||||
{
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||
};
|
||||
parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members);
|
||||
return new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Motor"] = parent,
|
||||
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,21 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
|
||||
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 whole-UDT read support. Tests drive multi-member decoding by setting
|
||||
/// <see cref="ValuesByOffset"/> — keyed by member byte offset — before invoking
|
||||
/// <see cref="AbCipDriver.ReadAsync"/>. Falls back to <see cref="Value"/> when the
|
||||
/// offset is zero or unmapped so existing tests that never set the offset map keep
|
||||
/// working unchanged.
|
||||
/// </summary>
|
||||
public Dictionary<int, object?> ValuesByOffset { get; } = new();
|
||||
|
||||
public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
|
||||
{
|
||||
if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
|
||||
return offset == 0 ? Value : null;
|
||||
}
|
||||
|
||||
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverEquipmentContentRegistryTests
|
||||
{
|
||||
private static readonly EquipmentNamespaceContent EmptyContent =
|
||||
new(Areas: [], Lines: [], Equipment: [], Tags: []);
|
||||
|
||||
[Fact]
|
||||
public void Get_Returns_Null_For_Unknown_Driver()
|
||||
{
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
registry.Get("galaxy-prod").ShouldBeNull();
|
||||
registry.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_Then_Get_Returns_Stored_Content()
|
||||
{
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
registry.Set("galaxy-prod", EmptyContent);
|
||||
|
||||
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
|
||||
registry.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Is_Case_Insensitive_For_Driver_Id()
|
||||
{
|
||||
// DriverInstanceId keys are OrdinalIgnoreCase across the codebase (Equipment /
|
||||
// Tag rows, walker grouping). Registry matches that contract so callers don't have
|
||||
// to canonicalize driver ids before lookup.
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
registry.Set("Galaxy-Prod", EmptyContent);
|
||||
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
|
||||
registry.Get("GALAXY-PROD").ShouldBeSameAs(EmptyContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_Overwrites_Existing_Content_For_Same_Driver()
|
||||
{
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
var first = EmptyContent;
|
||||
var second = new EquipmentNamespaceContent([], [], [], []);
|
||||
|
||||
registry.Set("galaxy-prod", first);
|
||||
registry.Set("galaxy-prod", second);
|
||||
|
||||
registry.Get("galaxy-prod").ShouldBeSameAs(second);
|
||||
registry.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
|
||||
/// Walks the full dispatch flow for a read against an Equipment / Identification
|
||||
/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
|
||||
/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
|
||||
/// without the Equipment-scope grant gets denied on the Identification sub-folder the
|
||||
/// same way they would be denied on the Equipment node itself, because they share the
|
||||
/// Equipment ScopeId (no new scope level for Identification per the builder's remark
|
||||
/// section).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentIdentificationAuthzTests
|
||||
{
|
||||
private const string Cluster = "c-warsaw";
|
||||
private const string Namespace = "ns-plc";
|
||||
|
||||
[Fact]
|
||||
public void Authorized_Group_Read_Granted_On_Identification_Property()
|
||||
{
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unauthorized_Group_Read_Denied_On_Identification_Property()
|
||||
{
|
||||
// The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
|
||||
// without the grant gets BadUserAccessDenied on both the Equipment node + its
|
||||
// Identification variables." This test proves the evaluator side of that contract;
|
||||
// the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
|
||||
// already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("bob", ["cn=other-team"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
|
||||
{
|
||||
// Identification properties share their parent Equipment's ScopeId (no new scope
|
||||
// level). An Equipment-scope grant must therefore read both — the Equipment's tag
|
||||
// AND its Identification properties — via the same evaluator call path.
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
|
||||
var tagScope = resolver.Resolve("plcaddr-temperature");
|
||||
var identityScope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
|
||||
{
|
||||
// Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
|
||||
// so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
|
||||
// motivation for populating the full UNS path at resolve time.
|
||||
var (gate, resolver) = BuildEvaluator(
|
||||
equipmentGrantGroup: "cn=oven-3-operators",
|
||||
equipmentIdForGrant: "eq-oven-3");
|
||||
|
||||
var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
|
||||
|
||||
var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- harness -----
|
||||
|
||||
/// <summary>
|
||||
/// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
|
||||
/// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
|
||||
/// Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
|
||||
/// populated via ScopePathIndexBuilder from the same Config-DB row set the
|
||||
/// EquipmentNodeWalker would consume at address-space build.
|
||||
/// </summary>
|
||||
private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
|
||||
string equipmentGrantGroup,
|
||||
string equipmentIdForGrant = "eq-oven-3")
|
||||
{
|
||||
var (content, scopeIndex) = BuildFixture();
|
||||
var resolver = new NodeScopeResolver(Cluster, scopeIndex);
|
||||
|
||||
var aclRow = new NodeAcl
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = Cluster,
|
||||
LdapGroup = equipmentGrantGroup,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = equipmentIdForGrant,
|
||||
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
|
||||
};
|
||||
var paths = new Dictionary<string, NodeAclPath>
|
||||
{
|
||||
[equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
|
||||
};
|
||||
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
var gate = new AuthorizationGate(evaluator, strictMode: true);
|
||||
|
||||
_ = content;
|
||||
return (gate, resolver);
|
||||
}
|
||||
|
||||
private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
|
||||
{
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
|
||||
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
||||
|
||||
var oven = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
|
||||
MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
|
||||
};
|
||||
var press = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
|
||||
MachineCode = "MC-press-7",
|
||||
};
|
||||
|
||||
// Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
|
||||
// reference the dispatch layer passes to NodeScopeResolver.Resolve.
|
||||
var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
|
||||
var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
|
||||
var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
|
||||
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [area],
|
||||
Lines: [line],
|
||||
Equipment: [oven, press],
|
||||
Tags: [tempTag, mfgTag, pressTempTag]);
|
||||
|
||||
var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
|
||||
return (content, index);
|
||||
}
|
||||
|
||||
private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
|
||||
DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
|
||||
DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
|
||||
};
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentNamespaceContentLoaderTests : IDisposable
|
||||
{
|
||||
private const string DriverId = "galaxy-prod";
|
||||
private const string OtherDriverId = "galaxy-dev";
|
||||
private const long Gen = 5;
|
||||
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly EquipmentNamespaceContentLoader _loader;
|
||||
|
||||
public EquipmentNamespaceContentLoaderTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"eq-content-loader-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
_loader = new EquipmentNamespaceContentLoader(_db);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_Null_When_Driver_Has_No_Equipment_At_Generation()
|
||||
{
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Loads_Areas_Lines_Equipment_Tags_For_Driver_At_Generation()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Areas.ShouldHaveSingleItem().UnsAreaId.ShouldBe("area-1");
|
||||
result.Lines.ShouldHaveSingleItem().UnsLineId.ShouldBe("line-a");
|
||||
result.Equipment.Count.ShouldBe(2);
|
||||
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-oven-3");
|
||||
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-press-7");
|
||||
result.Tags.Count.ShouldBe(2);
|
||||
result.Tags.ShouldContain(t => t.TagId == "tag-temp");
|
||||
result.Tags.ShouldContain(t => t.TagId == "tag-press");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_Other_Drivers_Equipment()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
// Equipment + Tag owned by a different driver at the same generation — must not leak.
|
||||
_db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-other", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = OtherDriverId, UnsLineId = "line-a", Name = "other-eq",
|
||||
MachineCode = "MC-other",
|
||||
});
|
||||
_db.Tags.Add(new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-other",
|
||||
DriverInstanceId = OtherDriverId, EquipmentId = "eq-other",
|
||||
Name = "OtherTag", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-other",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-other");
|
||||
result.Tags.ShouldNotContain(t => t.TagId == "tag-other");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_Other_Generations()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
// Same driver, different generation — must not leak in. Walker consumes one sealed
|
||||
// generation per bootstrap per decision #148.
|
||||
_db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 99,
|
||||
EquipmentId = "eq-futuristic", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "futuristic",
|
||||
MachineCode = "MC-fut",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-futuristic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_Disabled_Equipment()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
_db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-disabled", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "disabled-eq",
|
||||
MachineCode = "MC-dis", Enabled = false,
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-disabled");
|
||||
}
|
||||
|
||||
private void SeedBaseline()
|
||||
{
|
||||
_db.UnsAreas.Add(new UnsArea
|
||||
{
|
||||
UnsAreaRowId = Guid.NewGuid(), UnsAreaId = "area-1", ClusterId = "c-warsaw",
|
||||
Name = "warsaw", GenerationId = Gen,
|
||||
});
|
||||
_db.UnsLines.Add(new UnsLine
|
||||
{
|
||||
UnsLineRowId = Guid.NewGuid(), UnsLineId = "line-a", UnsAreaId = "area-1",
|
||||
Name = "line-a", GenerationId = Gen,
|
||||
});
|
||||
_db.Equipment.AddRange(
|
||||
new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
|
||||
MachineCode = "MC-oven-3",
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "press-7",
|
||||
MachineCode = "MC-press-7",
|
||||
});
|
||||
_db.Tags.AddRange(
|
||||
new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-temp",
|
||||
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
|
||||
Name = "Temperature", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
|
||||
},
|
||||
new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-press",
|
||||
DriverInstanceId = DriverId, EquipmentId = "eq-press-7",
|
||||
Name = "PressTemp", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-press-temp",
|
||||
});
|
||||
_db.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,59 @@ public sealed class NodeScopeResolverTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Leaves_UnsPath_Null_For_Phase1()
|
||||
public void Resolve_Leaves_UnsPath_Null_When_NoIndexSupplied()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-1");
|
||||
|
||||
var scope = resolver.Resolve("tag-1");
|
||||
|
||||
// Phase 1 flat scope — finer resolution tracked as Stream C.12 follow-up.
|
||||
// Cluster-only fallback path — used pre-ADR-001 and still the active path for
|
||||
// unindexed references (e.g. driver-discovered tags that have no Tag row yet).
|
||||
scope.NamespaceId.ShouldBeNull();
|
||||
scope.UnsAreaId.ShouldBeNull();
|
||||
scope.UnsLineId.ShouldBeNull();
|
||||
scope.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Returns_IndexedScope_When_FullReferenceFound()
|
||||
{
|
||||
var index = new Dictionary<string, NodeScope>
|
||||
{
|
||||
["plcaddr-01"] = new NodeScope
|
||||
{
|
||||
ClusterId = "c-1", NamespaceId = "ns-plc", UnsAreaId = "area-1",
|
||||
UnsLineId = "line-a", EquipmentId = "eq-oven-3", TagId = "plcaddr-01",
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
},
|
||||
};
|
||||
var resolver = new NodeScopeResolver("c-1", index);
|
||||
|
||||
var scope = resolver.Resolve("plcaddr-01");
|
||||
|
||||
scope.UnsAreaId.ShouldBe("area-1");
|
||||
scope.UnsLineId.ShouldBe("line-a");
|
||||
scope.EquipmentId.ShouldBe("eq-oven-3");
|
||||
scope.TagId.ShouldBe("plcaddr-01");
|
||||
scope.NamespaceId.ShouldBe("ns-plc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FallsBack_To_ClusterOnly_When_Reference_NotIndexed()
|
||||
{
|
||||
var index = new Dictionary<string, NodeScope>
|
||||
{
|
||||
["plcaddr-01"] = new NodeScope { ClusterId = "c-1", TagId = "plcaddr-01", Kind = NodeHierarchyKind.Equipment },
|
||||
};
|
||||
var resolver = new NodeScopeResolver("c-1", index);
|
||||
|
||||
var scope = resolver.Resolve("not-in-index");
|
||||
|
||||
scope.ClusterId.ShouldBe("c-1");
|
||||
scope.TagId.ShouldBe("not-in-index");
|
||||
scope.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Throws_OnEmptyFullReference()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when
|
||||
/// <see cref="OpcUaApplicationHost"/> is given an <c>equipmentContentLookup</c> that
|
||||
/// returns a non-null <see cref="EquipmentNamespaceContent"/>, the walker runs BEFORE
|
||||
/// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) +
|
||||
/// identifier properties are materialized into the driver's namespace + visible to an
|
||||
/// OPC UA client via standard browse.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}");
|
||||
private const string DriverId = "galaxy-prod";
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None);
|
||||
|
||||
var content = BuildFixture();
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaWalkerTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:WalkerTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
HealthEndpointsEnabled = false,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(
|
||||
options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance,
|
||||
equipmentContentLookup: id => id == DriverId ? content : null);
|
||||
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
||||
|
||||
var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex);
|
||||
var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex);
|
||||
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
||||
|
||||
BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a");
|
||||
BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3");
|
||||
|
||||
var equipmentChildren = BrowseChildren(session, equipmentFolder);
|
||||
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId");
|
||||
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid");
|
||||
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
||||
|
||||
var tagNode = new NodeId("plcaddr-temperature", nsIndex);
|
||||
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
||||
|
||||
BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature");
|
||||
|
||||
var dv = session.ReadValue(tagNode);
|
||||
dv.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node)
|
||||
{
|
||||
session.Browse(null, null, node, 0, BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences, true,
|
||||
(uint)NodeClass.Object | (uint)NodeClass.Variable,
|
||||
out _, out var refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
private static EquipmentNamespaceContent BuildFixture()
|
||||
{
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 };
|
||||
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
||||
var oven = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
|
||||
MachineCode = "MC-oven-3",
|
||||
};
|
||||
var tempTag = new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1",
|
||||
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
|
||||
Name = "Temperature", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
|
||||
};
|
||||
|
||||
return new EquipmentNamespaceContent(
|
||||
Areas: new[] { area },
|
||||
Lines: new[] { line },
|
||||
Equipment: new[] { oven },
|
||||
Tags: new[] { tempTag });
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaWalkerTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:WalkerTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaWalkerTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver that registers into the host + implements DiscoverAsync as a no-op. The
|
||||
/// walker is the sole source of address-space content; if the UNS folders appear
|
||||
/// under browse, they came from the wire-in (not from the driver's own discovery).
|
||||
/// </summary>
|
||||
private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable
|
||||
{
|
||||
public EmptyDriver(string id) { DriverInstanceId = id; }
|
||||
public string DriverInstanceId { get; }
|
||||
public string DriverType => "EmptyForWalkerTest";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> result =
|
||||
fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user