174 lines
9.6 KiB
C#
174 lines
9.6 KiB
C#
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);
|