FOCAS Tier-C PR C — IPC path end-to-end: Proxy IpcFocasClient + Host FwlibFrameHandler + IFocasBackend abstraction. Third of 5 PRs for #220. Ships the wire path from IFocasClient calls in the .NET 10 driver, over a named-pipe (or in-memory stream) to the .NET 4.8 Host's FwlibFrameHandler, dispatched to an IFocasBackend. Keeps the existing IFocasClient DI seam intact so existing unit tests are unaffected (172/172 still pass). Proxy side adds Ipc/FocasIpcClient (owns one pipe stream + call gate so concurrent callers don't interleave frames, supports both real NamedPipeClientStream and arbitrary Stream for in-memory test loopback) and Ipc/IpcFocasClient (implements IFocasClient by forwarding every call as an IPC frame — Connect sends OpenSessionRequest and caches the SessionId; Read sends ReadRequest and decodes the typed value via FocasDataTypeCode; Write sends WriteRequest for non-bit data or PmcBitWriteRequest when it's a PMC bit so the RMW critical section stays on the Host; Probe sends ProbeRequest; Dispose best-effort sends CloseSessionRequest); plus FocasIpcException surfacing Host-side ErrorResponse frames as typed exceptions. Host side adds Backend/IFocasBackend (the Host's view of one FOCAS session — Open/Close/Read/Write/PmcBitWrite/Probe) with two implementations: FakeFocasBackend (in-memory, per-address value store, honors bit-write RMW semantics against the containing byte — used by tests and as an OTOPCUA_FOCAS_BACKEND=fake operational stub) and UnconfiguredFocasBackend (structured failure pointing at docs/v2/focas-deployment.md — the safe default when OTOPCUA_FOCAS_BACKEND is unset or hardware isn't configured). Ipc/FwlibFrameHandler replaces StubFrameHandler: deserializes each request DTO, delegates to the IFocasBackend, re-serializes into the matching response kind. Catches backend exceptions and surfaces them as ErrorResponse{backend-exception} rather than tearing down the pipe. Program.cs now picks the backend from OTOPCUA_FOCAS_BACKEND env var (fake/unconfigured/fwlib32; fwlib32 still maps to Unconfigured because the real Fwlib32 P/Invoke integration is a hardware-dependent follow-up — #220 captures it). Tests: 7 new IPC round-trip tests on the Proxy side (IpcFocasClient vs. an IpcLoopback fake server: connect happy path, connect rejection, read decode, write round-trip, PMC bit write routes to first-class RMW frame, probe, ErrorResponse surfaces as typed exception) + 6 new Host-side tests on FwlibFrameHandler (OpenSession allocates id, read-without-session fails, full open/write/read round-trip preserves value, PmcBitWrite sets the specified bit, Probe reports healthy with open session, UnconfiguredBackend returns pointed-at-docs error with ErrorCode=NoFwlibBackend). Existing 165 FOCAS unit tests + 24 Shared tests + 3 Host handshake tests all unchanged. Total post-PR: 172+24+9 = 205 FOCAS-family tests green. What's NOT in this PR: the actual Fwlib32.dll P/Invoke integration inside the Host (FwlibHostedBackend) lands as a hardware-dependent follow-up since no CNC is available for validation; supervisor + respawn + crash-loop breaker comes in PR D; MMF + NSSM install scripts in PR E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-20 14:10:52 -04:00
parent 3609a5c676
commit 3892555631
11 changed files with 1163 additions and 2 deletions

View File

@@ -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;
/// <summary>
/// In-memory <see cref="IFocasBackend"/> for tests + an operational stub mode when
/// <c>OTOPCUA_FOCAS_BACKEND=fake</c>. Keeps per-address values keyed by a canonical
/// string; RMW semantics honor PMC bit-writes against the containing byte so the
/// <c>PmcBitWriteRequest</c> path can be exercised end-to-end without hardware.
/// </summary>
public sealed class FakeFocasBackend : IFocasBackend
{
private readonly object _gate = new();
private long _nextSessionId;
private readonly HashSet<long> _openSessions = [];
private readonly Dictionary<string, byte[]> _pmcValues = [];
private readonly Dictionary<string, byte[]> _paramValues = [];
private readonly Dictionary<string, byte[]> _macroValues = [];
public Task<OpenSessionResponse> 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<ReadResponse> 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<WriteResponse> 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<PmcBitWriteResponse> 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<byte>(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<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct)
{
lock (_gate)
{
return Task.FromResult(new ProbeResponse
{
Healthy = _openSessions.Contains(request.SessionId),
ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
}
private Dictionary<string, byte[]> 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}",
};
}

View File

@@ -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;
/// <summary>
/// The Host's view of a FOCAS session. One implementation wraps the real
/// <c>Fwlib32.dll</c> via P/Invoke (lands with the real Fwlib32 integration follow-up,
/// since no hardware is available today); a second implementation —
/// <see cref="FakeFocasBackend"/> — 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 <c>FwlibFrameHandler</c> in the Ipc namespace.
/// </summary>
public interface IFocasBackend
{
Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct);
Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct);
Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct);
Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct);
Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct);
Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct);
}

View File

@@ -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;
/// <summary>
/// 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 <c>BadDeviceFailure</c> and surface a clear operator message pointing at
/// <c>docs/v2/focas-deployment.md</c>. Used when <c>OTOPCUA_FOCAS_BACKEND</c> is unset
/// or set to <c>unconfigured</c>.
/// </summary>
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<OpenSessionResponse> 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<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct) =>
Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct) =>
Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) =>
Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct) =>
Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
}

View File

@@ -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;
/// <summary>
/// Real FOCAS frame handler. Deserializes each request DTO, delegates to
/// <see cref="IFocasBackend"/>, re-serializes the response. The backend owns the
/// Fwlib32 handle + STA thread — the handler is pure dispatch.
/// </summary>
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<Heartbeat>(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<OpenSessionRequest>(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<CloseSessionRequest>(body);
await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.ReadRequest:
{
var req = MessagePackSerializer.Deserialize<ReadRequest>(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<WriteRequest>(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<PmcBitWriteRequest>(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<ProbeRequest>(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;
}

View File

@@ -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");