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 Phase7CompositionResult( 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 Phase7CompositionResult( 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. Phase7Applier.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); public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId); 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 Phase7Applier.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. /// 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); /// 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. public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity); /// /// One Equipment-namespace VirtualTag from a row (joined to its /// for the expression). The VirtualTag value analogue of /// : Phase7Applier.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 Phase7Applier 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 Phase7Composer { /// 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 Phase7CompositionResult 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 composition result. public static Phase7CompositionResult Compose( IReadOnlyList unsAreas, IReadOnlyList unsLines, IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms) => Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms, Array.Empty(), Array.Empty()); /// /// 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 composition result. public static Phase7CompositionResult Compose( IReadOnlyList unsAreas, IReadOnlyList unsLines, IReadOnlyList equipment, IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms, IReadOnlyList tags, IReadOnlyList namespaces, IReadOnlyList? virtualTags = null, IReadOnlyList