diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs
new file mode 100644
index 0000000..3f4bedf
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs
@@ -0,0 +1,122 @@
+using System.Collections.Generic;
+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.Host.Backend;
+
+///
+/// In-memory for tests + an operational stub mode when
+/// OTOPCUA_FOCAS_BACKEND=fake. Keeps per-address values keyed by a canonical
+/// string; RMW semantics honor PMC bit-writes against the containing byte so the
+/// PmcBitWriteRequest path can be exercised end-to-end without hardware.
+///
+public sealed class FakeFocasBackend : IFocasBackend
+{
+ private readonly object _gate = new();
+ private long _nextSessionId;
+ private readonly HashSet _openSessions = [];
+ private readonly Dictionary _pmcValues = [];
+ private readonly Dictionary _paramValues = [];
+ private readonly Dictionary _macroValues = [];
+
+ public Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ var id = ++_nextSessionId;
+ _openSessions.Add(id);
+ return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
+ }
+ }
+
+ public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct)
+ {
+ lock (_gate) { _openSessions.Remove(request.SessionId); }
+ return Task.CompletedTask;
+ }
+
+ public Task ReadAsync(ReadRequest request, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ if (!_openSessions.Contains(request.SessionId))
+ return Task.FromResult(new ReadResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
+
+ var store = StoreFor(request.Address.Kind);
+ var key = CanonicalKey(request.Address);
+ store.TryGetValue(key, out var value);
+ return Task.FromResult(new ReadResponse
+ {
+ Success = true,
+ StatusCode = 0,
+ ValueBytes = value ?? MessagePackSerializer.Serialize((int)0),
+ ValueTypeCode = request.DataType,
+ SourceTimestampUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ });
+ }
+ }
+
+ public Task WriteAsync(WriteRequest request, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ if (!_openSessions.Contains(request.SessionId))
+ return Task.FromResult(new WriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
+
+ var store = StoreFor(request.Address.Kind);
+ store[CanonicalKey(request.Address)] = request.ValueBytes ?? [];
+ return Task.FromResult(new WriteResponse { Success = true, StatusCode = 0 });
+ }
+ }
+
+ public Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ if (!_openSessions.Contains(request.SessionId))
+ return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
+ if (request.BitIndex is < 0 or > 7)
+ return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x803C0000u, Error = "bit-out-of-range" });
+
+ var key = CanonicalKey(request.Address);
+ _pmcValues.TryGetValue(key, out var current);
+ current ??= MessagePackSerializer.Serialize((byte)0);
+ var b = MessagePackSerializer.Deserialize(current);
+ var mask = (byte)(1 << request.BitIndex);
+ b = request.Value ? (byte)(b | mask) : (byte)(b & ~mask);
+ _pmcValues[key] = MessagePackSerializer.Serialize(b);
+ return Task.FromResult(new PmcBitWriteResponse { Success = true, StatusCode = 0 });
+ }
+ }
+
+ public Task ProbeAsync(ProbeRequest request, CancellationToken ct)
+ {
+ lock (_gate)
+ {
+ return Task.FromResult(new ProbeResponse
+ {
+ Healthy = _openSessions.Contains(request.SessionId),
+ ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ });
+ }
+ }
+
+ private Dictionary StoreFor(int kind) => kind switch
+ {
+ 0 => _pmcValues,
+ 1 => _paramValues,
+ 2 => _macroValues,
+ _ => _pmcValues,
+ };
+
+ private static string CanonicalKey(FocasAddressDto addr) =>
+ addr.Kind switch
+ {
+ 0 => $"{addr.PmcLetter}{addr.Number}",
+ 1 => $"P{addr.Number}",
+ 2 => $"M{addr.Number}",
+ _ => $"?{addr.Number}",
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs
new file mode 100644
index 0000000..4176f08
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs
@@ -0,0 +1,24 @@
+using System.Threading;
+using System.Threading.Tasks;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
+
+///
+/// The Host's view of a FOCAS session. One implementation wraps the real
+/// Fwlib32.dll via P/Invoke (lands with the real Fwlib32 integration follow-up,
+/// since no hardware is available today); a second implementation —
+/// — is used by tests.
+/// Both live on .NET 4.8 x86 so the Host can be deployed in either mode without
+/// changing the pipe server.
+/// Invoked via FwlibFrameHandler in the Ipc namespace.
+///
+public interface IFocasBackend
+{
+ Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct);
+ Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct);
+ Task ReadAsync(ReadRequest request, CancellationToken ct);
+ Task WriteAsync(WriteRequest request, CancellationToken ct);
+ Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct);
+ Task ProbeAsync(ProbeRequest request, CancellationToken ct);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs
new file mode 100644
index 0000000..4889739
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs
@@ -0,0 +1,37 @@
+using System.Threading;
+using System.Threading.Tasks;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
+
+///
+/// Safe default when the deployment hasn't configured a real Fwlib32 backend.
+/// Returns structured failure responses instead of throwing so the Proxy can map the
+/// error to BadDeviceFailure and surface a clear operator message pointing at
+/// docs/v2/focas-deployment.md. Used when OTOPCUA_FOCAS_BACKEND is unset
+/// or set to unconfigured.
+///
+public sealed class UnconfiguredFocasBackend : IFocasBackend
+{
+ private const uint BadDeviceFailure = 0x80550000u;
+ private const string Reason =
+ "FOCAS Host is running without a real Fwlib32 backend. Set OTOPCUA_FOCAS_BACKEND=fwlib32 " +
+ "and ensure Fwlib32.dll is on PATH — see docs/v2/focas-deployment.md.";
+
+ public Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct) =>
+ Task.FromResult(new OpenSessionResponse { Success = false, Error = Reason, ErrorCode = "NoFwlibBackend" });
+
+ public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct) => Task.CompletedTask;
+
+ public Task ReadAsync(ReadRequest request, CancellationToken ct) =>
+ Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
+
+ public Task WriteAsync(WriteRequest request, CancellationToken ct) =>
+ Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
+
+ public Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) =>
+ Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
+
+ public Task ProbeAsync(ProbeRequest request, CancellationToken ct) =>
+ Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs
new file mode 100644
index 0000000..d6e8ecf
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MessagePack;
+using Serilog;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
+
+///
+/// Real FOCAS frame handler. Deserializes each request DTO, delegates to
+/// , re-serializes the response. The backend owns the
+/// Fwlib32 handle + STA thread — the handler is pure dispatch.
+///
+public sealed class FwlibFrameHandler : IFrameHandler
+{
+ private readonly IFocasBackend _backend;
+ private readonly ILogger _logger;
+
+ public FwlibFrameHandler(IFocasBackend backend, ILogger logger)
+ {
+ _backend = backend ?? throw new ArgumentNullException(nameof(backend));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
+ {
+ try
+ {
+ switch (kind)
+ {
+ case FocasMessageKind.Heartbeat:
+ {
+ var hb = MessagePackSerializer.Deserialize(body);
+ await writer.WriteAsync(FocasMessageKind.HeartbeatAck,
+ new HeartbeatAck
+ {
+ MonotonicTicks = hb.MonotonicTicks,
+ HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ }, ct).ConfigureAwait(false);
+ return;
+ }
+
+ case FocasMessageKind.OpenSessionRequest:
+ {
+ var req = MessagePackSerializer.Deserialize(body);
+ var resp = await _backend.OpenSessionAsync(req, ct).ConfigureAwait(false);
+ await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, resp, ct).ConfigureAwait(false);
+ return;
+ }
+
+ case FocasMessageKind.CloseSessionRequest:
+ {
+ var req = MessagePackSerializer.Deserialize(body);
+ await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false);
+ return;
+ }
+
+ case FocasMessageKind.ReadRequest:
+ {
+ var req = MessagePackSerializer.Deserialize(body);
+ var resp = await _backend.ReadAsync(req, ct).ConfigureAwait(false);
+ await writer.WriteAsync(FocasMessageKind.ReadResponse, resp, ct).ConfigureAwait(false);
+ return;
+ }
+
+ case FocasMessageKind.WriteRequest:
+ {
+ var req = MessagePackSerializer.Deserialize(body);
+ var resp = await _backend.WriteAsync(req, ct).ConfigureAwait(false);
+ await writer.WriteAsync(FocasMessageKind.WriteResponse, resp, ct).ConfigureAwait(false);
+ return;
+ }
+
+ case FocasMessageKind.PmcBitWriteRequest:
+ {
+ var req = MessagePackSerializer.Deserialize(body);
+ var resp = await _backend.PmcBitWriteAsync(req, ct).ConfigureAwait(false);
+ await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse, resp, ct).ConfigureAwait(false);
+ return;
+ }
+
+ case FocasMessageKind.ProbeRequest:
+ {
+ var req = MessagePackSerializer.Deserialize(body);
+ var resp = await _backend.ProbeAsync(req, ct).ConfigureAwait(false);
+ await writer.WriteAsync(FocasMessageKind.ProbeResponse, resp, ct).ConfigureAwait(false);
+ return;
+ }
+
+ default:
+ await writer.WriteAsync(FocasMessageKind.ErrorResponse,
+ new ErrorResponse { Code = "unknown-kind", Message = $"Kind {kind} is not handled by the Host" },
+ ct).ConfigureAwait(false);
+ return;
+ }
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "FwlibFrameHandler error processing {Kind}", kind);
+ await writer.WriteAsync(FocasMessageKind.ErrorResponse,
+ new ErrorResponse { Code = "backend-exception", Message = ex.Message },
+ ct).ConfigureAwait(false);
+ }
+ }
+
+ public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
index 6450747..05ab13e 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
@@ -2,6 +2,7 @@ using System;
using System.Security.Principal;
using System.Threading;
using Serilog;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host;
@@ -44,9 +45,18 @@ public static class Program
Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}",
pipeName, allowedSidValue);
- var handler = new StubFrameHandler();
- Log.Warning("OtOpcUaFocasHost backend=stub — Fwlib32 lift lands in PR C");
+ var backendKind = (Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_BACKEND") ?? "unconfigured")
+ .ToLowerInvariant();
+ IFocasBackend backend = backendKind switch
+ {
+ "fake" => new FakeFocasBackend(),
+ "unconfigured" => new UnconfiguredFocasBackend(),
+ "fwlib32" => new UnconfiguredFocasBackend(), // real Fwlib32 backend lands with hardware integration follow-up
+ _ => new UnconfiguredFocasBackend(),
+ };
+ Log.Information("OtOpcUaFocasHost backend={Backend}", backendKind);
+ var handler = new FwlibFrameHandler(backend, Log.Logger);
server.RunAsync(handler, cts.Token).GetAwaiter().GetResult();
Log.Information("OtOpcUaFocasHost stopped cleanly");
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs
new file mode 100644
index 0000000..51ede9b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs
@@ -0,0 +1,120 @@
+using System.IO;
+using System.IO.Pipes;
+using MessagePack;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
+
+///
+/// Proxy-side IPC channel to a running Driver.FOCAS.Host. Owns the pipe connection
+/// and serializes request/response round-trips through a single call gate so
+/// concurrent callers don't interleave frames. One instance per FOCAS Host session.
+///
+public sealed class FocasIpcClient : IAsyncDisposable
+{
+ private readonly Stream _stream;
+ private readonly FrameReader _reader;
+ private readonly FrameWriter _writer;
+ private readonly SemaphoreSlim _callGate = new(1, 1);
+
+ private FocasIpcClient(Stream stream)
+ {
+ _stream = stream;
+ _reader = new FrameReader(stream, leaveOpen: true);
+ _writer = new FrameWriter(stream, leaveOpen: true);
+ }
+
+ /// Named-pipe factory: connects, sends Hello, awaits HelloAck.
+ public static async Task ConnectAsync(
+ string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
+ {
+ var stream = new NamedPipeClientStream(
+ serverName: ".",
+ pipeName: pipeName,
+ direction: PipeDirection.InOut,
+ options: PipeOptions.Asynchronous);
+
+ await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
+ return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Stream factory — used by tests that wire the Proxy against an in-memory stream
+ /// pair instead of a real pipe. is owned by the caller
+ /// until .
+ ///
+ public static Task ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
+ => HandshakeAsync(stream, sharedSecret, ct);
+
+ private static async Task HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct)
+ {
+ var client = new FocasIpcClient(stream);
+ try
+ {
+ await client._writer.WriteAsync(FocasMessageKind.Hello,
+ new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false);
+
+ var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false);
+ if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck)
+ throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host");
+
+ var ackMsg = FrameReader.Deserialize(ack.Value.Body);
+ if (!ackMsg.Accepted)
+ throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}");
+
+ return client;
+ }
+ catch
+ {
+ await client.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ public async Task CallAsync(
+ FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct)
+ {
+ await _callGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
+
+ var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
+ if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response");
+
+ if (frame.Value.Kind == FocasMessageKind.ErrorResponse)
+ {
+ var err = MessagePackSerializer.Deserialize(frame.Value.Body);
+ throw new FocasIpcException(err.Code, err.Message);
+ }
+
+ if (frame.Value.Kind != expectedResponseKind)
+ throw new InvalidOperationException(
+ $"Expected {expectedResponseKind}, got {frame.Value.Kind}");
+
+ return MessagePackSerializer.Deserialize(frame.Value.Body);
+ }
+ finally { _callGate.Release(); }
+ }
+
+ public async Task SendOneWayAsync(FocasMessageKind requestKind, TReq request, CancellationToken ct)
+ {
+ await _callGate.WaitAsync(ct).ConfigureAwait(false);
+ try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); }
+ finally { _callGate.Release(); }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _callGate.Dispose();
+ _reader.Dispose();
+ _writer.Dispose();
+ await _stream.DisposeAsync().ConfigureAwait(false);
+ }
+}
+
+public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}")
+{
+ public string Code { get; } = code;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs
new file mode 100644
index 0000000..01c227c
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs
@@ -0,0 +1,199 @@
+using MessagePack;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
+
+///
+/// implementation that forwards every operation over a
+/// to a Driver.FOCAS.Host process. Keeps the
+/// Fwlib32.dll P/Invoke out of the main server process so a native crash
+/// blast-radius stops at the Host boundary.
+///
+///
+/// Session lifecycle: sends OpenSessionRequest and
+/// caches the returned SessionId. Subsequent /
+/// / calls thread that session id
+/// onto each request DTO. sends CloseSessionRequest +
+/// disposes the underlying pipe.
+///
+public sealed class IpcFocasClient : IFocasClient
+{
+ private readonly FocasIpcClient _ipc;
+ private readonly FocasCncSeries _series;
+ private long _sessionId;
+ private bool _connected;
+
+ public IpcFocasClient(FocasIpcClient ipc, FocasCncSeries series = FocasCncSeries.Unknown)
+ {
+ _ipc = ipc ?? throw new ArgumentNullException(nameof(ipc));
+ _series = series;
+ }
+
+ public bool IsConnected => _connected;
+
+ public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ if (_connected) return;
+
+ var resp = await _ipc.CallAsync(
+ FocasMessageKind.OpenSessionRequest,
+ new OpenSessionRequest
+ {
+ HostAddress = $"{address.Host}:{address.Port}",
+ TimeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds),
+ CncSeries = (int)_series,
+ },
+ FocasMessageKind.OpenSessionResponse,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!resp.Success)
+ throw new InvalidOperationException(
+ $"FOCAS Host rejected OpenSession for {address}: {resp.ErrorCode ?? "?"} — {resp.Error}");
+
+ _sessionId = resp.SessionId;
+ _connected = true;
+ }
+
+ public async Task<(object? value, uint status)> ReadAsync(
+ FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
+ {
+ if (!_connected) return (null, FocasStatusMapper.BadCommunicationError);
+
+ var resp = await _ipc.CallAsync(
+ FocasMessageKind.ReadRequest,
+ new ReadRequest
+ {
+ SessionId = _sessionId,
+ Address = ToDto(address),
+ DataType = (int)type,
+ },
+ FocasMessageKind.ReadResponse,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!resp.Success) return (null, resp.StatusCode);
+
+ var value = DecodeValue(resp.ValueBytes, resp.ValueTypeCode);
+ return (value, resp.StatusCode);
+ }
+
+ public async Task WriteAsync(
+ FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
+ {
+ if (!_connected) return FocasStatusMapper.BadCommunicationError;
+
+ // PMC bit writes get the first-class RMW frame so the critical section stays on the Host.
+ if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
+ {
+ var bitResp = await _ipc.CallAsync(
+ FocasMessageKind.PmcBitWriteRequest,
+ new PmcBitWriteRequest
+ {
+ SessionId = _sessionId,
+ Address = ToDto(address),
+ BitIndex = bit,
+ Value = Convert.ToBoolean(value),
+ },
+ FocasMessageKind.PmcBitWriteResponse,
+ cancellationToken).ConfigureAwait(false);
+ return bitResp.StatusCode;
+ }
+
+ var resp = await _ipc.CallAsync(
+ FocasMessageKind.WriteRequest,
+ new WriteRequest
+ {
+ SessionId = _sessionId,
+ Address = ToDto(address),
+ DataType = (int)type,
+ ValueTypeCode = (int)type,
+ ValueBytes = EncodeValue(value, type),
+ },
+ FocasMessageKind.WriteResponse,
+ cancellationToken).ConfigureAwait(false);
+
+ return resp.StatusCode;
+ }
+
+ public async Task ProbeAsync(CancellationToken cancellationToken)
+ {
+ if (!_connected) return false;
+ try
+ {
+ var resp = await _ipc.CallAsync(
+ FocasMessageKind.ProbeRequest,
+ new ProbeRequest { SessionId = _sessionId },
+ FocasMessageKind.ProbeResponse,
+ cancellationToken).ConfigureAwait(false);
+ return resp.Healthy;
+ }
+ catch { return false; }
+ }
+
+ public void Dispose()
+ {
+ if (_connected)
+ {
+ try
+ {
+ _ipc.SendOneWayAsync(FocasMessageKind.CloseSessionRequest,
+ new CloseSessionRequest { SessionId = _sessionId }, CancellationToken.None)
+ .GetAwaiter().GetResult();
+ }
+ catch { /* best effort */ }
+ _connected = false;
+ }
+ _ipc.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+
+ private static FocasAddressDto ToDto(FocasAddress addr) => new()
+ {
+ Kind = (int)addr.Kind,
+ PmcLetter = addr.PmcLetter,
+ Number = addr.Number,
+ BitIndex = addr.BitIndex,
+ };
+
+ private static byte[]? EncodeValue(object? value, FocasDataType type)
+ {
+ if (value is null) return null;
+ return type switch
+ {
+ FocasDataType.Bit => MessagePackSerializer.Serialize(Convert.ToBoolean(value)),
+ FocasDataType.Byte => MessagePackSerializer.Serialize(Convert.ToByte(value)),
+ FocasDataType.Int16 => MessagePackSerializer.Serialize(Convert.ToInt16(value)),
+ FocasDataType.Int32 => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
+ FocasDataType.Float32 => MessagePackSerializer.Serialize(Convert.ToSingle(value)),
+ FocasDataType.Float64 => MessagePackSerializer.Serialize(Convert.ToDouble(value)),
+ FocasDataType.String => MessagePackSerializer.Serialize(Convert.ToString(value) ?? string.Empty),
+ _ => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
+ };
+ }
+
+ private static object? DecodeValue(byte[]? bytes, int typeCode)
+ {
+ if (bytes is null) return null;
+ return typeCode switch
+ {
+ FocasDataTypeCode.Bit => MessagePackSerializer.Deserialize(bytes),
+ FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize(bytes),
+ FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize(bytes),
+ FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize(bytes),
+ FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize(bytes),
+ FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize(bytes),
+ FocasDataTypeCode.String => MessagePackSerializer.Deserialize(bytes),
+ _ => MessagePackSerializer.Deserialize(bytes),
+ };
+ }
+}
+
+///
+/// Factory producing s. One pipe connection per
+/// IFocasClient — matches the driver's one-client-per-device invariant. The
+/// deployment wires this into the DI container in place of
+/// .
+///
+public sealed class IpcFocasClientFactory(Func ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown)
+ : IFocasClientFactory
+{
+ public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
index 15b82fe..a17c4d7 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
@@ -14,6 +14,7 @@
+