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. /// carries SystemPlatform-namespace tags (Galaxy hierarchy) so the /// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe. public sealed record Phase7CompositionResult( IReadOnlyList UnsAreas, IReadOnlyList UnsLines, IReadOnlyList EquipmentNodes, IReadOnlyList DriverInstancePlans, IReadOnlyList ScriptedAlarmPlans, IReadOnlyList GalaxyTags) { /// Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy 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, Array.Empty()) { } /// Convenience constructor for callers carrying UNS but not Galaxy data. /// The UNS areas. /// The UNS lines. /// The equipment nodes. /// The driver instance plans. /// The scripted alarm plans. public Phase7CompositionResult( IReadOnlyList unsAreas, IReadOnlyList unsLines, IReadOnlyList equipmentNodes, IReadOnlyList driverInstancePlans, IReadOnlyList scriptedAlarmPlans) : this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty()) { } /// /// Equipment-namespace tags — a with non-null /// in an Equipment-kind namespace. Mirror of for the UNS /// equipment-signal path: Phase7Applier.MaterialiseEquipmentTags materialises each as /// a Variable under its existing equipment folder. Declared as an init-only member defaulting /// to empty (rather than a 7th 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 Galaxy / SystemPlatform-namespace tag from a row where /// is null. Carries the FolderPath segment that the applier /// turns into a folder, the leaf for the Variable, the OPC UA /// , and the dot-form MXAccess reference () /// that the Galaxy driver consumes when subscribing. /// public sealed record GalaxyTagPlan( string TagId, string DriverInstanceId, string FolderPath, string DisplayName, string DataType, string MxAccessRef); /// /// 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. The equipment-signal /// analogue of . /// public sealed record EquipmentTagPlan( string TagId, string EquipmentId, string DriverInstanceId, string FolderPath, string Name, string DataType, string FullName); /// /// 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. /// public sealed record EquipmentVirtualTagPlan( string VirtualTagId, string EquipmentId, string FolderPath, string Name, string DataType, string Expression, IReadOnlyList DependencyRefs) { /// 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. 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 && 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); 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 yet supply UNS / Galaxy data. /// 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 yet supply Galaxy 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