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,
}