diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index cc7bc01..13dbf2f 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -14,6 +14,7 @@
+
@@ -41,6 +42,7 @@
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs
new file mode 100644
index 0000000..ca443a1
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs
@@ -0,0 +1,39 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+///
+/// Wire shape for a parsed FOCAS address. Mirrors FocasAddress in the driver
+/// package but lives in Shared so the Host (.NET 4.8) can decode without taking a
+/// reference to the .NET 10 driver assembly. The Proxy serializes from its own
+/// FocasAddress; the Host maps back to its local equivalent.
+///
+[MessagePackObject]
+public sealed class FocasAddressDto
+{
+ /// 0 = Pmc, 1 = Parameter, 2 = Macro. Matches FocasAreaKind enum order.
+ [Key(0)] public int Kind { get; set; }
+
+ /// PMC letter — null for Parameter / Macro.
+ [Key(1)] public string? PmcLetter { get; set; }
+
+ [Key(2)] public int Number { get; set; }
+
+ /// Optional bit index (0-7 for PMC, 0-31 for Parameter).
+ [Key(3)] public int? BitIndex { get; set; }
+}
+
+///
+/// 0 = Bit, 1 = Byte, 2 = Int16, 3 = Int32, 4 = Float32, 5 = Float64, 6 = String.
+/// Matches FocasDataType enum order so both sides can cast (int).
+///
+public static class FocasDataTypeCode
+{
+ public const int Bit = 0;
+ public const int Byte = 1;
+ public const int Int16 = 2;
+ public const int Int32 = 3;
+ public const int Float32 = 4;
+ public const int Float64 = 5;
+ public const int String = 6;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs
new file mode 100644
index 0000000..fe2b979
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs
@@ -0,0 +1,57 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+///
+/// Length-prefixed framing. Each IPC frame is:
+/// [4-byte big-endian length][1-byte message kind][MessagePack body].
+/// Length is the body size only; the kind byte is not part of the prefixed length.
+/// Mirrors the Galaxy Tier-C framing so operators see one wire format across hosts.
+///
+public static class Framing
+{
+ public const int LengthPrefixSize = 4;
+ public const int KindByteSize = 1;
+
+ ///
+ /// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
+ /// misbehaving peer sending an oversized length prefix.
+ ///
+ public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
+}
+
+///
+/// Wire identifier for each contract. Values are stable — new contracts append, never
+/// reuse. Ranges kept aligned with Galaxy so an operator reading a hex dump doesn't have
+/// to context-switch between drivers.
+///
+public enum FocasMessageKind : byte
+{
+ Hello = 0x01,
+ HelloAck = 0x02,
+ Heartbeat = 0x03,
+ HeartbeatAck = 0x04,
+
+ OpenSessionRequest = 0x10,
+ OpenSessionResponse = 0x11,
+ CloseSessionRequest = 0x12,
+
+ ReadRequest = 0x30,
+ ReadResponse = 0x31,
+ WriteRequest = 0x32,
+ WriteResponse = 0x33,
+ PmcBitWriteRequest = 0x34,
+ PmcBitWriteResponse = 0x35,
+
+ SubscribeRequest = 0x40,
+ SubscribeResponse = 0x41,
+ UnsubscribeRequest = 0x42,
+ OnDataChangeNotification = 0x43,
+
+ ProbeRequest = 0x70,
+ ProbeResponse = 0x71,
+ RuntimeStatusChange = 0x72,
+
+ RecycleHostRequest = 0xF0,
+ RecycleStatusResponse = 0xF1,
+
+ ErrorResponse = 0xFE,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
new file mode 100644
index 0000000..716bb1a
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
@@ -0,0 +1,63 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+///
+/// First frame of every FOCAS Proxy -> Host connection. Advertises protocol major/minor
+/// and the per-process shared secret the Proxy passed to the Host at spawn time. Major
+/// mismatch is fatal; minor is advisory.
+///
+[MessagePackObject]
+public sealed class Hello
+{
+ public const int CurrentMajor = 1;
+ public const int CurrentMinor = 0;
+
+ [Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
+ [Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
+ [Key(2)] public string PeerName { get; set; } = string.Empty;
+
+ ///
+ /// Per-process shared secret verified on the Host side against the value passed by the
+ /// supervisor at spawn time. Protects against a local attacker connecting to the pipe
+ /// after authenticating via the pipe ACL.
+ ///
+ [Key(3)] public string SharedSecret { get; set; } = string.Empty;
+
+ [Key(4)] public string[] Features { get; set; } = System.Array.Empty();
+}
+
+[MessagePackObject]
+public sealed class HelloAck
+{
+ [Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
+ [Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
+
+ /// True if the Host accepted the hello; false + filled if not.
+ [Key(2)] public bool Accepted { get; set; }
+ [Key(3)] public string? RejectReason { get; set; }
+
+ [Key(4)] public string HostName { get; set; } = string.Empty;
+}
+
+[MessagePackObject]
+public sealed class Heartbeat
+{
+ [Key(0)] public long MonotonicTicks { get; set; }
+}
+
+[MessagePackObject]
+public sealed class HeartbeatAck
+{
+ [Key(0)] public long MonotonicTicks { get; set; }
+ [Key(1)] public long HostUtcUnixMs { get; set; }
+}
+
+[MessagePackObject]
+public sealed class ErrorResponse
+{
+ /// Stable symbolic code — e.g. InvalidAddress, SessionNotFound, Fwlib32Crashed.
+ [Key(0)] public string Code { get; set; } = string.Empty;
+
+ [Key(1)] public string Message { get; set; } = string.Empty;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
new file mode 100644
index 0000000..151f9d7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
@@ -0,0 +1,47 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+/// Lightweight connectivity probe — maps to cnc_rdcncstat on the Host.
+[MessagePackObject]
+public sealed class ProbeRequest
+{
+ [Key(0)] public long SessionId { get; set; }
+ [Key(1)] public int TimeoutMs { get; set; } = 2000;
+}
+
+[MessagePackObject]
+public sealed class ProbeResponse
+{
+ [Key(0)] public bool Healthy { get; set; }
+ [Key(1)] public string? Error { get; set; }
+ [Key(2)] public long ObservedAtUtcUnixMs { get; set; }
+}
+
+/// Per-host runtime status — fan-out target when the Host observes the CNC going unreachable without the Proxy asking.
+[MessagePackObject]
+public sealed class RuntimeStatusChangeNotification
+{
+ [Key(0)] public long SessionId { get; set; }
+
+ /// Running | Stopped | Unknown.
+ [Key(1)] public string RuntimeStatus { get; set; } = string.Empty;
+
+ [Key(2)] public long ObservedAtUtcUnixMs { get; set; }
+}
+
+[MessagePackObject]
+public sealed class RecycleHostRequest
+{
+ /// Soft | Hard. Soft drains subscriptions first; Hard kills immediately.
+ [Key(0)] public string Kind { get; set; } = "Soft";
+ [Key(1)] public string Reason { get; set; } = string.Empty;
+}
+
+[MessagePackObject]
+public sealed class RecycleStatusResponse
+{
+ [Key(0)] public bool Accepted { get; set; }
+ [Key(1)] public int GraceSeconds { get; set; } = 15;
+ [Key(2)] public string? Error { get; set; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs
new file mode 100644
index 0000000..3f3e7ae
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs
@@ -0,0 +1,85 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+///
+/// Read one FOCAS address. Multi-read is the Proxy's responsibility — it batches
+/// per-tag reads into parallel frames the Host services on its
+/// STA thread. Keeping the IPC read single-address keeps the Host side trivial; FOCAS
+/// itself has no multi-read primitive that spans area kinds.
+///
+[MessagePackObject]
+public sealed class ReadRequest
+{
+ [Key(0)] public long SessionId { get; set; }
+ [Key(1)] public FocasAddressDto Address { get; set; } = new();
+ [Key(2)] public int DataType { get; set; }
+ [Key(3)] public int TimeoutMs { get; set; } = 2000;
+}
+
+[MessagePackObject]
+public sealed class ReadResponse
+{
+ [Key(0)] public bool Success { get; set; }
+ [Key(1)] public string? Error { get; set; }
+
+ /// OPC UA status code mapped by the Host via FocasStatusMapper — 0 = Good.
+ [Key(2)] public uint StatusCode { get; set; }
+
+ /// MessagePack-serialized boxed value. null when is false.
+ [Key(3)] public byte[]? ValueBytes { get; set; }
+
+ /// Matches so the Proxy knows how to deserialize.
+ [Key(4)] public int ValueTypeCode { get; set; }
+
+ [Key(5)] public long SourceTimestampUtcUnixMs { get; set; }
+}
+
+[MessagePackObject]
+public sealed class WriteRequest
+{
+ [Key(0)] public long SessionId { get; set; }
+ [Key(1)] public FocasAddressDto Address { get; set; } = new();
+ [Key(2)] public int DataType { get; set; }
+ [Key(3)] public byte[]? ValueBytes { get; set; }
+ [Key(4)] public int ValueTypeCode { get; set; }
+ [Key(5)] public int TimeoutMs { get; set; } = 2000;
+}
+
+[MessagePackObject]
+public sealed class WriteResponse
+{
+ [Key(0)] public bool Success { get; set; }
+ [Key(1)] public string? Error { get; set; }
+
+ /// OPC UA status code — 0 = Good.
+ [Key(2)] public uint StatusCode { get; set; }
+}
+
+///
+/// PMC bit read-modify-write. Handled as a first-class operation (not two separate
+/// read+write round-trips) so the critical section stays on the Host — serializing
+/// concurrent bit writers to the same parent byte is Host-side via
+/// SemaphoreSlim keyed on (PmcLetter, Number). Mirrors the in-process
+/// pattern from FocasPmcBitRmw.
+///
+[MessagePackObject]
+public sealed class PmcBitWriteRequest
+{
+ [Key(0)] public long SessionId { get; set; }
+ [Key(1)] public FocasAddressDto Address { get; set; } = new();
+
+ /// The bit index to set/clear. 0-7.
+ [Key(2)] public int BitIndex { get; set; }
+
+ [Key(3)] public bool Value { get; set; }
+ [Key(4)] public int TimeoutMs { get; set; } = 2000;
+}
+
+[MessagePackObject]
+public sealed class PmcBitWriteResponse
+{
+ [Key(0)] public bool Success { get; set; }
+ [Key(1)] public string? Error { get; set; }
+ [Key(2)] public uint StatusCode { get; set; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs
new file mode 100644
index 0000000..fad9126
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs
@@ -0,0 +1,31 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+///
+/// Open a FOCAS session against the CNC at . One session per
+/// configured device. The Host owns the Fwlib32 handle; the Proxy tracks only the
+/// opaque returned on success.
+///
+[MessagePackObject]
+public sealed class OpenSessionRequest
+{
+ [Key(0)] public string HostAddress { get; set; } = string.Empty;
+ [Key(1)] public int TimeoutMs { get; set; } = 2000;
+ [Key(2)] public int CncSeries { get; set; }
+}
+
+[MessagePackObject]
+public sealed class OpenSessionResponse
+{
+ [Key(0)] public bool Success { get; set; }
+ [Key(1)] public long SessionId { get; set; }
+ [Key(2)] public string? Error { get; set; }
+ [Key(3)] public string? ErrorCode { get; set; }
+}
+
+[MessagePackObject]
+public sealed class CloseSessionRequest
+{
+ [Key(0)] public long SessionId { get; set; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs
new file mode 100644
index 0000000..9417d2b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs
@@ -0,0 +1,61 @@
+using MessagePack;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+///
+/// Subscribe the Host to polling a set of tags on behalf of the Proxy. FOCAS is
+/// poll-only — there are no CNC-initiated callbacks — so the Host runs the poll loop and
+/// pushes frames whenever a value differs from
+/// the last observation. Delta-only + per-group interval keeps the wire quiet.
+///
+[MessagePackObject]
+public sealed class SubscribeRequest
+{
+ [Key(0)] public long SessionId { get; set; }
+ [Key(1)] public long SubscriptionId { get; set; }
+ [Key(2)] public int IntervalMs { get; set; } = 1000;
+ [Key(3)] public SubscribeItem[] Items { get; set; } = System.Array.Empty();
+}
+
+[MessagePackObject]
+public sealed class SubscribeItem
+{
+ /// Opaque correlation id the Proxy uses to route notifications back to the right OPC UA MonitoredItem.
+ [Key(0)] public long MonitoredItemId { get; set; }
+
+ [Key(1)] public FocasAddressDto Address { get; set; } = new();
+ [Key(2)] public int DataType { get; set; }
+}
+
+[MessagePackObject]
+public sealed class SubscribeResponse
+{
+ [Key(0)] public bool Success { get; set; }
+ [Key(1)] public string? Error { get; set; }
+
+ /// Items the Host refused (address mismatch, unsupported type). Empty on full success.
+ [Key(2)] public long[] RejectedMonitoredItemIds { get; set; } = System.Array.Empty();
+}
+
+[MessagePackObject]
+public sealed class UnsubscribeRequest
+{
+ [Key(0)] public long SubscriptionId { get; set; }
+}
+
+[MessagePackObject]
+public sealed class OnDataChangeNotification
+{
+ [Key(0)] public long SubscriptionId { get; set; }
+ [Key(1)] public DataChange[] Changes { get; set; } = System.Array.Empty();
+}
+
+[MessagePackObject]
+public sealed class DataChange
+{
+ [Key(0)] public long MonitoredItemId { get; set; }
+ [Key(1)] public uint StatusCode { get; set; }
+ [Key(2)] public byte[]? ValueBytes { get; set; }
+ [Key(3)] public int ValueTypeCode { get; set; }
+ [Key(4)] public long SourceTimestampUtcUnixMs { get; set; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
new file mode 100644
index 0000000..7cb142d
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
@@ -0,0 +1,67 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MessagePack;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
+
+///
+/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
+/// from multiple threads against the same instance.
+///
+public sealed class FrameReader : IDisposable
+{
+ private readonly Stream _stream;
+ private readonly bool _leaveOpen;
+
+ public FrameReader(Stream stream, bool leaveOpen = false)
+ {
+ _stream = stream ?? throw new ArgumentNullException(nameof(stream));
+ _leaveOpen = leaveOpen;
+ }
+
+ public async Task<(FocasMessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
+ {
+ var lengthPrefix = new byte[Framing.LengthPrefixSize];
+ if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
+ return null;
+
+ var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
+ if (length < 0 || length > Framing.MaxFrameBodyBytes)
+ throw new InvalidDataException($"IPC frame length {length} out of range.");
+
+ var kindByte = _stream.ReadByte();
+ if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
+
+ var body = new byte[length];
+ if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
+ throw new EndOfStreamException("EOF mid-frame.");
+
+ return ((FocasMessageKind)(byte)kindByte, body);
+ }
+
+ public static T Deserialize(byte[] body) => MessagePackSerializer.Deserialize(body);
+
+ private async Task ReadExactAsync(byte[] buffer, CancellationToken ct)
+ {
+ var offset = 0;
+ while (offset < buffer.Length)
+ {
+ var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
+ if (read == 0)
+ {
+ if (offset == 0) return false;
+ throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
+ }
+ offset += read;
+ }
+ return true;
+ }
+
+ public void Dispose()
+ {
+ if (!_leaveOpen) _stream.Dispose();
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
new file mode 100644
index 0000000..76b0b19
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
@@ -0,0 +1,56 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using MessagePack;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
+
+///
+/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
+/// — multiple producers (e.g. heartbeat + data-plane sharing a
+/// stream) get serialized writes.
+///
+public sealed class FrameWriter : IDisposable
+{
+ private readonly Stream _stream;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private readonly bool _leaveOpen;
+
+ public FrameWriter(Stream stream, bool leaveOpen = false)
+ {
+ _stream = stream ?? throw new ArgumentNullException(nameof(stream));
+ _leaveOpen = leaveOpen;
+ }
+
+ public async Task WriteAsync(FocasMessageKind kind, T message, CancellationToken ct)
+ {
+ var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
+ if (body.Length > Framing.MaxFrameBodyBytes)
+ throw new InvalidOperationException(
+ $"IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
+
+ var lengthPrefix = new byte[Framing.LengthPrefixSize];
+ lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
+ lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
+ lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
+ lengthPrefix[3] = (byte)( body.Length & 0xFF);
+
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
+ _stream.WriteByte((byte)kind);
+ await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
+ await _stream.FlushAsync(ct).ConfigureAwait(false);
+ }
+ finally { _gate.Release(); }
+ }
+
+ public void Dispose()
+ {
+ _gate.Dispose();
+ if (!_leaveOpen) _stream.Dispose();
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj
new file mode 100644
index 0000000..a154f19
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj
@@ -0,0 +1,23 @@
+
+
+
+ netstandard2.0
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ContractRoundTripTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ContractRoundTripTests.cs
new file mode 100644
index 0000000..bbe85d6
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ContractRoundTripTests.cs
@@ -0,0 +1,280 @@
+using MessagePack;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
+
+///
+/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
+/// [Key]-tagged fields survive serialize -> deserialize without loss so the
+/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
+///
+[Trait("Category", "Unit")]
+public sealed class ContractRoundTripTests
+{
+ private static T RoundTrip(T value)
+ {
+ var bytes = MessagePackSerializer.Serialize(value);
+ return MessagePackSerializer.Deserialize(bytes);
+ }
+
+ [Fact]
+ public void Hello_round_trips()
+ {
+ var original = new Hello
+ {
+ ProtocolMajor = 1,
+ ProtocolMinor = 2,
+ PeerName = "OtOpcUa.Server",
+ SharedSecret = "abc-123",
+ Features = ["bulk-read", "pmc-rmw"],
+ };
+ var decoded = RoundTrip(original);
+ decoded.ProtocolMajor.ShouldBe(1);
+ decoded.ProtocolMinor.ShouldBe(2);
+ decoded.PeerName.ShouldBe("OtOpcUa.Server");
+ decoded.SharedSecret.ShouldBe("abc-123");
+ decoded.Features.ShouldBe(["bulk-read", "pmc-rmw"]);
+ }
+
+ [Fact]
+ public void HelloAck_rejected_carries_reason()
+ {
+ var original = new HelloAck { Accepted = false, RejectReason = "bad secret" };
+ var decoded = RoundTrip(original);
+ decoded.Accepted.ShouldBeFalse();
+ decoded.RejectReason.ShouldBe("bad secret");
+ }
+
+ [Fact]
+ public void Heartbeat_and_ack_preserve_ticks()
+ {
+ var hb = RoundTrip(new Heartbeat { MonotonicTicks = 987654321 });
+ hb.MonotonicTicks.ShouldBe(987654321);
+
+ var ack = RoundTrip(new HeartbeatAck { MonotonicTicks = 987654321, HostUtcUnixMs = 1_700_000_000_000 });
+ ack.MonotonicTicks.ShouldBe(987654321);
+ ack.HostUtcUnixMs.ShouldBe(1_700_000_000_000);
+ }
+
+ [Fact]
+ public void ErrorResponse_preserves_code_and_message()
+ {
+ var decoded = RoundTrip(new ErrorResponse { Code = "Fwlib32Crashed", Message = "EW_UNEXPECTED" });
+ decoded.Code.ShouldBe("Fwlib32Crashed");
+ decoded.Message.ShouldBe("EW_UNEXPECTED");
+ }
+
+ [Fact]
+ public void OpenSessionRequest_preserves_series_and_timeout()
+ {
+ var decoded = RoundTrip(new OpenSessionRequest
+ {
+ HostAddress = "192.168.1.50:8193",
+ TimeoutMs = 3500,
+ CncSeries = 5,
+ });
+ decoded.HostAddress.ShouldBe("192.168.1.50:8193");
+ decoded.TimeoutMs.ShouldBe(3500);
+ decoded.CncSeries.ShouldBe(5);
+ }
+
+ [Fact]
+ public void OpenSessionResponse_failure_carries_error_code()
+ {
+ var decoded = RoundTrip(new OpenSessionResponse
+ {
+ Success = false,
+ SessionId = 0,
+ Error = "unreachable",
+ ErrorCode = "EW_SOCKET",
+ });
+ decoded.Success.ShouldBeFalse();
+ decoded.Error.ShouldBe("unreachable");
+ decoded.ErrorCode.ShouldBe("EW_SOCKET");
+ }
+
+ [Fact]
+ public void FocasAddressDto_carries_pmc_with_bit_index()
+ {
+ var decoded = RoundTrip(new FocasAddressDto
+ {
+ Kind = 0,
+ PmcLetter = "R",
+ Number = 100,
+ BitIndex = 3,
+ });
+ decoded.Kind.ShouldBe(0);
+ decoded.PmcLetter.ShouldBe("R");
+ decoded.Number.ShouldBe(100);
+ decoded.BitIndex.ShouldBe(3);
+ }
+
+ [Fact]
+ public void FocasAddressDto_macro_omits_letter_and_bit()
+ {
+ var decoded = RoundTrip(new FocasAddressDto { Kind = 2, Number = 500 });
+ decoded.Kind.ShouldBe(2);
+ decoded.PmcLetter.ShouldBeNull();
+ decoded.Number.ShouldBe(500);
+ decoded.BitIndex.ShouldBeNull();
+ }
+
+ [Fact]
+ public void ReadRequest_and_response_round_trip()
+ {
+ var req = RoundTrip(new ReadRequest
+ {
+ SessionId = 42,
+ Address = new FocasAddressDto { Kind = 1, Number = 1815 },
+ DataType = FocasDataTypeCode.Int32,
+ TimeoutMs = 1500,
+ });
+ req.SessionId.ShouldBe(42);
+ req.Address.Number.ShouldBe(1815);
+ req.DataType.ShouldBe(FocasDataTypeCode.Int32);
+
+ var resp = RoundTrip(new ReadResponse
+ {
+ Success = true,
+ StatusCode = 0,
+ ValueBytes = MessagePackSerializer.Serialize((int)12345),
+ ValueTypeCode = FocasDataTypeCode.Int32,
+ SourceTimestampUtcUnixMs = 1_700_000_000_000,
+ });
+ resp.Success.ShouldBeTrue();
+ resp.StatusCode.ShouldBe(0u);
+ MessagePackSerializer.Deserialize(resp.ValueBytes!).ShouldBe(12345);
+ resp.ValueTypeCode.ShouldBe(FocasDataTypeCode.Int32);
+ }
+
+ [Fact]
+ public void WriteRequest_and_response_round_trip()
+ {
+ var req = RoundTrip(new WriteRequest
+ {
+ SessionId = 1,
+ Address = new FocasAddressDto { Kind = 2, Number = 500 },
+ DataType = FocasDataTypeCode.Float64,
+ ValueBytes = MessagePackSerializer.Serialize(3.14159),
+ ValueTypeCode = FocasDataTypeCode.Float64,
+ });
+ MessagePackSerializer.Deserialize(req.ValueBytes!).ShouldBe(3.14159);
+
+ var resp = RoundTrip(new WriteResponse { Success = true, StatusCode = 0 });
+ resp.Success.ShouldBeTrue();
+ resp.StatusCode.ShouldBe(0u);
+ }
+
+ [Fact]
+ public void PmcBitWriteRequest_preserves_bit_and_value()
+ {
+ var req = RoundTrip(new PmcBitWriteRequest
+ {
+ SessionId = 7,
+ Address = new FocasAddressDto { Kind = 0, PmcLetter = "Y", Number = 12 },
+ BitIndex = 5,
+ Value = true,
+ });
+ req.BitIndex.ShouldBe(5);
+ req.Value.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void SubscribeRequest_round_trips_multiple_items()
+ {
+ var original = new SubscribeRequest
+ {
+ SessionId = 1,
+ SubscriptionId = 100,
+ IntervalMs = 250,
+ Items =
+ [
+ new() { MonitoredItemId = 1, Address = new() { Kind = 0, PmcLetter = "R", Number = 100 }, DataType = FocasDataTypeCode.Bit },
+ new() { MonitoredItemId = 2, Address = new() { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64 },
+ ],
+ };
+ var decoded = RoundTrip(original);
+ decoded.Items.Length.ShouldBe(2);
+ decoded.Items[0].MonitoredItemId.ShouldBe(1);
+ decoded.Items[0].Address.PmcLetter.ShouldBe("R");
+ decoded.Items[1].DataType.ShouldBe(FocasDataTypeCode.Float64);
+ }
+
+ [Fact]
+ public void SubscribeResponse_rejected_items_survive()
+ {
+ var decoded = RoundTrip(new SubscribeResponse
+ {
+ Success = true,
+ RejectedMonitoredItemIds = [2, 7],
+ });
+ decoded.RejectedMonitoredItemIds.ShouldBe([2, 7]);
+ }
+
+ [Fact]
+ public void UnsubscribeRequest_round_trips()
+ {
+ var decoded = RoundTrip(new UnsubscribeRequest { SubscriptionId = 42 });
+ decoded.SubscriptionId.ShouldBe(42);
+ }
+
+ [Fact]
+ public void OnDataChangeNotification_round_trips()
+ {
+ var original = new OnDataChangeNotification
+ {
+ SubscriptionId = 100,
+ Changes =
+ [
+ new()
+ {
+ MonitoredItemId = 1,
+ StatusCode = 0,
+ ValueBytes = MessagePackSerializer.Serialize(true),
+ ValueTypeCode = FocasDataTypeCode.Bit,
+ SourceTimestampUtcUnixMs = 1_700_000_000_000,
+ },
+ ],
+ };
+ var decoded = RoundTrip(original);
+ decoded.Changes.Length.ShouldBe(1);
+ MessagePackSerializer.Deserialize(decoded.Changes[0].ValueBytes!).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ProbeRequest_and_response_round_trip()
+ {
+ var req = RoundTrip(new ProbeRequest { SessionId = 1, TimeoutMs = 500 });
+ req.TimeoutMs.ShouldBe(500);
+
+ var resp = RoundTrip(new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 });
+ resp.Healthy.ShouldBeTrue();
+ resp.ObservedAtUtcUnixMs.ShouldBe(1_700_000_000_000);
+ }
+
+ [Fact]
+ public void RuntimeStatusChangeNotification_round_trips()
+ {
+ var decoded = RoundTrip(new RuntimeStatusChangeNotification
+ {
+ SessionId = 5,
+ RuntimeStatus = "Stopped",
+ ObservedAtUtcUnixMs = 1_700_000_000_000,
+ });
+ decoded.RuntimeStatus.ShouldBe("Stopped");
+ }
+
+ [Fact]
+ public void RecycleHostRequest_and_response_round_trip()
+ {
+ var req = RoundTrip(new RecycleHostRequest { Kind = "Hard", Reason = "wedge-detected" });
+ req.Kind.ShouldBe("Hard");
+ req.Reason.ShouldBe("wedge-detected");
+
+ var resp = RoundTrip(new RecycleStatusResponse { Accepted = true, GraceSeconds = 20 });
+ resp.Accepted.ShouldBeTrue();
+ resp.GraceSeconds.ShouldBe(20);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/FramingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/FramingTests.cs
new file mode 100644
index 0000000..66778da
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/FramingTests.cs
@@ -0,0 +1,107 @@
+using System.IO;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class FramingTests
+{
+ [Fact]
+ public async Task FrameWriter_round_trips_single_frame_through_FrameReader()
+ {
+ var buffer = new MemoryStream();
+ using (var writer = new FrameWriter(buffer, leaveOpen: true))
+ {
+ await writer.WriteAsync(FocasMessageKind.Hello,
+ new Hello { PeerName = "proxy", SharedSecret = "s3cr3t" }, TestContext.Current.CancellationToken);
+ }
+
+ buffer.Position = 0;
+ using var reader = new FrameReader(buffer, leaveOpen: true);
+ var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
+ frame.ShouldNotBeNull();
+ frame!.Value.Kind.ShouldBe(FocasMessageKind.Hello);
+ var hello = FrameReader.Deserialize(frame.Value.Body);
+ hello.PeerName.ShouldBe("proxy");
+ hello.SharedSecret.ShouldBe("s3cr3t");
+ }
+
+ [Fact]
+ public async Task FrameReader_returns_null_on_clean_EOF_at_frame_boundary()
+ {
+ using var empty = new MemoryStream();
+ using var reader = new FrameReader(empty, leaveOpen: true);
+ var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
+ frame.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task FrameReader_throws_on_oversized_length_prefix()
+ {
+ var hostile = new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0x01 }; // length > 16 MiB
+ using var stream = new MemoryStream(hostile);
+ using var reader = new FrameReader(stream, leaveOpen: true);
+ await Should.ThrowAsync(async () =>
+ await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task FrameReader_throws_on_mid_frame_eof()
+ {
+ var buffer = new MemoryStream();
+ using (var writer = new FrameWriter(buffer, leaveOpen: true))
+ {
+ await writer.WriteAsync(FocasMessageKind.Hello, new Hello { PeerName = "x" },
+ TestContext.Current.CancellationToken);
+ }
+ // Truncate so body is incomplete.
+ var truncated = buffer.ToArray()[..(buffer.ToArray().Length - 2)];
+ using var partial = new MemoryStream(truncated);
+ using var reader = new FrameReader(partial, leaveOpen: true);
+ await Should.ThrowAsync(async () =>
+ await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task FrameWriter_serializes_concurrent_writes()
+ {
+ var buffer = new MemoryStream();
+ using var writer = new FrameWriter(buffer, leaveOpen: true);
+
+ var tasks = Enumerable.Range(0, 20).Select(i => writer.WriteAsync(
+ FocasMessageKind.Heartbeat,
+ new Heartbeat { MonotonicTicks = i },
+ TestContext.Current.CancellationToken)).ToArray();
+ await Task.WhenAll(tasks);
+
+ buffer.Position = 0;
+ using var reader = new FrameReader(buffer, leaveOpen: true);
+ var seen = new List();
+ while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
+ {
+ frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
+ seen.Add(FrameReader.Deserialize(frame.Body).MonotonicTicks);
+ }
+ seen.Count.ShouldBe(20);
+ seen.OrderBy(x => x).ShouldBe(Enumerable.Range(0, 20).Select(x => (long)x));
+ }
+
+ [Fact]
+ public void MessageKind_values_are_stable()
+ {
+ // Guardrail — if someone reorders/renumbers, the wire format breaks for deployed peers.
+ ((byte)FocasMessageKind.Hello).ShouldBe((byte)0x01);
+ ((byte)FocasMessageKind.Heartbeat).ShouldBe((byte)0x03);
+ ((byte)FocasMessageKind.OpenSessionRequest).ShouldBe((byte)0x10);
+ ((byte)FocasMessageKind.ReadRequest).ShouldBe((byte)0x30);
+ ((byte)FocasMessageKind.WriteRequest).ShouldBe((byte)0x32);
+ ((byte)FocasMessageKind.PmcBitWriteRequest).ShouldBe((byte)0x34);
+ ((byte)FocasMessageKind.SubscribeRequest).ShouldBe((byte)0x40);
+ ((byte)FocasMessageKind.OnDataChangeNotification).ShouldBe((byte)0x43);
+ ((byte)FocasMessageKind.ProbeRequest).ShouldBe((byte)0x70);
+ ((byte)FocasMessageKind.ErrorResponse).ShouldBe((byte)0xFE);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj
new file mode 100644
index 0000000..88c368e
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+