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:
@@ -0,0 +1,200 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||
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.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that <see cref="FwlibFrameHandler"/> correctly dispatches each
|
||||
/// <see cref="FocasMessageKind"/> to the corresponding <see cref="IFocasBackend"/>
|
||||
/// method and serializes the response into the expected response kind. Uses
|
||||
/// <see cref="FakeFocasBackend"/> so no hardware is needed.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FwlibFrameHandlerTests
|
||||
{
|
||||
private static async Task RoundTripAsync<TReq, TResp>(
|
||||
IFrameHandler handler, FocasMessageKind reqKind, TReq req, FocasMessageKind expectedRespKind,
|
||||
Action<TResp> assertResponse)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||
await handler.HandleAsync(reqKind, MessagePackSerializer.Serialize(req), writer, CancellationToken.None);
|
||||
|
||||
buffer.Position = 0;
|
||||
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||
var frame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
frame.HasValue.ShouldBeTrue();
|
||||
frame!.Value.Kind.ShouldBe(expectedRespKind);
|
||||
assertResponse(MessagePackSerializer.Deserialize<TResp>(frame.Value.Body));
|
||||
}
|
||||
|
||||
private static FwlibFrameHandler BuildHandler() =>
|
||||
new(new FakeFocasBackend(), new LoggerConfiguration().CreateLogger());
|
||||
|
||||
[Fact]
|
||||
public async Task OpenSession_returns_a_new_session_id()
|
||||
{
|
||||
long sessionId = 0;
|
||||
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
BuildHandler(),
|
||||
FocasMessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { HostAddress = "h:8193" },
|
||||
FocasMessageKind.OpenSessionResponse,
|
||||
resp => { resp.Success.ShouldBeTrue(); resp.SessionId.ShouldBeGreaterThan(0L); sessionId = resp.SessionId; });
|
||||
sessionId.ShouldBeGreaterThan(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_without_open_session_returns_internal_error()
|
||||
{
|
||||
await RoundTripAsync<ReadRequest, ReadResponse>(
|
||||
BuildHandler(),
|
||||
FocasMessageKind.ReadRequest,
|
||||
new ReadRequest
|
||||
{
|
||||
SessionId = 999,
|
||||
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
|
||||
DataType = FocasDataTypeCode.Int32,
|
||||
},
|
||||
FocasMessageKind.ReadResponse,
|
||||
resp => { resp.Success.ShouldBeFalse(); resp.Error.ShouldContain("session-not-open"); });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Full_open_write_read_round_trip_preserves_value()
|
||||
{
|
||||
var handler = BuildHandler();
|
||||
|
||||
// Open.
|
||||
using var buffer = new MemoryStream();
|
||||
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
|
||||
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
|
||||
|
||||
buffer.Position = 0;
|
||||
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
var openResp = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body);
|
||||
var sessionId = openResp.SessionId;
|
||||
|
||||
// Write 42 at MACRO:500 as Int32.
|
||||
buffer.Position = 0;
|
||||
buffer.SetLength(0);
|
||||
await handler.HandleAsync(FocasMessageKind.WriteRequest,
|
||||
MessagePackSerializer.Serialize(new WriteRequest
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Address = new FocasAddressDto { Kind = 2, Number = 500 },
|
||||
DataType = FocasDataTypeCode.Int32,
|
||||
ValueTypeCode = FocasDataTypeCode.Int32,
|
||||
ValueBytes = MessagePackSerializer.Serialize((int)42),
|
||||
}), writer, CancellationToken.None);
|
||||
|
||||
// Read back.
|
||||
buffer.Position = 0;
|
||||
buffer.SetLength(0);
|
||||
await handler.HandleAsync(FocasMessageKind.ReadRequest,
|
||||
MessagePackSerializer.Serialize(new ReadRequest
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Address = new FocasAddressDto { Kind = 2, Number = 500 },
|
||||
DataType = FocasDataTypeCode.Int32,
|
||||
}), writer, CancellationToken.None);
|
||||
|
||||
buffer.Position = 0;
|
||||
var readFrame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
readFrame.HasValue.ShouldBeTrue();
|
||||
readFrame!.Value.Kind.ShouldBe(FocasMessageKind.ReadResponse);
|
||||
// With buffer reuse there may be multiple queued frames; we want the last one.
|
||||
var lastResp = MessagePackSerializer.Deserialize<ReadResponse>(readFrame.Value.Body);
|
||||
// If the Write frame is first, drain it.
|
||||
if (lastResp.ValueBytes is null)
|
||||
{
|
||||
var next = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
lastResp = MessagePackSerializer.Deserialize<ReadResponse>(next!.Value.Body);
|
||||
}
|
||||
lastResp.Success.ShouldBeTrue();
|
||||
MessagePackSerializer.Deserialize<int>(lastResp.ValueBytes!).ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PmcBitWrite_sets_specified_bit()
|
||||
{
|
||||
var handler = BuildHandler();
|
||||
using var buffer = new MemoryStream();
|
||||
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||
|
||||
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
|
||||
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
|
||||
buffer.Position = 0;
|
||||
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body).SessionId;
|
||||
|
||||
buffer.Position = 0; buffer.SetLength(0);
|
||||
await handler.HandleAsync(FocasMessageKind.PmcBitWriteRequest,
|
||||
MessagePackSerializer.Serialize(new PmcBitWriteRequest
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
|
||||
BitIndex = 3,
|
||||
Value = true,
|
||||
}), writer, CancellationToken.None);
|
||||
|
||||
buffer.Position = 0;
|
||||
var resp = MessagePackSerializer.Deserialize<PmcBitWriteResponse>(
|
||||
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_reports_healthy_when_session_open()
|
||||
{
|
||||
var handler = BuildHandler();
|
||||
using var buffer = new MemoryStream();
|
||||
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
|
||||
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
|
||||
buffer.Position = 0;
|
||||
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(
|
||||
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body).SessionId;
|
||||
|
||||
buffer.Position = 0; buffer.SetLength(0);
|
||||
await handler.HandleAsync(FocasMessageKind.ProbeRequest,
|
||||
MessagePackSerializer.Serialize(new ProbeRequest { SessionId = sessionId }), writer, CancellationToken.None);
|
||||
buffer.Position = 0;
|
||||
var resp = MessagePackSerializer.Deserialize<ProbeResponse>(
|
||||
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
|
||||
resp.Healthy.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unconfigured_backend_returns_pointed_error_message()
|
||||
{
|
||||
var handler = new FwlibFrameHandler(new UnconfiguredFocasBackend(), new LoggerConfiguration().CreateLogger());
|
||||
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
handler,
|
||||
FocasMessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { HostAddress = "h:8193" },
|
||||
FocasMessageKind.OpenSessionResponse,
|
||||
resp =>
|
||||
{
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Fwlib32");
|
||||
resp.ErrorCode.ShouldBe("NoFwlibBackend");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user