52708bff52
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
1160 lines
66 KiB
C#
1160 lines
66 KiB
C#
using System.Diagnostics;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>Outcome of <see cref="AddressSpaceComposer.Compose"/> — pure value tuple, no side effects.
|
|
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
|
|
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
|
|
/// its parent line id so the applier knows where to hang each equipment folder.</summary>
|
|
public sealed record AddressSpaceComposition(
|
|
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
|
IReadOnlyList<UnsLineProjection> UnsLines,
|
|
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
|
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
|
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
|
|
{
|
|
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS data.</summary>
|
|
/// <param name="equipmentNodes">The equipment nodes.</param>
|
|
/// <param name="driverInstancePlans">The driver instance plans.</param>
|
|
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
|
|
public AddressSpaceComposition(
|
|
IReadOnlyList<EquipmentNode> equipmentNodes,
|
|
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
|
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
|
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
|
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
|
|
/// in an <c>Equipment</c>-kind namespace. <c>AddressSpaceApplier.MaterialiseEquipmentTags</c>
|
|
/// materialises each as a Variable under its existing equipment folder. Declared as an
|
|
/// init-only member defaulting to empty (rather than a positional parameter) so every existing
|
|
/// convenience constructor + call site keeps compiling unchanged; new producers set it via
|
|
/// initializer.
|
|
/// </summary>
|
|
public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
|
|
|
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
|
|
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
|
|
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
|
|
|
/// <summary>
|
|
/// Per-equipment scripted-alarm host plans — the richer analogue of the thin
|
|
/// <see cref="ScriptedAlarmPlans"/> projection. Each carries the fully-resolved predicate
|
|
/// source (joined from its <see cref="Script"/>) and the merged dependency graph (predicate
|
|
/// <c>ctx.GetTag("…")</c> reads UNION <c>{TagPath}</c> tokens in the message template) so the
|
|
/// runtime alarm host can subscribe to every signal and evaluate the predicate. See
|
|
/// <see cref="EquipmentScriptedAlarmPlan"/>. Init-only, defaults empty so every existing
|
|
/// constructor + call site keeps compiling unchanged.
|
|
/// </summary>
|
|
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
|
|
|
// ----- v3 Batch 4 dual-namespace address space (un-darkened) -----
|
|
|
|
/// <summary>
|
|
/// The Raw subtree's <b>container</b> nodes (Folder → Driver → Device → TagGroup) as
|
|
/// <c>Object</c>/<c>Folder</c> nodes in the <see cref="AddressSpaceRealm.Raw"/> namespace. Each
|
|
/// carries its <c>s=<RawPath></c> NodeId and its parent NodeId (<see langword="null"/> = cluster
|
|
/// root) so <c>AddressSpaceApplier</c> can materialise parents-before-children. Sorted by NodeId
|
|
/// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor
|
|
/// + call site keeps compiling unchanged.
|
|
/// </summary>
|
|
public IReadOnlyList<RawContainerNode> RawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
|
|
|
/// <summary>
|
|
/// The Raw subtree's <b>tag</b> Variable nodes — one per raw <see cref="Tag"/> — keyed
|
|
/// <c>(realm=Raw, s=<RawPath>)</c>. The RawPath IS the node identity + the single value source
|
|
/// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable /
|
|
/// historize + array shape, the optional native-alarm intent (attached at the RAW tag —
|
|
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the
|
|
/// multi-notifier alarm). Init-only, defaults empty.
|
|
/// </summary>
|
|
public IReadOnlyList<RawTagPlan> RawTags { get; init; } = Array.Empty<RawTagPlan>();
|
|
|
|
/// <summary>
|
|
/// The UNS subtree's reference Variable nodes — one per <see cref="UnsTagReference"/> — keyed
|
|
/// <c>(realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>)</c>. Each carries
|
|
/// its backing raw tag's RawPath (the <c>Organizes</c> UNS→Raw target + the fan-out source) plus the
|
|
/// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan
|
|
/// out from the raw node. Init-only, defaults empty.
|
|
/// </summary>
|
|
public IReadOnlyList<UnsReferenceVariable> UnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
|
}
|
|
|
|
/// <summary>Which Raw-tree container a <see cref="RawContainerNode"/> is (all are <c>Object</c>/<c>Folder</c>
|
|
/// nodes; the kind lets the applier pick the OPC UA type + reference).</summary>
|
|
public enum RawNodeKind
|
|
{
|
|
/// <summary>A driver-organizing <see cref="RawFolder"/>.</summary>
|
|
Folder,
|
|
|
|
/// <summary>A <see cref="DriverInstance"/>.</summary>
|
|
Driver,
|
|
|
|
/// <summary>A <see cref="Device"/>.</summary>
|
|
Device,
|
|
|
|
/// <summary>A tag-organizing <see cref="TagGroup"/>.</summary>
|
|
TagGroup,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). <see cref="NodeId"/> is its
|
|
/// <c>RawPath</c> (== the <c>ns=Raw</c> <c>s=</c> id); <see cref="ParentNodeId"/> is the parent container's
|
|
/// RawPath (<see langword="null"/> = directly under the cluster root). <see cref="Realm"/> is always
|
|
/// <see cref="AddressSpaceRealm.Raw"/> — carried explicitly so the applier chooses the namespace at the
|
|
/// call site rather than inferring it.
|
|
/// </summary>
|
|
/// <param name="NodeId">The container's RawPath (its <c>ns=Raw</c> <c>s=</c> identifier).</param>
|
|
/// <param name="ParentNodeId">The parent container's RawPath, or <see langword="null"/> for a cluster-root node.</param>
|
|
/// <param name="DisplayName">The friendly browse name (the container's leaf <c>Name</c>).</param>
|
|
/// <param name="Kind">Which container this is.</param>
|
|
/// <param name="Realm">The address-space realm (always <see cref="AddressSpaceRealm.Raw"/>).</param>
|
|
public sealed record RawContainerNode(
|
|
string NodeId,
|
|
string? ParentNodeId,
|
|
string DisplayName,
|
|
RawNodeKind Kind,
|
|
AddressSpaceRealm Realm = AddressSpaceRealm.Raw);
|
|
|
|
/// <summary>
|
|
/// One Raw-subtree tag Variable node. <see cref="NodeId"/> is the tag's <c>RawPath</c> — the single
|
|
/// identity string at every seam (the <c>ns=Raw</c> <c>s=</c> id, the driver fan-out/write key, the
|
|
/// historian default tagname, the native-alarm <c>ConditionId</c>). <see cref="ParentNodeId"/> is the
|
|
/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device).
|
|
/// <see cref="Writable"/> mirrors <c>Tag.AccessLevel == ReadWrite</c>. <see cref="Alarm"/> /
|
|
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> / <see cref="IsArray"/> /
|
|
/// <see cref="ArrayLength"/> are parsed from <c>Tag.TagConfig</c> via
|
|
/// <see cref="TagConfigIntent"/> (the single byte-parity authority). <see cref="ReferencingEquipmentPaths"/>
|
|
/// is the (possibly empty) set of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference
|
|
/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every
|
|
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
|
|
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
|
|
/// later, not here).
|
|
/// <see cref="ArrayWritable"/> (Gitea #610) is whether the owning driver can write this tag as a whole
|
|
/// 1-D array (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>); always
|
|
/// <see langword="false"/> for a scalar. The materialised node is writable iff
|
|
/// <c>Writable && (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array
|
|
/// encoder stays read-only exactly as before #610.
|
|
/// <see cref="ScalarRefusesArrayValue"/> (#610 review R6) is true when the owning driver's SCALAR write path
|
|
/// cannot take an array value
|
|
/// (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>);
|
|
/// the node manager refuses an array write to the node inline while it is scalar-shaped. It is a fact about
|
|
/// the DRIVER, deliberately not gated on <see cref="IsArray"/>, so a surgical scalar↔array edit does not
|
|
/// flip it. It is not in the classifier's surgical whitelist, so a change to it (a driver's type edited in
|
|
/// place) rebuilds.
|
|
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
|
|
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
|
|
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
|
|
/// to the ctor and nowhere else silently never diffs (a redeploy that changes only it produces an EMPTY
|
|
/// plan). Update, in order: (1) the primary constructor, (2) <see cref="Equals(RawTagPlan)"/>,
|
|
/// (3) <see cref="GetHashCode"/>. Then reconsider two downstream consumers: (4a)
|
|
/// <c>AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible</c>'s <c>with</c>-equality whitelist —
|
|
/// a new field is default-closed (⇒ Rebuild) until it is listed, which is the safe direction but may be
|
|
/// the wrong one; and (4b) <see cref="UnsReferenceVariable"/>'s <c>Backing*</c> mirror fields — if the
|
|
/// new field is INHERITED by a UNS reference node at materialise time, it must be mirrored there too or
|
|
/// referencing UNS nodes go stale on a surgical raw edit (the exact 2026-08-15 review finding).</para>
|
|
/// </summary>
|
|
public sealed record RawTagPlan(
|
|
string TagId,
|
|
string NodeId,
|
|
string? ParentNodeId,
|
|
string DriverInstanceId,
|
|
string Name,
|
|
string DataType,
|
|
bool Writable,
|
|
EquipmentTagAlarmInfo? Alarm,
|
|
IReadOnlyList<string> ReferencingEquipmentPaths,
|
|
bool IsHistorized = false,
|
|
string? HistorianTagname = null,
|
|
bool IsArray = false,
|
|
uint? ArrayLength = null,
|
|
AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
|
|
bool ArrayWritable = false,
|
|
bool ScalarRefusesArrayValue = false)
|
|
{
|
|
/// <inheritdoc />
|
|
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
|
|
// interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse
|
|
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
|
|
// EquipmentVirtualTagPlan).
|
|
public bool Equals(RawTagPlan? other) =>
|
|
other is not null &&
|
|
TagId == other.TagId &&
|
|
NodeId == other.NodeId &&
|
|
ParentNodeId == other.ParentNodeId &&
|
|
DriverInstanceId == other.DriverInstanceId &&
|
|
Name == other.Name &&
|
|
DataType == other.DataType &&
|
|
Writable == other.Writable &&
|
|
EqualityComparer<EquipmentTagAlarmInfo?>.Default.Equals(Alarm, other.Alarm) &&
|
|
IsHistorized == other.IsHistorized &&
|
|
HistorianTagname == other.HistorianTagname &&
|
|
IsArray == other.IsArray &&
|
|
ArrayLength == other.ArrayLength &&
|
|
Realm == other.Realm &&
|
|
ArrayWritable == other.ArrayWritable &&
|
|
ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
|
|
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
|
|
|
|
/// <inheritdoc />
|
|
public override int GetHashCode()
|
|
{
|
|
var hash = new HashCode();
|
|
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
|
|
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
|
|
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
|
|
hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue);
|
|
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
|
|
return hash.ToHashCode();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One UNS-subtree reference Variable node projected from a <see cref="UnsTagReference"/>.
|
|
/// <see cref="NodeId"/> is the slash-joined UNS path <c>Area/Line/Equipment/EffectiveName</c> (the
|
|
/// <c>ns=UNS</c> <c>s=</c> id); <see cref="EffectiveName"/> is <c>DisplayNameOverride</c> else the backing
|
|
/// raw tag's <c>Name</c>. <see cref="BackingRawPath"/> is the backing raw tag's RawPath — the
|
|
/// <c>Organizes</c> UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver;
|
|
/// it mirrors the raw node). <see cref="DataType"/> / <see cref="Writable"/> are inherited from the
|
|
/// backing raw tag. <see cref="UnsTagReferenceId"/> is the stable diff identity (a display-name-override
|
|
/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id
|
|
/// + NodeId while <see cref="BackingRawPath"/> re-points). <see cref="Realm"/> is always
|
|
/// <see cref="AddressSpaceRealm.Uns"/>.
|
|
/// <para><b><see cref="BackingIsHistorized"/> / <see cref="BackingHistorianTagname"/> /
|
|
/// <see cref="BackingIsArray"/> / <see cref="BackingArrayLength"/> mirror the backing raw tag's
|
|
/// historian intent + array shape, and they are on THIS record for one reason: to make a raw-side
|
|
/// edit produce a COINCIDENT <c>UnsReferenceDelta</c>.</b> The 2026-08-15 surgical-deploys review
|
|
/// found that once non-alarm raw-tag deltas became surgical, editing a backing tag's
|
|
/// <c>IsHistorized</c> / <c>HistorianTagname</c> / <c>IsArray</c> / <c>ArrayLength</c> left every
|
|
/// referencing UNS node STALE: the applier updates the raw node in place, no UNS-reference delta
|
|
/// fired (none of those fields were on this record), and <c>EnsureVariable</c> early-returns on an
|
|
/// existing node — so HistoryRead via the UNS NodeId served a different series than via the raw
|
|
/// NodeId, a de-historized tag kept serving history through UNS, and a scalar→array flip left the
|
|
/// UNS mirror scalar AND writable (bypassing the array read-only write gate). Carrying the four
|
|
/// fields here makes record equality flag the reference as Changed in the same plan, and the
|
|
/// existing machinery repairs it: the applier removes the previous UNS node and
|
|
/// <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> recreates it from these values.
|
|
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are
|
|
/// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing
|
|
/// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node
|
|
/// is), and <see cref="BackingScalarRefusesArrayValue"/> (#610 review R6 — the backing tag's
|
|
/// <see cref="RawTagPlan.ScalarRefusesArrayValue"/>, so a mirror refuses an array write exactly when its raw
|
|
/// node does; a change to it is treated as an identity change by the applier, so the mirror is recreated
|
|
/// rather than updated in place).</para>
|
|
/// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a
|
|
/// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four
|
|
/// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If
|
|
/// a collection-typed member is ever added here, custom equality becomes mandatory — see the
|
|
/// three-place contract comment on <see cref="RawTagPlan"/>.</para>
|
|
/// </summary>
|
|
public sealed record UnsReferenceVariable(
|
|
string UnsTagReferenceId,
|
|
string EquipmentId,
|
|
string NodeId,
|
|
string EffectiveName,
|
|
string BackingRawPath,
|
|
string DataType,
|
|
bool Writable,
|
|
bool BackingIsHistorized = false,
|
|
string? BackingHistorianTagname = null,
|
|
bool BackingIsArray = false,
|
|
uint? BackingArrayLength = null,
|
|
AddressSpaceRealm Realm = AddressSpaceRealm.Uns,
|
|
bool BackingArrayWritable = false,
|
|
bool BackingScalarRefusesArrayValue = false);
|
|
|
|
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
|
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
|
|
|
/// <summary>One UNS level-5 equipment folder in the address space. <see cref="EquipmentId"/> is the
|
|
/// logical NodeId; <see cref="DisplayName"/> is the friendly UNS Name segment; <see cref="UnsLineId"/>
|
|
/// is the parent line the folder hangs under.
|
|
/// <para><see cref="DriverInstanceId"/> / <see cref="DeviceId"/> carry the equipment's optional bindings
|
|
/// (both <c>null</c> ⇒ driver-less / no device), copied straight from the <c>Equipment</c> row.
|
|
/// <see cref="DeviceHost"/> is the device's connection host (e.g. <c>"10.0.0.5:8193"</c>) resolved from the
|
|
/// bound <c>Device</c>'s schemaless <c>DeviceConfig</c> JSON via
|
|
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent.TryExtractHost"/> — <c>null</c> when there
|
|
/// is no device, no <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later
|
|
/// task graft a driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
|
|
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
|
|
/// the artifact-decode sides (single source of truth: <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent"/>);
|
|
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
|
|
/// (trim + lower-case) so the two compare equal.</para>
|
|
/// <para><b>Address-space-rebuild interaction (accepted trade-off).</b> These three fields participate in
|
|
/// <see cref="EquipmentNode"/>'s record value-equality, which <c>AddressSpacePlan.Compute</c> uses to
|
|
/// build its changed-equipment set. So editing a <c>Device</c>'s <c>DeviceConfig</c> host/port, or
|
|
/// rebinding an equipment's <c>DriverInstanceId</c> / <c>DeviceId</c>, now yields an
|
|
/// <see cref="EquipmentNode"/> delta that triggers a full structural address-space rebuild on the next
|
|
/// deploy (a momentary subscription teardown for that equipment). This is a deliberate, accepted
|
|
/// decision: it fires only on rare operator-initiated config edits at deploy time (routine redeploys of
|
|
/// unchanged config are unaffected — the delta is empty), it is recoverable, and it is directionally
|
|
/// correct for the multi-device FixedTree re-partition (a later task). <c>AddressSpacePlan</c> is left
|
|
/// unchanged.</para></summary>
|
|
public sealed record EquipmentNode(
|
|
string EquipmentId,
|
|
string DisplayName,
|
|
string UnsLineId,
|
|
string? DriverInstanceId = null,
|
|
string? DeviceId = null,
|
|
string? DeviceHost = null);
|
|
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
|
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
|
|
|
/// <summary>
|
|
/// One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/>
|
|
/// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the stable
|
|
/// <see cref="TagId"/> (diff identity), the parent <see cref="EquipmentId"/> folder (already
|
|
/// materialised by <c>AddressSpaceApplier.MaterialiseHierarchy</c>) the variable hangs under, the
|
|
/// optional <see cref="FolderPath"/> sub-folder, the leaf <see cref="Name"/> display, the OPC UA
|
|
/// <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from
|
|
/// <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId
|
|
/// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
|
|
/// (e.g. a Modbus register) is not unique across identical machines. <see cref="Writable"/>
|
|
/// mirrors the authored <c>Tag.AccessLevel == ReadWrite</c> so the materialised node is created
|
|
/// <c>CurrentReadWrite</c> (the prerequisite for the inbound-write pipeline); a <c>Read</c> tag
|
|
/// stays read-only. This flag is derived identically on the artifact-decode side
|
|
/// (<c>DeploymentArtifact.BuildEquipmentTagPlans</c>) for byte-parity. <see cref="Alarm"/> carries
|
|
/// the optional native-alarm intent parsed from <c>Tag.TagConfig</c>'s <c>alarm</c> object (null ⇒
|
|
/// a plain value variable); it too is parsed identically on the artifact-decode side for byte-parity.
|
|
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> carry the optional server-side
|
|
/// HistoryRead intent parsed from <c>Tag.TagConfig</c>'s <c>isHistorized</c> bool +
|
|
/// <c>historianTagname</c> string (Phase C); a null <see cref="HistorianTagname"/> means the historian
|
|
/// tagname defaults to <see cref="FullName"/> (resolved later, not here). Both are parsed identically
|
|
/// on the artifact-decode side for byte-parity.
|
|
/// <see cref="IsArray"/> / <see cref="ArrayLength"/> carry the optional array intent parsed from
|
|
/// <c>Tag.TagConfig</c>'s <c>isArray</c> bool + <c>arrayLength</c> uint: when <see cref="IsArray"/>
|
|
/// the variable materialises as a 1-D array (<c>ValueRank=OneDimension</c>,
|
|
/// <c>ArrayDimensions=[ArrayLength]</c>) rather than a scalar. A null <see cref="ArrayLength"/> means
|
|
/// length 0 (unbounded). Both are parsed identically on the artifact-decode side for byte-parity.
|
|
/// </summary>
|
|
public sealed record EquipmentTagPlan(
|
|
string TagId,
|
|
string EquipmentId,
|
|
string DriverInstanceId,
|
|
string FolderPath,
|
|
string Name,
|
|
string DataType,
|
|
string FullName,
|
|
bool Writable,
|
|
EquipmentTagAlarmInfo? Alarm,
|
|
bool IsHistorized = false,
|
|
string? HistorianTagname = null,
|
|
bool IsArray = false,
|
|
uint? ArrayLength = null);
|
|
|
|
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
|
|
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
|
|
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.
|
|
/// <see cref="HistorizeToAveva"/> is the per-condition opt-out of the durable AVEVA historian write
|
|
/// (<c>bool?</c>; absent ⇒ <c>null</c> ⇒ historize). It is threaded onto each native
|
|
/// <c>AlarmTransitionEvent</c> so the runtime's <c>HistorianAdapterActor</c> gate
|
|
/// (<c>historizeToAveva is not false</c>) suppresses the durable row only on an explicit <c>false</c> —
|
|
/// the same posture as the scripted-alarm opt-out; the live <c>/alerts</c> fan-out is unaffected.</summary>
|
|
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool? HistorizeToAveva = null);
|
|
|
|
/// <summary>
|
|
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
|
|
/// <see cref="Script"/> for the expression). The VirtualTag value analogue of
|
|
/// <see cref="EquipmentTagPlan"/>: <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c>
|
|
/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId
|
|
/// (<c>EquipmentId/Name</c>, or <c>EquipmentId/FolderPath/Name</c> when a sub-folder is set),
|
|
/// and <c>VirtualTagHostActor</c> spawns a <c>VirtualTagActor</c> per plan that evaluates
|
|
/// <see cref="Expression"/> over <see cref="DependencyRefs"/> and publishes the value back to
|
|
/// that NodeId. <see cref="DependencyRefs"/> = the distinct <c>ctx.GetTag("…")</c> literals in
|
|
/// the script source.
|
|
/// </summary>
|
|
/// <param name="Historize">When true, this VirtualTag's values are historized (carried from the
|
|
/// <c>VirtualTag.Historize</c> entity column). Threaded through the deploy-diff equality below so a
|
|
/// Historize-only toggle is detected as a change. Defaults to <c>false</c> — matching both the CLR
|
|
/// default of the <c>bool VirtualTag.Historize</c> column and the artifact-decode default when the flag is absent/non-bool —
|
|
/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.</param>
|
|
public sealed record EquipmentVirtualTagPlan(
|
|
string VirtualTagId,
|
|
string EquipmentId,
|
|
string FolderPath,
|
|
string Name,
|
|
string DataType,
|
|
string Expression,
|
|
IReadOnlyList<string> DependencyRefs,
|
|
bool Historize = false)
|
|
{
|
|
/// <inheritdoc />
|
|
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
|
|
// interface-typed list) BY REFERENCE, flagging every VirtualTag as "changed" on every parse
|
|
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty. Historize
|
|
// is included so a Historize-only toggle is detected as a change.
|
|
public bool Equals(EquipmentVirtualTagPlan? other) =>
|
|
other is not null &&
|
|
VirtualTagId == other.VirtualTagId &&
|
|
EquipmentId == other.EquipmentId &&
|
|
FolderPath == other.FolderPath &&
|
|
Name == other.Name &&
|
|
DataType == other.DataType &&
|
|
Expression == other.Expression &&
|
|
Historize == other.Historize &&
|
|
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
|
|
|
/// <inheritdoc />
|
|
public override int GetHashCode()
|
|
{
|
|
var hash = new HashCode();
|
|
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
|
|
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
|
|
hash.Add(Historize);
|
|
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
|
return hash.ToHashCode();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One Equipment-owned scripted alarm from a <see cref="ScriptedAlarm"/> row, joined to its
|
|
/// predicate <see cref="Script"/> (by <see cref="PredicateScriptId"/>) for the source. The
|
|
/// richer host analogue of the thin <see cref="ScriptedAlarmPlan"/>: the runtime alarm host
|
|
/// spawns one Part 9 condition per plan, evaluates <see cref="PredicateSource"/> over
|
|
/// <see cref="DependencyRefs"/>, and resolves the <see cref="MessageTemplate"/>'s
|
|
/// <c>{TagPath}</c> tokens at emission time. <see cref="DependencyRefs"/> = the distinct
|
|
/// <c>ctx.GetTag("…")</c> read literals in the predicate source UNION the distinct
|
|
/// <c>{TagPath}</c> token paths referenced in the message template (the reserved
|
|
/// <c>{{equip}}</c> double-brace form is excluded). <see cref="Enabled"/> is carried (never
|
|
/// dropped) so the host — not the composer — decides whether to host a disabled alarm. Designed
|
|
/// to be cleanly serializable so the artifact-decode seam (sibling task) can mirror it for
|
|
/// byte-parity, exactly like <see cref="EquipmentVirtualTagPlan"/>.
|
|
/// </summary>
|
|
/// <param name="ScriptedAlarmId">Stable logical id — drives the condition name + diff identity.</param>
|
|
/// <param name="EquipmentId">Owning equipment folder the alarm hangs under.</param>
|
|
/// <param name="Name">Operator-facing alarm name.</param>
|
|
/// <param name="AlarmType">Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm").</param>
|
|
/// <param name="Severity">Numeric severity 1..1000 per OPC UA Part 9.</param>
|
|
/// <param name="MessageTemplate">Template with <c>{TagPath}</c> tokens resolved at emission time.</param>
|
|
/// <param name="PredicateScriptId">Logical FK to the predicate script.</param>
|
|
/// <param name="PredicateSource">The resolved predicate script source (joined by <paramref name="PredicateScriptId"/>).</param>
|
|
/// <param name="DependencyRefs">Distinct predicate read refs UNION message-template token paths.</param>
|
|
/// <param name="HistorizeToAveva">When true, transitions route to the Aveva Historian sink.</param>
|
|
/// <param name="Retain">OPC UA Part 9 <c>Retain</c> flag.</param>
|
|
/// <param name="Enabled">Whether the alarm is enabled — carried for the host to decide on.</param>
|
|
public sealed record EquipmentScriptedAlarmPlan(
|
|
string ScriptedAlarmId,
|
|
string EquipmentId,
|
|
string Name,
|
|
string AlarmType,
|
|
int Severity,
|
|
string MessageTemplate,
|
|
string PredicateScriptId,
|
|
string PredicateSource,
|
|
IReadOnlyList<string> DependencyRefs,
|
|
bool HistorizeToAveva,
|
|
bool Retain,
|
|
bool Enabled)
|
|
{
|
|
/// <inheritdoc />
|
|
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
|
|
// interface-typed list) BY REFERENCE, flagging every alarm as "changed" on every parse (fresh
|
|
// list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
|
|
// EquipmentVirtualTagPlan). DependencyRefs equality is order-sensitive (SequenceEqual).
|
|
// EquipmentScriptPaths.ExtractAlarmDependencyRefs is the canonical, deterministic producer of
|
|
// that order (predicate ctx.GetTag reads first, then first-seen message template tokens).
|
|
// Downstream byte-parity between the live composer and the artifact-decode mirror depends on
|
|
// both sides calling ExtractAlarmDependencyRefs with identical inputs.
|
|
public bool Equals(EquipmentScriptedAlarmPlan? other) =>
|
|
other is not null &&
|
|
ScriptedAlarmId == other.ScriptedAlarmId &&
|
|
EquipmentId == other.EquipmentId &&
|
|
Name == other.Name &&
|
|
AlarmType == other.AlarmType &&
|
|
Severity == other.Severity &&
|
|
MessageTemplate == other.MessageTemplate &&
|
|
PredicateScriptId == other.PredicateScriptId &&
|
|
PredicateSource == other.PredicateSource &&
|
|
HistorizeToAveva == other.HistorizeToAveva &&
|
|
Retain == other.Retain &&
|
|
Enabled == other.Enabled &&
|
|
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
|
|
|
/// <inheritdoc />
|
|
public override int GetHashCode()
|
|
{
|
|
var hash = new HashCode();
|
|
hash.Add(ScriptedAlarmId); hash.Add(EquipmentId); hash.Add(Name);
|
|
hash.Add(AlarmType); hash.Add(Severity); hash.Add(MessageTemplate);
|
|
hash.Add(PredicateScriptId); hash.Add(PredicateSource);
|
|
hash.Add(HistorizeToAveva); hash.Add(Retain); hash.Add(Enabled);
|
|
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
|
return hash.ToHashCode();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
|
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
|
/// startup consumes the result and hands it to the node-manager factory.
|
|
///
|
|
/// The composer also carries UNS topology (<see cref="UnsAreaProjection"/> +
|
|
/// <see cref="UnsLineProjection"/>) so <c>AddressSpaceApplier</c> can build the
|
|
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
|
|
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
|
|
/// (composer → applier → sink → node manager) chain.
|
|
/// </summary>
|
|
public static class AddressSpaceComposer
|
|
{
|
|
/// <summary>Convenience overload for legacy callers + tests that don't supply UNS topology or tags.</summary>
|
|
/// <param name="equipment">The equipment.</param>
|
|
/// <param name="driverInstances">The driver instances.</param>
|
|
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
|
/// <returns>The composition result.</returns>
|
|
public static AddressSpaceComposition Compose(
|
|
IReadOnlyList<Equipment> equipment,
|
|
IReadOnlyList<DriverInstance> driverInstances,
|
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
|
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
|
|
|
|
/// <summary>
|
|
/// Composes the address space build plan from the v3 configuration entities.
|
|
/// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
|
|
/// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
|
|
/// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
|
|
/// <c>s=<RawPath></c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
|
|
/// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
|
|
/// <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>, carrying its backing RawPath
|
|
/// for the <c>Organizes</c> reference + fan-out). Native-alarm intents attach at the RAW tag
|
|
/// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy
|
|
/// <see cref="AddressSpaceComposition.EquipmentTags"/> set stays EMPTY (the retired
|
|
/// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are
|
|
/// unchanged.</para>
|
|
/// </summary>
|
|
/// <param name="unsAreas">The UNS areas.</param>
|
|
/// <param name="unsLines">The UNS lines.</param>
|
|
/// <param name="equipment">The equipment.</param>
|
|
/// <param name="driverInstances">The driver instances.</param>
|
|
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
|
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
|
|
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
|
|
/// <param name="unsTagReferences">The UNS tag references (equipment → raw tag). Feed the <c>{{equip}}/<RefName></c> reference map. <c>null</c> = none.</param>
|
|
/// <param name="tags">The raw tags backing <paramref name="unsTagReferences"/> (RawPath computed via the shared resolver). <c>null</c> = none.</param>
|
|
/// <param name="rawFolders">The raw-tree folders (RawPath ancestry). <c>null</c> = none.</param>
|
|
/// <param name="devices">The raw-tree devices (RawPath ancestry). <c>null</c> = none.</param>
|
|
/// <param name="tagGroups">The raw-tree tag-groups (RawPath ancestry). <c>null</c> = none.</param>
|
|
/// <returns>The composition result.</returns>
|
|
public static AddressSpaceComposition Compose(
|
|
IReadOnlyList<UnsArea> unsAreas,
|
|
IReadOnlyList<UnsLine> unsLines,
|
|
IReadOnlyList<Equipment> equipment,
|
|
IReadOnlyList<DriverInstance> driverInstances,
|
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
|
IReadOnlyList<VirtualTag>? virtualTags = null,
|
|
IReadOnlyList<Script>? scripts = null,
|
|
IReadOnlyList<UnsTagReference>? unsTagReferences = null,
|
|
IReadOnlyList<Tag>? tags = null,
|
|
IReadOnlyList<RawFolder>? rawFolders = null,
|
|
IReadOnlyList<Device>? devices = null,
|
|
IReadOnlyList<TagGroup>? tagGroups = null)
|
|
{
|
|
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
|
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
|
|
|
var areas = unsAreas
|
|
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
|
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
|
|
.ToList();
|
|
|
|
var lines = unsLines
|
|
.OrderBy(l => l.UnsLineId, StringComparer.Ordinal)
|
|
.Select(l => new UnsLineProjection(l.UnsLineId, l.UnsAreaId, l.Name))
|
|
.ToList();
|
|
|
|
var nodes = equipment
|
|
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
|
// DisplayName = the UNS level-5 Name segment (friendly browse name). NodeId stays the logical
|
|
// EquipmentId. v3: equipment no longer binds a driver/device (it references raw tags via
|
|
// UnsTagReference), so DriverInstanceId/DeviceId/DeviceHost are always null.
|
|
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
|
|
.ToList();
|
|
|
|
var plans = driverInstances
|
|
.OrderBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
|
.Select(d => new DriverInstancePlan(d.DriverInstanceId, d.DriverType, d.DriverConfig))
|
|
.ToList();
|
|
|
|
var alarms = scriptedAlarms
|
|
.OrderBy(a => a.ScriptedAlarmId, StringComparer.Ordinal)
|
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
|
.ToList();
|
|
|
|
// v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry
|
|
// every value (see RawTags / UnsReferenceVariables below).
|
|
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
|
|
|
// The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the
|
|
// container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the
|
|
// {{equip}} reference map — so the three agree byte-for-byte on every RawPath.
|
|
var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups);
|
|
|
|
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built via
|
|
// the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the
|
|
// artifact-decode mirror + the draft validator use.
|
|
var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver);
|
|
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
|
new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
|
|
// expression source. Each {{equip}}/<RefName> is substituted with the owning equipment's backing
|
|
// RawPath BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
|
|
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
|
|
// VirtualTag has no FolderPath today → "".
|
|
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
|
|
var equipmentVirtualTags = vtags
|
|
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
|
|
.ThenBy(v => v.Name, StringComparer.Ordinal)
|
|
.Select(v =>
|
|
{
|
|
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
|
|
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
|
src, referenceMapByEquip.GetValueOrDefault(v.EquipmentId, emptyRefMap));
|
|
return new EquipmentVirtualTagPlan(
|
|
VirtualTagId: v.VirtualTagId,
|
|
EquipmentId: v.EquipmentId,
|
|
FolderPath: string.Empty,
|
|
Name: v.Name,
|
|
DataType: v.DataType,
|
|
Expression: expanded,
|
|
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
|
|
Historize: v.Historize);
|
|
})
|
|
.ToList();
|
|
|
|
// Equipment scripted alarms = each ScriptedAlarm joined to its predicate Script (by
|
|
// PredicateScriptId) for the source. An alarm whose PredicateScriptId has no matching Script
|
|
// row is SKIPPED (not emitted) with a structured warning rather than failing the whole
|
|
// compose — the upstream draft validator is the authority that should prevent the dangling
|
|
// reference. DependencyRefs = the predicate's distinct ctx.GetTag("…") reads UNION the
|
|
// distinct {TagPath} tokens in the MessageTemplate (predicate reads first, then first-seen
|
|
// template tokens; the reserved {{equip}} double-brace form is excluded). Enabled is carried
|
|
// (never dropped) — the runtime host decides whether to host a disabled alarm. Ordered by
|
|
// EquipmentId then ScriptedAlarmId so the upcoming artifact byte-parity test is reliable.
|
|
//
|
|
// Eager foreach (not lazy LINQ Select) so the Trace.TraceWarning fires exactly once per
|
|
// compose call; a lazy select would re-fire on every re-enumeration of the LINQ chain.
|
|
var equipmentScriptedAlarms = new List<EquipmentScriptedAlarmPlan>();
|
|
foreach (var a in scriptedAlarms
|
|
.OrderBy(a => a.EquipmentId, StringComparer.Ordinal)
|
|
.ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal))
|
|
{
|
|
if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s))
|
|
{
|
|
Trace.TraceWarning(
|
|
"AddressSpaceComposer: scripted alarm '{0}' (equipment '{1}') references predicate " +
|
|
"script '{2}' which is not in the supplied scripts — skipping.",
|
|
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
|
|
continue;
|
|
}
|
|
// v3: scripted-alarm predicates ALSO resolve {{equip}}/<RefName> (equipment-scoped scripts),
|
|
// substituted with the owning equipment's reference map BEFORE the dependency merge — so the
|
|
// stored PredicateSource + DependencyRefs are resolved RawPaths, byte-parity with the
|
|
// artifact-decode mirror. The merge (predicate reads first, then template tokens) lives in the
|
|
// shared EquipmentScriptPaths helper.
|
|
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
|
|
s.SourceCode, referenceMapByEquip.GetValueOrDefault(a.EquipmentId, emptyRefMap));
|
|
equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
|
|
ScriptedAlarmId: a.ScriptedAlarmId,
|
|
EquipmentId: a.EquipmentId,
|
|
Name: a.Name,
|
|
AlarmType: a.AlarmType,
|
|
Severity: a.Severity,
|
|
MessageTemplate: a.MessageTemplate,
|
|
PredicateScriptId: a.PredicateScriptId,
|
|
PredicateSource: source,
|
|
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
|
|
HistorizeToAveva: a.HistorizeToAveva,
|
|
Retain: a.Retain,
|
|
Enabled: a.Enabled));
|
|
}
|
|
|
|
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace.
|
|
var (rawContainers, rawTags) = BuildRawSubtree(
|
|
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
|
unsAreas, unsLines, equipment, topology);
|
|
var unsReferenceVariables = BuildUnsReferenceVariables(
|
|
unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
|
|
BuildArrayWriteCapability(driverInstances, devices),
|
|
BuildScalarArrayRefusal(driverInstances, devices));
|
|
|
|
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
|
{
|
|
EquipmentTags = equipmentTags,
|
|
EquipmentVirtualTags = equipmentVirtualTags,
|
|
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
|
RawContainers = rawContainers,
|
|
RawTags = rawTags,
|
|
UnsReferenceVariables = unsReferenceVariables,
|
|
};
|
|
}
|
|
|
|
/// <summary>Build the per-equipment <c>{{equip}}/<RefName></c> reference map from the v3 entities
|
|
/// via the shared <see cref="EquipmentReferenceMap"/> over the SHARED <paramref name="resolver"/> — the
|
|
/// entity-side mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no
|
|
/// references are supplied (every test / earlier caller that omits the reference data).</summary>
|
|
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
|
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
|
IReadOnlyList<Tag>? tags,
|
|
RawPathResolver resolver)
|
|
{
|
|
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
|
var tagRows = tags ?? Array.Empty<Tag>();
|
|
if (refs.Count == 0 || tagRows.Count == 0)
|
|
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
|
|
|
var tagsById = tagRows
|
|
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
|
.ToDictionary(
|
|
g => g.Key,
|
|
g => new EquipmentReferenceMap.TagRow(g.First().Name, g.First().DeviceId, g.First().TagGroupId),
|
|
StringComparer.Ordinal);
|
|
|
|
return EquipmentReferenceMap.Build(
|
|
refs.Select(r => new EquipmentReferenceMap.ReferenceRow(
|
|
r.UnsTagReferenceId, r.EquipmentId, r.TagId, r.DisplayNameOverride)),
|
|
tagsById,
|
|
resolver);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as
|
|
/// <c>Object</c>/<c>Folder</c> nodes, and the tag Variable nodes keyed <c>(realm=Raw, s=RawPath)</c>.
|
|
/// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is
|
|
/// SKIPPED rather than faulting the whole compose (mirrors <see cref="RawPathResolver"/>'s
|
|
/// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child.
|
|
/// </summary>
|
|
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags) BuildRawSubtree(
|
|
IReadOnlyList<RawFolder>? rawFolders,
|
|
IReadOnlyList<DriverInstance> driverInstances,
|
|
IReadOnlyList<Device>? devices,
|
|
IReadOnlyList<TagGroup>? tagGroups,
|
|
IReadOnlyList<Tag>? tags,
|
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
|
IReadOnlyList<UnsArea> unsAreas,
|
|
IReadOnlyList<UnsLine> unsLines,
|
|
IReadOnlyList<Equipment> equipment,
|
|
RawTopology topology)
|
|
{
|
|
var containers = new List<RawContainerNode>();
|
|
|
|
foreach (var f in rawFolders ?? Array.Empty<RawFolder>())
|
|
{
|
|
if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue;
|
|
var parent = f.ParentRawFolderId is not null
|
|
? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId)
|
|
: null;
|
|
containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder));
|
|
}
|
|
|
|
foreach (var d in driverInstances)
|
|
{
|
|
if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue;
|
|
var parent = d.RawFolderId is not null
|
|
? topology.FolderPaths.GetValueOrDefault(d.RawFolderId)
|
|
: null;
|
|
containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver));
|
|
}
|
|
|
|
foreach (var dev in devices ?? Array.Empty<Device>())
|
|
{
|
|
if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue;
|
|
var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId);
|
|
containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device));
|
|
}
|
|
|
|
foreach (var g in tagGroups ?? Array.Empty<TagGroup>())
|
|
{
|
|
if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue;
|
|
var parent = g.ParentTagGroupId is not null
|
|
? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId)
|
|
: topology.DevicePaths.GetValueOrDefault(g.DeviceId);
|
|
containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup));
|
|
}
|
|
|
|
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
|
|
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
|
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
|
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
|
|
var scalarRefusesArrayFor = BuildScalarArrayRefusal(driverInstances, devices);
|
|
|
|
var rawTags = new List<RawTagPlan>();
|
|
foreach (var t in tags ?? Array.Empty<Tag>())
|
|
{
|
|
var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
|
if (nodeId is null) continue;
|
|
var parent = t.TagGroupId is not null
|
|
? topology.GroupPaths.GetValueOrDefault(t.TagGroupId)
|
|
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
|
|
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
|
var intent = TagConfigIntent.Parse(t.TagConfig);
|
|
var arrayWritable = intent.IsArray && arrayWritableFor(t);
|
|
var scalarRefusesArray = scalarRefusesArrayFor(t);
|
|
var alarm = intent.Alarm is { } a
|
|
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
|
: null;
|
|
var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty<string>());
|
|
|
|
rawTags.Add(new RawTagPlan(
|
|
TagId: t.TagId,
|
|
NodeId: nodeId,
|
|
ParentNodeId: parent,
|
|
DriverInstanceId: driverInstanceId,
|
|
Name: t.Name,
|
|
DataType: t.DataType,
|
|
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
|
Alarm: alarm,
|
|
ReferencingEquipmentPaths: refEquipPaths,
|
|
IsHistorized: intent.IsHistorized,
|
|
HistorianTagname: intent.HistorianTagname,
|
|
IsArray: intent.IsArray,
|
|
ArrayLength: intent.ArrayLength,
|
|
ArrayWritable: arrayWritable,
|
|
ScalarRefusesArrayValue: scalarRefusesArray));
|
|
}
|
|
|
|
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
|
rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
|
return (containers, rawTags);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The per-driver array-write capability (Gitea #610) as a per-tag predicate over the owning driver's
|
|
/// <c>DriverType</c>, the driver's <c>DriverConfig</c> and the tag's own device's <c>DeviceConfig</c>
|
|
/// (either config can carry a write cap such as Modbus <c>maxRegistersPerWrite</c>; the table takes the
|
|
/// smaller). First row wins on a duplicate id, matching the composer's other id maps. Built once per
|
|
/// compose and shared by the raw and UNS passes, so a mirror is writable exactly when its raw node is.
|
|
/// </summary>
|
|
private static Func<Tag, bool> BuildArrayWriteCapability(
|
|
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
|
{
|
|
var resolve = BuildTagOwnerResolver(driverInstances, devices);
|
|
return tag =>
|
|
{
|
|
var (driver, device) = resolve(tag);
|
|
return DriverArrayWriteSupport.Supports(
|
|
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// #610 review R6 MINOR-1 — whether a tag's owning driver refuses an array value on a SCALAR tag
|
|
/// (<see cref="DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>), as a per-tag predicate resolved
|
|
/// the same way as <see cref="BuildArrayWriteCapability"/>. Shared by the raw and UNS passes so a mirror
|
|
/// refuses exactly when its raw node does.
|
|
/// </summary>
|
|
private static Func<Tag, bool> BuildScalarArrayRefusal(
|
|
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
|
{
|
|
var resolve = BuildTagOwnerResolver(driverInstances, devices);
|
|
return tag => DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(resolve(tag).Driver?.DriverType);
|
|
}
|
|
|
|
// Tag → (owning driver, owning device); first row wins on a duplicate id, matching the composer's other id maps.
|
|
private static Func<Tag, (DriverInstance? Driver, Device? Device)> BuildTagOwnerResolver(
|
|
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
|
{
|
|
var driverById = driverInstances
|
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
var deviceById = (devices ?? Array.Empty<Device>())
|
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
return tag =>
|
|
{
|
|
var device = deviceById.GetValueOrDefault(tag.DeviceId);
|
|
var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
|
|
return (driver, device);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
|
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
|
|
/// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A
|
|
/// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments
|
|
/// are invalid is SKIPPED. Sorted by NodeId ordinal.
|
|
/// </summary>
|
|
private static IReadOnlyList<UnsReferenceVariable> BuildUnsReferenceVariables(
|
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
|
IReadOnlyList<Tag>? tags,
|
|
IReadOnlyList<UnsArea> unsAreas,
|
|
IReadOnlyList<UnsLine> unsLines,
|
|
IReadOnlyList<Equipment> equipment,
|
|
RawTopology topology,
|
|
Func<Tag, bool> arrayWritableFor,
|
|
Func<Tag, bool> scalarRefusesArrayFor)
|
|
{
|
|
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
|
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
|
|
|
var tagsById = (tags ?? Array.Empty<Tag>())
|
|
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
|
|
|
var vars = new List<UnsReferenceVariable>();
|
|
foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
|
|
{
|
|
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
|
|
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
|
var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
|
|
if (rawPath is null) continue;
|
|
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
|
|
|
|
string nodeId;
|
|
try
|
|
{
|
|
nodeId = V3NodeIds.UnsChild(equipPath, effectiveName);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names)
|
|
}
|
|
|
|
// The backing tag's historian intent + array shape ride the record so a raw-side edit to any of
|
|
// them produces a coincident UnsReferenceDelta that repairs this mirror node (see the record's
|
|
// doc-comment; 2026-08-15 surgical-deploys review). Parsed from the SAME TagConfigIntent the raw
|
|
// tag plan uses, so the two sides can never disagree.
|
|
var intent = TagConfigIntent.Parse(tag.TagConfig);
|
|
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
|
|
// exactly when its raw node is.
|
|
var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
|
|
var backingScalarRefusesArray = scalarRefusesArrayFor(tag);
|
|
|
|
vars.Add(new UnsReferenceVariable(
|
|
UnsTagReferenceId: r.UnsTagReferenceId,
|
|
EquipmentId: r.EquipmentId,
|
|
NodeId: nodeId,
|
|
EffectiveName: effectiveName,
|
|
BackingRawPath: rawPath,
|
|
DataType: tag.DataType,
|
|
Writable: tag.AccessLevel == TagAccessLevel.ReadWrite,
|
|
BackingIsHistorized: intent.IsHistorized,
|
|
BackingHistorianTagname: intent.HistorianTagname,
|
|
BackingIsArray: intent.IsArray,
|
|
BackingArrayLength: intent.ArrayLength,
|
|
BackingArrayWritable: backingArrayWritable,
|
|
BackingScalarRefusesArrayValue: backingScalarRefusesArray));
|
|
}
|
|
|
|
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
|
return vars;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>EquipmentId → UNS folder path</c> (<c>Area/Line/Equipment</c>, slash-joined via
|
|
/// <see cref="V3NodeIds"/>) for every equipment whose Area/Line ancestry resolves + whose three
|
|
/// segments are valid. Equipment with a broken ancestry or an invalid segment is absent.
|
|
/// </summary>
|
|
private static IReadOnlyDictionary<string, string> BuildEquipmentFolderPaths(
|
|
IReadOnlyList<UnsArea> unsAreas,
|
|
IReadOnlyList<UnsLine> unsLines,
|
|
IReadOnlyList<Equipment> equipment)
|
|
{
|
|
var areaName = unsAreas
|
|
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
|
var line = unsLines
|
|
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal);
|
|
|
|
var byEquip = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var e in equipment)
|
|
{
|
|
if (!line.TryGetValue(e.UnsLineId, out var ln)) continue;
|
|
if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue;
|
|
try
|
|
{
|
|
byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
// invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names).
|
|
}
|
|
}
|
|
return byEquip;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths</c>
|
|
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier (WP4): the raw tag's single
|
|
/// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no
|
|
/// resolvable reference is absent (⇒ an empty list at the call site).
|
|
/// </summary>
|
|
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildReferencingEquipmentPathsByTag(
|
|
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
|
IReadOnlyList<UnsArea> unsAreas,
|
|
IReadOnlyList<UnsLine> unsLines,
|
|
IReadOnlyList<Equipment> equipment)
|
|
{
|
|
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
|
if (refs.Count == 0) return new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
|
|
|
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
|
var byTag = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
|
foreach (var r in refs)
|
|
{
|
|
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
|
if (!byTag.TryGetValue(r.TagId, out var set))
|
|
byTag[r.TagId] = set = new SortedSet<string>(StringComparer.Ordinal);
|
|
set.Add(equipPath);
|
|
}
|
|
return byTag.ToDictionary(
|
|
kv => kv.Key,
|
|
kv => (IReadOnlyList<string>)kv.Value.ToList(),
|
|
StringComparer.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The shared Raw-tree topology: a <see cref="RawPathResolver"/> over the folder/driver/device/group
|
|
/// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built
|
|
/// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the <c>{{equip}}</c>
|
|
/// reference map so every RawPath is produced by the single authority.
|
|
/// </summary>
|
|
private sealed class RawTopology
|
|
{
|
|
public required RawPathResolver Resolver { get; init; }
|
|
|
|
/// <summary><c>RawFolderId → RawPath</c> (absent when the ancestry is broken).</summary>
|
|
public required IReadOnlyDictionary<string, string> FolderPaths { get; init; }
|
|
|
|
/// <summary><c>DriverInstanceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
|
public required IReadOnlyDictionary<string, string> DriverPaths { get; init; }
|
|
|
|
/// <summary><c>DeviceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
|
public required IReadOnlyDictionary<string, string> DevicePaths { get; init; }
|
|
|
|
/// <summary><c>TagGroupId → RawPath</c> (absent when the ancestry is broken).</summary>
|
|
public required IReadOnlyDictionary<string, string> GroupPaths { get; init; }
|
|
|
|
/// <summary><c>DeviceId → DriverInstanceId</c> — the raw tag's owning driver (for RawTagPlan).</summary>
|
|
public required IReadOnlyDictionary<string, string> DriverIdByDevice { get; init; }
|
|
|
|
public static RawTopology Build(
|
|
IReadOnlyList<RawFolder>? rawFolders,
|
|
IReadOnlyList<DriverInstance> driverInstances,
|
|
IReadOnlyList<Device>? devices,
|
|
IReadOnlyList<TagGroup>? tagGroups)
|
|
{
|
|
var folderRows = (rawFolders ?? Array.Empty<RawFolder>())
|
|
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
|
var driverRows = driverInstances
|
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
|
var deviceRows = (devices ?? Array.Empty<Device>())
|
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
|
var groupRows = (tagGroups ?? Array.Empty<TagGroup>())
|
|
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
|
|
|
var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows);
|
|
|
|
// Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains.
|
|
var folderPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var id in folderRows.Keys)
|
|
ResolveFolder(id, folderRows, folderPaths, 0);
|
|
|
|
var driverPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var id in driverRows.Keys)
|
|
{
|
|
var p = resolver.TryBuildDriverPrefix(id);
|
|
if (p is not null) driverPaths[id] = p;
|
|
}
|
|
|
|
var devicePaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var (id, dev) in deviceRows)
|
|
{
|
|
if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue;
|
|
try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); }
|
|
catch (ArgumentException) { /* invalid device name — dropped */ }
|
|
}
|
|
|
|
var groupPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
var groupDeviceById = (tagGroups ?? Array.Empty<TagGroup>())
|
|
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal);
|
|
foreach (var id in groupRows.Keys)
|
|
ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0);
|
|
|
|
var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal);
|
|
|
|
return new RawTopology
|
|
{
|
|
Resolver = resolver,
|
|
FolderPaths = folderPaths,
|
|
DriverPaths = driverPaths,
|
|
DevicePaths = devicePaths,
|
|
GroupPaths = groupPaths,
|
|
DriverIdByDevice = driverIdByDevice,
|
|
};
|
|
}
|
|
|
|
private const int MaxDepth = 256;
|
|
|
|
private static string? ResolveFolder(
|
|
string id,
|
|
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
|
Dictionary<string, string> memo,
|
|
int depth)
|
|
{
|
|
if (depth > MaxDepth) return null;
|
|
if (memo.TryGetValue(id, out var cached)) return cached;
|
|
if (!rows.TryGetValue(id, out var row)) return null;
|
|
var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1);
|
|
if (row.ParentId is not null && parentPath is null) return null; // broken parent chain
|
|
try
|
|
{
|
|
var path = RawPaths.Combine(parentPath, row.Name);
|
|
memo[id] = path;
|
|
return path;
|
|
}
|
|
catch (ArgumentException) { return null; }
|
|
}
|
|
|
|
private static string? ResolveGroup(
|
|
string id,
|
|
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
|
IReadOnlyDictionary<string, string> groupDeviceById,
|
|
IReadOnlyDictionary<string, string> devicePaths,
|
|
Dictionary<string, string> memo,
|
|
int depth)
|
|
{
|
|
if (depth > MaxDepth) return null;
|
|
if (memo.TryGetValue(id, out var cached)) return cached;
|
|
if (!rows.TryGetValue(id, out var row)) return null;
|
|
|
|
string? parentPath;
|
|
if (row.ParentId is not null)
|
|
{
|
|
parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1);
|
|
if (parentPath is null) return null; // broken parent chain
|
|
}
|
|
else
|
|
{
|
|
// Root group: parent is the owning device's RawPath.
|
|
if (!groupDeviceById.TryGetValue(id, out var deviceId)
|
|
|| !devicePaths.TryGetValue(deviceId, out parentPath))
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var path = RawPaths.Combine(parentPath, row.Name);
|
|
memo[id] = path;
|
|
return path;
|
|
}
|
|
catch (ArgumentException) { return null; }
|
|
}
|
|
}
|
|
}
|