using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy; /// /// Pure-function translator from the redundancy-state inputs (role, self health, peer /// reachability via HTTP + UA probes, apply-in-progress flag, recovery dwell, topology /// validity) to the OPC UA Part 5 §6.3.34 ServiceLevel value. /// /// /// Per decision #154 the 8-state matrix avoids the reserved bands (0=Maintenance, /// 1=NoData) for operational states. Operational values occupy 2..255 so a spec-compliant /// client that cuts over on "<3 = unhealthy" keeps working without its vendor treating /// the server as "under maintenance" during normal runtime. /// /// This class is pure — no threads, no I/O. The coordinator that owns it re-evaluates /// on every input change and pushes the new byte through an IObserver<byte> to /// the OPC UA ServiceLevel variable. Tests exercise the full matrix without touching a UA /// stack. /// public static class ServiceLevelCalculator { /// Compute the ServiceLevel for the given inputs. /// Role declared for this node in the shared config DB. /// This node's own health (from Phase 6.1 /healthz). /// Peer node reachable via OPC UA probe. /// Peer node reachable via HTTP /healthz probe. /// True while this node is inside a publish-generation apply window. /// True once the post-fault dwell + publish-witness conditions are met. /// False when the cluster has detected >1 Primary (InvalidTopology demotes both nodes). /// True when operator has declared the node in maintenance. public static byte Compute( RedundancyRole role, bool selfHealthy, bool peerUaHealthy, bool peerHttpHealthy, bool applyInProgress, bool recoveryDwellMet, bool topologyValid, bool operatorMaintenance = false) { // Reserved bands first — they override everything per OPC UA Part 5 §6.3.34. if (operatorMaintenance) return (byte)ServiceLevelBand.Maintenance; // 0 if (!selfHealthy) return (byte)ServiceLevelBand.NoData; // 1 if (!topologyValid) return (byte)ServiceLevelBand.InvalidTopology; // 2 // Standalone nodes have no peer — treat as authoritative when healthy. if (role == RedundancyRole.Standalone) return (byte)(applyInProgress ? ServiceLevelBand.PrimaryMidApply : ServiceLevelBand.AuthoritativePrimary); var isPrimary = role == RedundancyRole.Primary; // Apply-in-progress band dominates recovery + isolation (client should cut to peer). if (applyInProgress) return (byte)(isPrimary ? ServiceLevelBand.PrimaryMidApply : ServiceLevelBand.BackupMidApply); // Post-fault recovering — hold until dwell + witness satisfied. if (!recoveryDwellMet) return (byte)(isPrimary ? ServiceLevelBand.RecoveringPrimary : ServiceLevelBand.RecoveringBackup); // Peer unreachable (either probe fails) → isolated band. Per decision #154 Primary // retains authority at 230 when isolated; Backup signals 80 "take over if asked" and // does NOT auto-promote (non-transparent model). var peerReachable = peerUaHealthy && peerHttpHealthy; if (!peerReachable) return (byte)(isPrimary ? ServiceLevelBand.IsolatedPrimary : ServiceLevelBand.IsolatedBackup); return (byte)(isPrimary ? ServiceLevelBand.AuthoritativePrimary : ServiceLevelBand.AuthoritativeBackup); } /// Labels a ServiceLevel byte with its matrix band name — for logs + Admin UI. public static ServiceLevelBand Classify(byte value) => value switch { (byte)ServiceLevelBand.Maintenance => ServiceLevelBand.Maintenance, (byte)ServiceLevelBand.NoData => ServiceLevelBand.NoData, (byte)ServiceLevelBand.InvalidTopology => ServiceLevelBand.InvalidTopology, (byte)ServiceLevelBand.RecoveringBackup => ServiceLevelBand.RecoveringBackup, (byte)ServiceLevelBand.BackupMidApply => ServiceLevelBand.BackupMidApply, (byte)ServiceLevelBand.IsolatedBackup => ServiceLevelBand.IsolatedBackup, (byte)ServiceLevelBand.AuthoritativeBackup => ServiceLevelBand.AuthoritativeBackup, (byte)ServiceLevelBand.RecoveringPrimary => ServiceLevelBand.RecoveringPrimary, (byte)ServiceLevelBand.PrimaryMidApply => ServiceLevelBand.PrimaryMidApply, (byte)ServiceLevelBand.IsolatedPrimary => ServiceLevelBand.IsolatedPrimary, (byte)ServiceLevelBand.AuthoritativePrimary => ServiceLevelBand.AuthoritativePrimary, _ => ServiceLevelBand.Unknown, }; } /// /// Named bands of the 8-state ServiceLevel matrix. Numeric values match the /// table exactly; any drift will be caught by the /// Phase 6.3 compliance script. /// public enum ServiceLevelBand : byte { /// Operator-declared maintenance. Reserved per OPC UA Part 5 §6.3.34. Maintenance = 0, /// Unreachable / Faulted. Reserved per OPC UA Part 5 §6.3.34. NoData = 1, /// Detected-inconsistency band — >1 Primary observed runtime; both nodes self-demote. InvalidTopology = 2, /// Backup post-fault, dwell not met. RecoveringBackup = 30, /// Backup inside a publish-apply window. BackupMidApply = 50, /// Backup with unreachable Primary — "take over if asked"; does NOT auto-promote. IsolatedBackup = 80, /// Backup nominal operation. AuthoritativeBackup = 100, /// Primary post-fault, dwell not met. RecoveringPrimary = 180, /// Primary inside a publish-apply window. PrimaryMidApply = 200, /// Primary with unreachable peer, self serving — retains authority. IsolatedPrimary = 230, /// Primary nominal operation. AuthoritativePrimary = 255, /// Sentinel for unrecognised byte values. Unknown = 254, }