diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs
index 3c276c56..0afcf188 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/RedundancyStateActor.cs
@@ -104,7 +104,7 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
: CommonsRedundancyRole.Detached;
list.Add(new NodeRedundancyState(
- NodeId.Parse(host),
+ ToNodeId(member.Address),
role,
IsClusterLeader: clusterLeader == member.Address,
IsRoleLeaderForDriver: driverLeader == member.Address,
@@ -113,6 +113,16 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
return list;
}
+ ///
+ /// Builds the canonical cluster node id (host:port) for an Akka member address — the SAME
+ /// format ClusterRoleInfo.LocalNode/ToNodeId use, so redundancy-state consumers
+ /// (OPC UA ServiceLevel, scripted-alarm emit gate, historian gate) can match their local node.
+ ///
+ /// The cluster member's address.
+ /// The canonical host:port node id.
+ public static NodeId ToNodeId(Akka.Actor.Address address) =>
+ NodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}");
+
public sealed class RecomputeNow
{
public static readonly RecomputeNow Instance = new();
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs
index 505ada1c..5f0df58e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/RedundancyStateActorTests.cs
@@ -42,4 +42,30 @@ public sealed class RedundancyStateActorTests : ControlPlaneActorTestBase
// After debounce settles, no more events are fired by a quiescent cluster.
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
+
+ ///
+ /// Regression guard: the snapshot node id MUST be the canonical host:port form (matching
+ /// ClusterRoleInfo.LocalNode/ToNodeId), or every consumer's
+ /// n.NodeId == _localNode.Value match fails and no node ever learns its role.
+ ///
+ [Fact]
+ public void ToNodeId_uses_canonical_host_and_port()
+ {
+ var nodeId = RedundancyStateActor.ToNodeId(
+ new Address("akka.tcp", "otopcua", "central-2", 4053));
+
+ nodeId.Value.ShouldBe("central-2:4053");
+ }
+
+ ///
+ /// Documents the host-less/port-less fallback (:0). Such members are skipped by
+ /// BuildSnapshot's guard, but the helper must still format deterministically.
+ ///
+ [Fact]
+ public void ToNodeId_handles_missing_port()
+ {
+ var nodeId = RedundancyStateActor.ToNodeId(new Address("akka.tcp", "otopcua"));
+
+ nodeId.Value.ShouldBe(":0");
+ }
}