using System.Diagnostics; using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// Outcome of — pure value tuple, no side effects. /// + 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. public sealed record AddressSpaceComposition( IReadOnlyList UnsAreas, IReadOnlyList UnsLines, IReadOnlyList EquipmentNodes, IReadOnlyList DriverInstancePlans, IReadOnlyList ScriptedAlarmPlans) { /// Convenience constructor for tests + earlier callers that don't carry UNS data. /// The equipment nodes. /// The driver instance plans. /// The scripted alarm plans. public AddressSpaceComposition( IReadOnlyList equipmentNodes, IReadOnlyList driverInstancePlans, IReadOnlyList scriptedAlarmPlans) : this(Array.Empty(), Array.Empty(), equipmentNodes, driverInstancePlans, scriptedAlarmPlans) { } /// /// Equipment-namespace tags — a with non-null /// in an Equipment-kind namespace. AddressSpaceApplier.MaterialiseEquipmentTags /// 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. /// public IReadOnlyList EquipmentTags { get; init; } = Array.Empty(); /// Equipment-namespace VirtualTags. See . Init-only, /// defaults empty so every existing constructor + call site keeps compiling. public IReadOnlyList EquipmentVirtualTags { get; init; } = Array.Empty(); /// /// Per-equipment scripted-alarm host plans — the richer analogue of the thin /// projection. Each carries the fully-resolved predicate /// source (joined from its ) and the merged dependency graph (predicate /// ctx.GetTag("…") reads UNION {TagPath} tokens in the message template) so the /// runtime alarm host can subscribe to every signal and evaluate the predicate. See /// . Init-only, defaults empty so every existing /// constructor + call site keeps compiling unchanged. /// public IReadOnlyList EquipmentScriptedAlarms { get; init; } = Array.Empty(); } public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName); /// One UNS level-5 equipment folder in the address space. is the /// logical NodeId; is the friendly UNS Name segment; /// is the parent line the folder hangs under. /// / carry the equipment's optional bindings /// (both null ⇒ driver-less / no device), copied straight from the Equipment row. /// is the device's connection host (e.g. "10.0.0.5:8193") resolved from the /// bound Device's schemaless DeviceConfig JSON via /// null when there is no device, no /// HostAddress 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: ); /// the later partition task MUST normalize the driver-discovered device-host folder segment the same way /// (trim + lower-case) so the two compare equal. /// Address-space-rebuild interaction (accepted trade-off). These three fields participate in /// 's record value-equality, which AddressSpacePlan.Compute uses to /// build its changed-equipment set. So editing a Device's DeviceConfig host/port, or /// rebinding an equipment's DriverInstanceId / DeviceId, now yields an /// 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). AddressSpacePlan is left /// unchanged. 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); /// /// One Equipment-namespace tag from a row whose /// is non-null and whose owning driver's namespace is Equipment-kind. Carries the stable /// (diff identity), the parent folder (already /// materialised by AddressSpaceApplier.MaterialiseHierarchy) the variable hangs under, the /// optional sub-folder, the leaf display, the OPC UA /// , and the driver-side reference (extracted from /// Tag.TagConfig) the later values milestone routes reads/writes by. The variable's NodeId /// is folder-scoped (parent/Name), NOT , because a raw driver ref /// (e.g. a Modbus register) is not unique across identical machines. /// mirrors the authored Tag.AccessLevel == ReadWrite so the materialised node is created /// CurrentReadWrite (the prerequisite for the inbound-write pipeline); a Read tag /// stays read-only. This flag is derived identically on the artifact-decode side /// (DeploymentArtifact.BuildEquipmentTagPlans) for byte-parity. carries /// the optional native-alarm intent parsed from Tag.TagConfig's alarm object (null ⇒ /// a plain value variable); it too is parsed identically on the artifact-decode side for byte-parity. /// / carry the optional server-side /// HistoryRead intent parsed from Tag.TagConfig's isHistorized bool + /// historianTagname string (Phase C); a null means the historian /// tagname defaults to (resolved later, not here). Both are parsed identically /// on the artifact-decode side for byte-parity. /// / carry the optional array intent parsed from /// Tag.TagConfig's isArray bool + arrayLength uint: when /// the variable materialises as a 1-D array (ValueRank=OneDimension, /// ArrayDimensions=[ArrayLength]) rather than a scalar. A null means /// length 0 (unbounded). Both are parsed identically on the artifact-decode side for byte-parity. /// 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); /// Native-alarm intent parsed from an equipment tag's TagConfig.alarm object. Null ⇒ /// the tag is a plain value variable. is an OPC UA Part 9 subtype string /// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); is the 1..1000 scale. /// is the per-condition opt-out of the durable AVEVA historian write /// (bool?; absent ⇒ null ⇒ historize). It is threaded onto each native /// AlarmTransitionEvent so the runtime's HistorianAdapterActor gate /// (historizeToAveva is not false) suppresses the durable row only on an explicit false — /// the same posture as the scripted-alarm opt-out; the live /alerts fan-out is unaffected. public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool? HistorizeToAveva = null); /// /// One Equipment-namespace VirtualTag from a row (joined to its /// for the expression). The VirtualTag value analogue of /// : AddressSpaceApplier.MaterialiseEquipmentVirtualTags /// materialises each as a Variable under its equipment folder with a folder-scoped NodeId /// (EquipmentId/Name, or EquipmentId/FolderPath/Name when a sub-folder is set), /// and VirtualTagHostActor spawns a VirtualTagActor per plan that evaluates /// over and publishes the value back to /// that NodeId. = the distinct ctx.GetTag("…") literals in /// the script source. /// /// When true, this VirtualTag's values are historized (carried from the /// VirtualTag.Historize entity column). Threaded through the deploy-diff equality below so a /// Historize-only toggle is detected as a change. Defaults to false — matching both the CLR /// default of the bool VirtualTag.Historize 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. public sealed record EquipmentVirtualTagPlan( string VirtualTagId, string EquipmentId, string FolderPath, string Name, string DataType, string Expression, IReadOnlyList DependencyRefs, bool Historize = false) { /// Structural equality: the auto-generated record equality would compare /// (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. 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); 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(); } } /// /// One Equipment-owned scripted alarm from a row, joined to its /// predicate (by ) for the source. The /// richer host analogue of the thin : the runtime alarm host /// spawns one Part 9 condition per plan, evaluates over /// , and resolves the 's /// {TagPath} tokens at emission time. = the distinct /// ctx.GetTag("…") read literals in the predicate source UNION the distinct /// {TagPath} token paths referenced in the message template (the reserved /// {{equip}} double-brace form is excluded). 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 . /// /// Stable logical id — drives the condition name + diff identity. /// Owning equipment folder the alarm hangs under. /// Operator-facing alarm name. /// Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm"). /// Numeric severity 1..1000 per OPC UA Part 9. /// Template with {TagPath} tokens resolved at emission time. /// Logical FK to the predicate script. /// The resolved predicate script source (joined by ). /// Distinct predicate read refs UNION message-template token paths. /// When true, transitions route to the Aveva Historian sink. /// OPC UA Part 9 Retain flag. /// Whether the alarm is enabled — carried for the host to decide on. public sealed record EquipmentScriptedAlarmPlan( string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate, string PredicateScriptId, string PredicateSource, IReadOnlyList DependencyRefs, bool HistorizeToAveva, bool Retain, bool Enabled) { /// Structural equality: the auto-generated record equality would compare /// (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 ). /// /// DependencyRefs equality is order-sensitive (SequenceEqual). /// 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); /// 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(); } } /// /// 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 (Task 53) consumes the result and hands it to the node-manager factory. /// /// #85 — the composer now carries UNS topology ( + /// ) so AddressSpaceApplier can build the /// Area/Line/Equipment folder hierarchy in the SDK's address space. The legacy /// EquipmentNodeWalker integration that did this server-side is fully replaced by the /// (composer → applier → sink → node manager) chain. /// public static class AddressSpaceComposer { /// Convenience overload for legacy callers + tests that don't supply UNS topology or tags. /// The equipment. /// The driver instances. /// The scripted alarms. /// The composition result. public static AddressSpaceComposition Compose( IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms) => Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms, Array.Empty(), Array.Empty()); /// UNS-aware overload that doesn't supply tags. /// The UNS areas. /// The UNS lines. /// The equipment. /// The driver instances. /// The scripted alarms. /// The per-device rows used to resolve each equipment's DeviceHost. null = none. /// The composition result. public static AddressSpaceComposition Compose( IReadOnlyList unsAreas, IReadOnlyList unsLines, IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms, IReadOnlyList? devices = null) => Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms, Array.Empty(), Array.Empty(), devices: devices); /// /// Composes the address space build plan from the configuration entities. /// /// The UNS areas. /// The UNS lines. /// The equipment. /// The driver instances. /// The scripted alarms. /// The tags. /// The namespaces. /// The Equipment-namespace virtual (calculated) tags. null = none. /// The scripts joined to by ScriptId for the expression. null = none. /// The per-device rows (DeviceId + schemaless DeviceConfig JSON) used to resolve /// each equipment's DeviceHost from its bound DeviceId. null = none. /// The composition result. public static AddressSpaceComposition Compose( IReadOnlyList unsAreas, IReadOnlyList unsLines, IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms, IReadOnlyList tags, IReadOnlyList namespaces, IReadOnlyList? virtualTags = null, IReadOnlyList