From 389255563138ade6f429bcc5fd8518eb0ec7270d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 14:10:52 -0400 Subject: [PATCH] =?UTF-8?q?FOCAS=20Tier-C=20PR=20C=20=E2=80=94=20IPC=20pat?= =?UTF-8?q?h=20end-to-end:=20Proxy=20IpcFocasClient=20+=20Host=20FwlibFram?= =?UTF-8?q?eHandler=20+=20IFocasBackend=20abstraction.=20Third=20of=205=20?= =?UTF-8?q?PRs=20for=20#220.=20Ships=20the=20wire=20path=20from=20IFocasCl?= =?UTF-8?q?ient=20calls=20in=20the=20.NET=2010=20driver,=20over=20a=20name?= =?UTF-8?q?d-pipe=20(or=20in-memory=20stream)=20to=20the=20.NET=204.8=20Ho?= =?UTF-8?q?st's=20FwlibFrameHandler,=20dispatched=20to=20an=20IFocasBacken?= =?UTF-8?q?d.=20Keeps=20the=20existing=20IFocasClient=20DI=20seam=20intact?= =?UTF-8?q?=20so=20existing=20unit=20tests=20are=20unaffected=20(172/172?= =?UTF-8?q?=20still=20pass).=20Proxy=20side=20adds=20Ipc/FocasIpcClient=20?= =?UTF-8?q?(owns=20one=20pipe=20stream=20+=20call=20gate=20so=20concurrent?= =?UTF-8?q?=20callers=20don't=20interleave=20frames,=20supports=20both=20r?= =?UTF-8?q?eal=20NamedPipeClientStream=20and=20arbitrary=20Stream=20for=20?= =?UTF-8?q?in-memory=20test=20loopback)=20and=20Ipc/IpcFocasClient=20(impl?= =?UTF-8?q?ements=20IFocasClient=20by=20forwarding=20every=20call=20as=20a?= =?UTF-8?q?n=20IPC=20frame=20=E2=80=94=20Connect=20sends=20OpenSessionRequ?= =?UTF-8?q?est=20and=20caches=20the=20SessionId;=20Read=20sends=20ReadRequ?= =?UTF-8?q?est=20and=20decodes=20the=20typed=20value=20via=20FocasDataType?= =?UTF-8?q?Code;=20Write=20sends=20WriteRequest=20for=20non-bit=20data=20o?= =?UTF-8?q?r=20PmcBitWriteRequest=20when=20it's=20a=20PMC=20bit=20so=20the?= =?UTF-8?q?=20RMW=20critical=20section=20stays=20on=20the=20Host;=20Probe?= =?UTF-8?q?=20sends=20ProbeRequest;=20Dispose=20best-effort=20sends=20Clos?= =?UTF-8?q?eSessionRequest);=20plus=20FocasIpcException=20surfacing=20Host?= =?UTF-8?q?-side=20ErrorResponse=20frames=20as=20typed=20exceptions.=20Hos?= =?UTF-8?q?t=20side=20adds=20Backend/IFocasBackend=20(the=20Host's=20view?= =?UTF-8?q?=20of=20one=20FOCAS=20session=20=E2=80=94=20Open/Close/Read/Wri?= =?UTF-8?q?te/PmcBitWrite/Probe)=20with=20two=20implementations:=20FakeFoc?= =?UTF-8?q?asBackend=20(in-memory,=20per-address=20value=20store,=20honors?= =?UTF-8?q?=20bit-write=20RMW=20semantics=20against=20the=20containing=20b?= =?UTF-8?q?yte=20=E2=80=94=20used=20by=20tests=20and=20as=20an=20OTOPCUA?= =?UTF-8?q?=5FFOCAS=5FBACKEND=3Dfake=20operational=20stub)=20and=20Unconfi?= =?UTF-8?q?guredFocasBackend=20(structured=20failure=20pointing=20at=20doc?= =?UTF-8?q?s/v2/focas-deployment.md=20=E2=80=94=20the=20safe=20default=20w?= =?UTF-8?q?hen=20OTOPCUA=5FFOCAS=5FBACKEND=20is=20unset=20or=20hardware=20?= =?UTF-8?q?isn't=20configured).=20Ipc/FwlibFrameHandler=20replaces=20StubF?= =?UTF-8?q?rameHandler:=20deserializes=20each=20request=20DTO,=20delegates?= =?UTF-8?q?=20to=20the=20IFocasBackend,=20re-serializes=20into=20the=20mat?= =?UTF-8?q?ching=20response=20kind.=20Catches=20backend=20exceptions=20and?= =?UTF-8?q?=20surfaces=20them=20as=20ErrorResponse{backend-exception}=20ra?= =?UTF-8?q?ther=20than=20tearing=20down=20the=20pipe.=20Program.cs=20now?= =?UTF-8?q?=20picks=20the=20backend=20from=20OTOPCUA=5FFOCAS=5FBACKEND=20e?= =?UTF-8?q?nv=20var=20(fake/unconfigured/fwlib32;=20fwlib32=20still=20maps?= =?UTF-8?q?=20to=20Unconfigured=20because=20the=20real=20Fwlib32=20P/Invok?= =?UTF-8?q?e=20integration=20is=20a=20hardware-dependent=20follow-up=20?= =?UTF-8?q?=E2=80=94=20#220=20captures=20it).=20Tests:=207=20new=20IPC=20r?= =?UTF-8?q?ound-trip=20tests=20on=20the=20Proxy=20side=20(IpcFocasClient?= =?UTF-8?q?=20vs.=20an=20IpcLoopback=20fake=20server:=20connect=20happy=20?= =?UTF-8?q?path,=20connect=20rejection,=20read=20decode,=20write=20round-t?= =?UTF-8?q?rip,=20PMC=20bit=20write=20routes=20to=20first-class=20RMW=20fr?= =?UTF-8?q?ame,=20probe,=20ErrorResponse=20surfaces=20as=20typed=20excepti?= =?UTF-8?q?on)=20+=206=20new=20Host-side=20tests=20on=20FwlibFrameHandler?= =?UTF-8?q?=20(OpenSession=20allocates=20id,=20read-without-session=20fail?= =?UTF-8?q?s,=20full=20open/write/read=20round-trip=20preserves=20value,?= =?UTF-8?q?=20PmcBitWrite=20sets=20the=20specified=20bit,=20Probe=20report?= =?UTF-8?q?s=20healthy=20with=20open=20session,=20UnconfiguredBackend=20re?= =?UTF-8?q?turns=20pointed-at-docs=20error=20with=20ErrorCode=3DNoFwlibBac?= =?UTF-8?q?kend).=20Existing=20165=20FOCAS=20unit=20tests=20+=2024=20Share?= =?UTF-8?q?d=20tests=20+=203=20Host=20handshake=20tests=20all=20unchanged.?= =?UTF-8?q?=20Total=20post-PR:=20172+24+9=20=3D=20205=20FOCAS-family=20tes?= =?UTF-8?q?ts=20green.=20What's=20NOT=20in=20this=20PR:=20the=20actual=20F?= =?UTF-8?q?wlib32.dll=20P/Invoke=20integration=20inside=20the=20Host=20(Fw?= =?UTF-8?q?libHostedBackend)=20lands=20as=20a=20hardware-dependent=20follo?= =?UTF-8?q?w-up=20since=20no=20CNC=20is=20available=20for=20validation;=20?= =?UTF-8?q?supervisor=20+=20respawn=20+=20crash-loop=20breaker=20comes=20i?= =?UTF-8?q?n=20PR=20D;=20MMF=20+=20NSSM=20install=20scripts=20in=20PR=20E.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backend/FakeFocasBackend.cs | 122 ++++++++ .../Backend/IFocasBackend.cs | 24 ++ .../Backend/UnconfiguredFocasBackend.cs | 37 +++ .../Ipc/FwlibFrameHandler.cs | 111 ++++++++ .../Program.cs | 14 +- .../Ipc/FocasIpcClient.cs | 120 ++++++++ .../Ipc/IpcFocasClient.cs | 199 +++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj | 1 + .../FwlibFrameHandlerTests.cs | 200 +++++++++++++ .../IpcFocasClientTests.cs | 265 ++++++++++++++++++ .../IpcLoopback.cs | 72 +++++ 11 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/FwlibFrameHandlerTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/IpcFocasClientTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/IpcLoopback.cs 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 @@ +