chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
using System.Text.Json;
|
||||
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);
|
||||
|
||||
var virtualTagsByEquipment = (content.VirtualTags ?? [])
|
||||
.Where(v => v.Enabled)
|
||||
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
|
||||
.Where(a => a.Enabled)
|
||||
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.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))
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
|
||||
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
|
||||
foreach (var vtag in vTags)
|
||||
AddVirtualTagVariable(equipmentBuilder, vtag);
|
||||
|
||||
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
|
||||
foreach (var alarm in alarms)
|
||||
AddScriptedAlarmVariable(equipmentBuilder, alarm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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: ExtractFullName(tag.TagConfig),
|
||||
DriverDataType: ParseDriverDataType(tag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: false);
|
||||
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cross-driver TagConfig convention — the Config DB's <c>CK_Tag_TagConfig_IsJson</c>
|
||||
/// check constraint requires TagConfig to be a JSON object, and every shipped driver
|
||||
/// (Galaxy / Modbus / AB CIP / S7 / FOCAS / TwinCAT / ABLegacy) stores the wire-level
|
||||
/// address in a top-level <c>FullName</c> field. Extracting it here keeps the walker
|
||||
/// driver-agnostic while giving the driver the plain address string its backend
|
||||
/// expects at read-time — the raw JSON would otherwise be passed verbatim to
|
||||
/// <c>IReadable.ReadAsync</c> and the driver would fail to resolve the tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Falls back to the raw <paramref name="tagConfig"/> if it doesn't parse as JSON or
|
||||
/// the <c>FullName</c> field is absent. This preserves the pre-refactor behaviour for
|
||||
/// any legacy row that slipped past the check constraint or any future driver that
|
||||
/// wants an opaque non-JSON reference.
|
||||
/// </remarks>
|
||||
internal static string ExtractFullName(string tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||
&& fullName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return fullName.GetString() ?? tagConfig;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* fall through */ }
|
||||
return tagConfig;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
|
||||
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
|
||||
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
|
||||
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
|
||||
/// driver.
|
||||
/// </summary>
|
||||
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: vtag.VirtualTagId,
|
||||
DriverDataType: ParseDriverDataType(vtag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: vtag.Historize,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false,
|
||||
Source: NodeSourceKind.Virtual,
|
||||
VirtualTagId: vtag.VirtualTagId,
|
||||
ScriptedAlarmId: null);
|
||||
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
|
||||
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
|
||||
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
|
||||
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
|
||||
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
|
||||
/// materialization path.
|
||||
/// </summary>
|
||||
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: alarm.ScriptedAlarmId,
|
||||
DriverDataType: DriverDataType.Boolean,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: false,
|
||||
IsAlarm: true,
|
||||
WriteIdempotent: false,
|
||||
Source: NodeSourceKind.ScriptedAlarm,
|
||||
VirtualTagId: null,
|
||||
ScriptedAlarmId: alarm.ScriptedAlarmId);
|
||||
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
IReadOnlyList<VirtualTag>? VirtualTags = null,
|
||||
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Generic, driver-agnostic backbone for populating the OPC UA address space from an
|
||||
/// <see cref="IDriver"/>. Walks the driver's discovery, wires the alarm + data-change +
|
||||
/// rediscovery subscription events, and hands each variable to the supplied
|
||||
/// <see cref="IAddressSpaceBuilder"/>. Concrete OPC UA server implementations provide the
|
||||
/// builder — see the Server project's <c>OpcUaAddressSpaceBuilder</c> for the materialization
|
||||
/// against <c>CustomNodeManager2</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #52 + #62 — Core owns the node tree, drivers stream
|
||||
/// <c>Folder</c>/<c>Variable</c> calls, alarm-bearing variables are annotated via
|
||||
/// <see cref="IVariableHandle.MarkAsAlarmCondition"/> and subsequent
|
||||
/// <see cref="IAlarmSource.OnAlarmEvent"/> payloads route to the sink the builder returned.
|
||||
/// </remarks>
|
||||
public class GenericDriverNodeManager(IDriver driver) : IDisposable
|
||||
{
|
||||
protected IDriver Driver { get; } = driver ?? throw new ArgumentNullException(nameof(driver));
|
||||
|
||||
public string DriverInstanceId => Driver.DriverInstanceId;
|
||||
|
||||
// Source tag (DriverAttributeInfo.FullName) → alarm-condition sink. Populated during
|
||||
// BuildAddressSpaceAsync by a recording IAddressSpaceBuilder implementation that captures the
|
||||
// IVariableHandle per attr.IsAlarm=true variable and calls MarkAsAlarmCondition.
|
||||
private readonly ConcurrentDictionary<string, IAlarmConditionSink> _alarmSinks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private EventHandler<AlarmEventArgs>? _alarmForwarder;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Populates the address space by streaming nodes from the driver into the supplied builder,
|
||||
/// wraps the builder so alarm-condition sinks are captured, subscribes to the driver's
|
||||
/// alarm event stream, and routes each transition to the matching sink by <c>SourceNodeId</c>.
|
||||
/// Driver exceptions are isolated per decision #12 — the driver's subtree is marked Faulted,
|
||||
/// but other drivers remain available.
|
||||
/// </summary>
|
||||
public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
if (Driver is not ITagDiscovery discovery)
|
||||
throw new NotSupportedException($"Driver '{Driver.DriverInstanceId}' does not implement ITagDiscovery.");
|
||||
|
||||
var capturing = new CapturingBuilder(builder, _alarmSinks);
|
||||
await discovery.DiscoverAsync(capturing, ct);
|
||||
|
||||
if (Driver is IAlarmSource alarmSource)
|
||||
{
|
||||
_alarmForwarder = (_, e) =>
|
||||
{
|
||||
// Route the alarm to the sink registered for the originating variable, if any.
|
||||
// Unknown source ids are dropped silently — they may belong to another driver or
|
||||
// to a variable the builder chose not to flag as an alarm condition.
|
||||
if (_alarmSinks.TryGetValue(e.SourceNodeId, out var sink))
|
||||
sink.OnTransition(e);
|
||||
};
|
||||
alarmSource.OnAlarmEvent += _alarmForwarder;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_alarmForwarder is not null && Driver is IAlarmSource alarmSource)
|
||||
{
|
||||
alarmSource.OnAlarmEvent -= _alarmForwarder;
|
||||
}
|
||||
_alarmSinks.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the current alarm-sink registry by source node id. Diagnostic + test hook;
|
||||
/// not part of the hot path.
|
||||
/// </summary>
|
||||
internal IReadOnlyCollection<string> TrackedAlarmSources => _alarmSinks.Keys.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the caller-supplied <see cref="IAddressSpaceBuilder"/> so every
|
||||
/// <see cref="IVariableHandle.MarkAsAlarmCondition"/> call registers the returned sink in
|
||||
/// the node manager's source-node-id map. The builder itself drives materialization;
|
||||
/// this wrapper only observes.
|
||||
/// </summary>
|
||||
private sealed class CapturingBuilder(
|
||||
IAddressSpaceBuilder inner,
|
||||
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> inner.AddProperty(browseName, dataType, value);
|
||||
}
|
||||
|
||||
private sealed class CapturingHandle(
|
||||
IVariableHandle inner,
|
||||
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
|
||||
{
|
||||
public string FullReference => inner.FullReference;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
{
|
||||
var sink = inner.MarkAsAlarmCondition(info);
|
||||
// Register by the driver-side full reference so the alarm forwarder can look it up
|
||||
// using AlarmEventArgs.SourceNodeId (which the driver populates with the same tag).
|
||||
sinks[inner.FullReference] = sink;
|
||||
return sink;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4 Stream D: materializes the OPC 40010 Machinery companion-spec Identification
|
||||
/// sub-folder under an Equipment node. Reads the nine decision-#139 columns off the
|
||||
/// <see cref="Equipment"/> row and emits one property per non-null field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Pure-function shape — testable without a real OPC UA node manager. The caller
|
||||
/// passes the builder scoped to the Equipment node; this class handles the Identification
|
||||
/// sub-folder creation + per-field <see cref="IAddressSpaceBuilder.AddProperty"/> calls.</para>
|
||||
///
|
||||
/// <para>ACL binding: the sub-folder + its properties inherit the Equipment scope's
|
||||
/// grants (no new scope level). Phase 6.2's trie treats them as part of the Equipment
|
||||
/// ScopeId — a user with Equipment-level grant reads Identification; a user without the
|
||||
/// grant gets BadUserAccessDenied on both the Equipment node + its Identification variables.
|
||||
/// See <c>docs/v2/acl-design.md</c> §Identification cross-reference.</para>
|
||||
///
|
||||
/// <para>The nine fields per decision #139 are exposed exactly when they carry a non-null
|
||||
/// value. A row with all nine null produces no Identification sub-folder at all — the
|
||||
/// caller can use <see cref="HasAnyFields(Equipment)"/> to skip the Folder call entirely
|
||||
/// and avoid a pointless empty folder appearing in browse trees.</para>
|
||||
/// </remarks>
|
||||
public static class IdentificationFolderBuilder
|
||||
{
|
||||
/// <summary>Browse + display name of the sub-folder — fixed per OPC 40010 convention.</summary>
|
||||
public const string FolderName = "Identification";
|
||||
|
||||
/// <summary>
|
||||
/// Canonical decision #139 field set exposed in the Identification sub-folder. Order
|
||||
/// matches the decision-log entry so any browse-order reader can cross-reference
|
||||
/// without re-sorting.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> FieldNames { get; } = new[]
|
||||
{
|
||||
"Manufacturer", "Model", "SerialNumber",
|
||||
"HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation",
|
||||
"ManufacturerUri", "DeviceManualUri",
|
||||
};
|
||||
|
||||
/// <summary>True when the equipment row has at least one non-null Identification field.</summary>
|
||||
public static bool HasAnyFields(Equipment equipment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(equipment);
|
||||
return equipment.Manufacturer is not null
|
||||
|| equipment.Model is not null
|
||||
|| equipment.SerialNumber is not null
|
||||
|| equipment.HardwareRevision is not null
|
||||
|| equipment.SoftwareRevision is not null
|
||||
|| equipment.YearOfConstruction is not null
|
||||
|| equipment.AssetLocation is not null
|
||||
|| equipment.ManufacturerUri is not null
|
||||
|| equipment.DeviceManualUri is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the Identification sub-folder under <paramref name="equipmentBuilder"/>. No-op
|
||||
/// when every field is null. Returns the sub-folder builder (or null when no-op) so
|
||||
/// callers can attach additional nodes underneath if needed.
|
||||
/// </summary>
|
||||
public static IAddressSpaceBuilder? Build(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(equipmentBuilder);
|
||||
ArgumentNullException.ThrowIfNull(equipment);
|
||||
|
||||
if (!HasAnyFields(equipment)) return null;
|
||||
|
||||
var folder = equipmentBuilder.Folder(FolderName, FolderName);
|
||||
AddIfPresent(folder, "Manufacturer", DriverDataType.String, equipment.Manufacturer);
|
||||
AddIfPresent(folder, "Model", DriverDataType.String, equipment.Model);
|
||||
AddIfPresent(folder, "SerialNumber", DriverDataType.String, equipment.SerialNumber);
|
||||
AddIfPresent(folder, "HardwareRevision", DriverDataType.String, equipment.HardwareRevision);
|
||||
AddIfPresent(folder, "SoftwareRevision", DriverDataType.String, equipment.SoftwareRevision);
|
||||
AddIfPresent(folder, "YearOfConstruction", DriverDataType.Int32,
|
||||
equipment.YearOfConstruction is null ? null : (object)(int)equipment.YearOfConstruction.Value);
|
||||
AddIfPresent(folder, "AssetLocation", DriverDataType.String, equipment.AssetLocation);
|
||||
AddIfPresent(folder, "ManufacturerUri", DriverDataType.String, equipment.ManufacturerUri);
|
||||
AddIfPresent(folder, "DeviceManualUri", DriverDataType.String, equipment.DeviceManualUri);
|
||||
return folder;
|
||||
}
|
||||
|
||||
private static void AddIfPresent(IAddressSpaceBuilder folder, string name, DriverDataType dataType, object? value)
|
||||
{
|
||||
if (value is null) return;
|
||||
folder.AddProperty(name, dataType, value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user