FOCAS — retire Tier-C split, inline managed wire client, make read-only
Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.
Architecture
- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
(owner-imported — see Wire/FocasWireClient.cs for the full surface).
Opens two TCP sockets, runs the initiate handshake, serialises requests
on socket 2 through a semaphore, closes cleanly with PDU + socket
teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
`IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
rejected at startup with a diagnostic pointing at the migration doc.
Deletions
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
`IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
Host-process supervisor (backoff, circuit breaker, heartbeat, post-
mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
DLL that masqueraded as Fwlib64.dll.
Solution changes
- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
`Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
`WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.
Integration tests
- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
tree reads (identity, axes, dynamic, program, operation mode, timers,
spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
`IAlarmSource` raise/clear transitions, and `ProbeAsync` /
`OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
(Docker container with the vendored Python mock's native FOCAS/2
Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
compose down. Dropped the shim-build stage + DLL-copy step + the split
testhost workaround (the latter only existed because of native-DLL
lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
service. Tests seed per-series state via `mock_load_profile` at test
start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
pre-refresh snapshot only spoke the JSON admin protocol.
Tests
- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
to assert the new diagnostic message pointing at
`docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.
Docs
- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
deployment collapses to one `"Backend": "wire"` config block, no
separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
driver complement closed. FOCAS change-log entry added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,200 +0,0 @@
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
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>
|
||||
/// Direct FOCAS Host IPC handshake test. Drives <see cref="PipeServer"/> through a
|
||||
/// hand-rolled pipe client built on <see cref="FrameReader"/> / <see cref="FrameWriter"/>
|
||||
/// from FOCAS.Shared. Skipped on Administrator shells because <c>PipeAcl</c> denies
|
||||
/// the BuiltinAdministrators group.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class IpcHandshakeIntegrationTests
|
||||
{
|
||||
private static bool IsAdministrator()
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)>
|
||||
ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct)
|
||||
{
|
||||
var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
await stream.ConnectAsync(5_000, ct);
|
||||
|
||||
var reader = new FrameReader(stream, leaveOpen: true);
|
||||
var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
await writer.WriteAsync(FocasMessageKind.Hello,
|
||||
new Hello { PeerName = "test-client", SharedSecret = secret }, ct);
|
||||
|
||||
var ack = await reader.ReadFrameAsync(ct);
|
||||
if (ack is null) throw new EndOfStreamException("no HelloAck");
|
||||
if (ack.Value.Kind != FocasMessageKind.HelloAck)
|
||||
throw new InvalidOperationException("unexpected first frame kind " + ack.Value.Kind);
|
||||
var ackMsg = MessagePackSerializer.Deserialize<HelloAck>(ack.Value.Body);
|
||||
if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason);
|
||||
|
||||
return (stream, reader, writer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
|
||||
{
|
||||
if (IsAdministrator()) return;
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
|
||||
const string secret = "test-secret-2026";
|
||||
Logger log = new LoggerConfiguration().CreateLogger();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
var server = new PipeServer(pipe, sid, secret, log);
|
||||
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
|
||||
|
||||
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
|
||||
using (stream)
|
||||
using (reader)
|
||||
using (writer)
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.Heartbeat,
|
||||
new Heartbeat { MonotonicTicks = 42 }, cts.Token);
|
||||
|
||||
var hbAck = await reader.ReadFrameAsync(cts.Token);
|
||||
hbAck.HasValue.ShouldBeTrue();
|
||||
hbAck!.Value.Kind.ShouldBe(FocasMessageKind.HeartbeatAck);
|
||||
MessagePackSerializer.Deserialize<HeartbeatAck>(hbAck.Value.Body).MonotonicTicks.ShouldBe(42L);
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
try { await serverTask; } catch { }
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_with_wrong_secret_is_rejected()
|
||||
{
|
||||
if (IsAdministrator()) return;
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
|
||||
Logger log = new LoggerConfiguration().CreateLogger();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
var server = new PipeServer(pipe, sid, "real-secret", log);
|
||||
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
|
||||
|
||||
await Should.ThrowAsync<UnauthorizedAccessException>(async () =>
|
||||
{
|
||||
var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token);
|
||||
s.Dispose();
|
||||
r.Dispose();
|
||||
w.Dispose();
|
||||
});
|
||||
|
||||
cts.Cancel();
|
||||
try { await serverTask; } catch { }
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stub_handler_returns_not_implemented_for_data_plane_request()
|
||||
{
|
||||
if (IsAdministrator()) return;
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
|
||||
const string secret = "stub-test";
|
||||
Logger log = new LoggerConfiguration().CreateLogger();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
var server = new PipeServer(pipe, sid, secret, log);
|
||||
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
|
||||
|
||||
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
|
||||
using (stream)
|
||||
using (reader)
|
||||
using (writer)
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.ReadRequest,
|
||||
new ReadRequest
|
||||
{
|
||||
SessionId = 1,
|
||||
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
|
||||
DataType = FocasDataTypeCode.Int32,
|
||||
},
|
||||
cts.Token);
|
||||
|
||||
var resp = await reader.ReadFrameAsync(cts.Token);
|
||||
resp.HasValue.ShouldBeTrue();
|
||||
resp!.Value.Kind.ShouldBe(FocasMessageKind.ErrorResponse);
|
||||
var err = MessagePackSerializer.Deserialize<ErrorResponse>(resp.Value.Body);
|
||||
err.Code.ShouldBe("not-implemented");
|
||||
err.Message.ShouldContain("PR C");
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
try { await serverTask; } catch { }
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PostMortemMmfTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
|
||||
public PostMortemMmfTests()
|
||||
{
|
||||
_tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-{Guid.NewGuid():N}.bin");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_tempPath)) File.Delete(_tempPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_and_read_preserve_order_and_content()
|
||||
{
|
||||
using (var mmf = new PostMortemMmf(_tempPath, capacity: 10))
|
||||
{
|
||||
mmf.Write(opKind: 1, "read R100");
|
||||
mmf.Write(opKind: 2, "write MACRO:500 = 3.14");
|
||||
mmf.Write(opKind: 3, "probe ok");
|
||||
}
|
||||
|
||||
// Reopen (simulating a reader after the writer crashed).
|
||||
using var reader = new PostMortemMmf(_tempPath, capacity: 10);
|
||||
var entries = reader.ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
entries[0].OpKind.ShouldBe(1L);
|
||||
entries[0].Message.ShouldBe("read R100");
|
||||
entries[1].OpKind.ShouldBe(2L);
|
||||
entries[2].Message.ShouldBe("probe ok");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ring_buffer_wraps_at_capacity()
|
||||
{
|
||||
using var mmf = new PostMortemMmf(_tempPath, capacity: 3);
|
||||
for (var i = 0; i < 10; i++) mmf.Write(i, $"op-{i}");
|
||||
|
||||
var entries = mmf.ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
// Oldest surviving entry is op-7 (entries 7,8,9 survive in FIFO order).
|
||||
entries[0].Message.ShouldBe("op-7");
|
||||
entries[1].Message.ShouldBe("op-8");
|
||||
entries[2].Message.ShouldBe("op-9");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_message_is_null_terminated_and_does_not_overflow()
|
||||
{
|
||||
using var mmf = new PostMortemMmf(_tempPath, capacity: 4);
|
||||
var big = new string('x', 500); // longer than the 240-byte message capacity
|
||||
mmf.Write(42, big);
|
||||
|
||||
var entries = mmf.ReadAll();
|
||||
entries.Length.ShouldBe(1);
|
||||
entries[0].Message.Length.ShouldBeLessThanOrEqualTo(240);
|
||||
entries[0].OpKind.ShouldBe(42L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reopening_with_existing_data_preserves_entries()
|
||||
{
|
||||
using (var first = new PostMortemMmf(_tempPath, capacity: 5))
|
||||
{
|
||||
first.Write(1, "first-run-1");
|
||||
first.Write(2, "first-run-2");
|
||||
}
|
||||
|
||||
using var second = new PostMortemMmf(_tempPath, capacity: 5);
|
||||
var entries = second.ReadAll();
|
||||
entries.Length.ShouldBe(2);
|
||||
entries[0].Message.ShouldBe("first-run-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,94 @@
|
||||
# FOCAS Docker simulator — focas-mock + shim DLL
|
||||
|
||||
Hardware-free FOCAS fixture for OtOpcUa's integration test matrix. Runs
|
||||
the vendored [`focas-mock`](focas-mock/VENDORED.md) Python server under
|
||||
Docker and pairs it with the [shim DLL](../Shim/VENDORED.md) that
|
||||
masquerades as `Fwlib64.dll` inside the .NET test process.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────┐ cnc_allclibhndl3 / cnc_rdparam / ...
|
||||
│ xunit test process │ (P/Invoke, __stdcall)
|
||||
│ ├── Driver.FOCAS │
|
||||
│ │ └── FwlibNative.cs ─┼─┐
|
||||
│ └── FocasSimFixture │ │ resolves to...
|
||||
└────────────────────────────┘ │
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ Fwlib64.dll (shim) │ JSON over TCP
|
||||
│ tests/.../Shim/focas_ │──────────────────────┐
|
||||
│ shim.c compiled here │ │
|
||||
└────────────────────────────┘ │
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ focas-mock (Docker) │
|
||||
│ python:3.11-slim │
|
||||
│ profile-aware responses │
|
||||
│ mock_load_profile / │
|
||||
│ mock_patch admin methods │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
The shim bridges the binary ABI (C `__stdcall` exports with FOCAS struct
|
||||
shapes) to the mock's newline-delimited JSON protocol. OtOpcUa's
|
||||
`FocasSimFixture` seeds per-test state by sending `mock_load_profile` +
|
||||
`mock_patch` admin calls on the same socket. Tests assert the managed
|
||||
driver sees the seeded values through its normal P/Invoke path.
|
||||
|
||||
## Running
|
||||
|
||||
Pick one compose profile (they all publish 8193 — only one at a time):
|
||||
|
||||
```powershell
|
||||
docker compose -f Docker/docker-compose.yml --profile thirtyone up -d
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests
|
||||
docker compose -f Docker/docker-compose.yml --profile thirtyone down
|
||||
```
|
||||
|
||||
Available profiles + their focas-mock target:
|
||||
|
||||
| compose --profile | focas-mock profile | Covers |
|
||||
|---|---|---|
|
||||
| `thirtyone` / `thirty` / `thirtytwo` | `fwlib30i64` | 30i / 31i / 32i series |
|
||||
| `sixteen` | `FWLIB64` | 16i / 18i / 21i legacy family |
|
||||
| `zerod` / `zerof` / `zeromf` / `zerotf` | `fwlib0iD64` | 0i-D / 0i-F / 0i-MF / 0i-TF |
|
||||
| `powermotion` | `fwlib0DN64` | Power Motion i |
|
||||
| `ethernet` | `fwlibe64` | Ethernet-variant DLL |
|
||||
| `ncguide` | `fwlibNCG64` | NC Guide PC simulator |
|
||||
|
||||
## What this covers — and what it doesn't
|
||||
|
||||
**Covered:**
|
||||
|
||||
- All 10 FOCAS functions `FwlibNative.cs` P/Invokes
|
||||
- Read-after-write round-trip for parameters, macros, PMC ranges
|
||||
- PMC bit read-modify-write path (via the `pmc_wrpmcrng` seam)
|
||||
- `IAlarmSource` raise + clear transitions (via `mock_schedule_alarms`)
|
||||
- Per-series profile selection — tests can pin one and assert series-gated
|
||||
behaviour
|
||||
|
||||
**Not covered** (still hardware-gated):
|
||||
|
||||
- Real FOCAS2 TCP wire protocol (this is a JSON mock; the shim hides
|
||||
the real protocol entirely)
|
||||
- CNC-specific firmware quirks (position scaling across power cycles,
|
||||
edit-mode session locks, MTB custom screens)
|
||||
- Concurrent-read behaviour on the real `Fwlib64.dll` — the shim is
|
||||
single-threaded per connection
|
||||
|
||||
See [`docs/drivers/FOCAS-Test-Fixture.md`](../../../docs/drivers/FOCAS-Test-Fixture.md)
|
||||
for the full coverage map.
|
||||
|
||||
## Skip behaviour
|
||||
|
||||
`FocasSimFixture` probes the mock at collection init time:
|
||||
|
||||
- Mock unreachable → tests skip with the compose-up command to run
|
||||
- Mock reachable but shim DLL not loaded → tests skip with a pointer
|
||||
at `Shim/build.ps1`
|
||||
- Both available → tests run
|
||||
|
||||
This lets the same test assembly be green on a fresh CI box without
|
||||
docker, green on a dev box with just the docker compose up, and
|
||||
exercise the full wire path when the shim is built.
|
||||
@@ -0,0 +1,34 @@
|
||||
# FOCAS simulator — focas-mock JSON/TCP + native FOCAS2 Ethernet server.
|
||||
#
|
||||
# The image is built from the vendored focas-mock snapshot at ./focas-mock/
|
||||
# (see focas-mock/VENDORED.md for refresh procedure).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f Docker/docker-compose.yml up -d --wait
|
||||
# docker compose -f Docker/docker-compose.yml down
|
||||
#
|
||||
# One service, one container — the mock's native FOCAS Ethernet responder
|
||||
# auto-detects the binary PDU prefix (`a0 a0 a0 a0`) on the same TCP port
|
||||
# that serves JSON admin commands. Tests that need per-series behaviour
|
||||
# call `mock_load_profile` via the fixture's admin API at test start.
|
||||
# The pre-wire-client era had one compose profile per CNC series; that
|
||||
# ceremony is gone because the managed wire client doesn't depend on a
|
||||
# per-series shim DLL.
|
||||
|
||||
services:
|
||||
focas-sim:
|
||||
image: otopcua-focas-sim:latest
|
||||
build:
|
||||
context: ./focas-mock
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-focas-sim
|
||||
ports:
|
||||
- "8193:8193"
|
||||
restart: "no"
|
||||
command: ["--profile", "FWLIB64"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('127.0.0.1',8193),timeout=2); s.close()\" || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8193
|
||||
|
||||
ENTRYPOINT ["focas-mock", "serve", "--host", "0.0.0.0", "--port", "8193"]
|
||||
CMD ["--profile", "FWLIB64"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,191 @@
|
||||
# focas-mock
|
||||
|
||||
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
|
||||
|
||||
The project is built from two inputs:
|
||||
|
||||
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
|
||||
- The vendor `fwlib.cs` interop file, used as the callable surface reference
|
||||
|
||||
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, exposes a JSON-over-TCP mock API, and implements the targeted native FOCAS Ethernet wire protocol used by OtOpcUa fixed-tree tests.
|
||||
|
||||
## What is included
|
||||
|
||||
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
|
||||
- A profile extractor that inspects PE exports with `pefile`
|
||||
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
|
||||
- Built-in profiles for:
|
||||
- `FWLIB64`
|
||||
- `fwlib0DN64`
|
||||
- `fwlib0iD64`
|
||||
- `fwlib30i64`
|
||||
- `fwlibe64`
|
||||
- `fwlibNCG64`
|
||||
- A stateful mock server with:
|
||||
- version/profile switching
|
||||
- forced error injection
|
||||
- runtime state patching
|
||||
- built-in default mock data
|
||||
- auto-detected native FOCAS Ethernet PDU handling for the targeted API subset
|
||||
|
||||
## Quick start
|
||||
|
||||
Install in editable mode:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
List the generated profiles:
|
||||
|
||||
```powershell
|
||||
focas-mock list-profiles
|
||||
```
|
||||
|
||||
Start the mock server with the 30i profile:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
|
||||
```
|
||||
|
||||
Start with a JSON patch file that overrides the default data:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The server accepts two protocols on the same port:
|
||||
|
||||
- newline-delimited JSON for fixture control and shim tests
|
||||
- native FOCAS Ethernet binary PDUs from the real `fwlibe64.dll`
|
||||
|
||||
JSON requests are one object per line:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
|
||||
```
|
||||
|
||||
Supported admin methods:
|
||||
|
||||
- `mock_get_state`
|
||||
- `mock_patch`
|
||||
- `mock_reset`
|
||||
- `mock_load_profile`
|
||||
- `mock_list_methods`
|
||||
- `mock_schedule_alarms`
|
||||
|
||||
Example patch request:
|
||||
|
||||
```json
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
|
||||
```
|
||||
|
||||
Native FOCAS Ethernet clients do not use the JSON request format. Seed profile
|
||||
and fixture state with JSON first, then point `cnc_allclibhndl3` at the same
|
||||
host and port. Wire-level details are documented in
|
||||
`docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
|
||||
For clients that should avoid FANUC DLL loading entirely, `dotnet/Focas.Wire`
|
||||
contains a native C# read-only TCP client for the verified wire subset. It does
|
||||
not expose write APIs; use the JSON control channel to preset fixture state.
|
||||
|
||||
Example test setup over TCP:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
|
||||
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
|
||||
```
|
||||
|
||||
## Regenerating profiles
|
||||
|
||||
The built-in JSON profiles are generated from the vendored binaries:
|
||||
|
||||
```powershell
|
||||
python -m focas_mock.cli extract-profiles
|
||||
```
|
||||
|
||||
By default this reads:
|
||||
|
||||
- `vendor/fanuc-cnc-api/64bit/*.dll`
|
||||
- `upstream/fwlib.cs`
|
||||
|
||||
and writes:
|
||||
|
||||
- `src/focas_mock/builtin_profiles/*.json`
|
||||
|
||||
## Testing Direct P/Invoke Clients
|
||||
|
||||
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
|
||||
.\shim\build.ps1
|
||||
$env:FOCAS_MOCK_HOST = "127.0.0.1"
|
||||
$env:FOCAS_MOCK_PORT = "8193"
|
||||
```
|
||||
|
||||
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
|
||||
|
||||
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
|
||||
Native Ethernet wire notes are in `docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
|
||||
|
||||
## Implemented mock calls
|
||||
|
||||
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
|
||||
|
||||
- `cnc_allclibhndl`
|
||||
- `cnc_allclibhndl2`
|
||||
- `cnc_allclibhndl3`
|
||||
- `cnc_freelibhndl`
|
||||
- `cnc_sysinfo`
|
||||
- `cnc_statinfo`
|
||||
- `cnc_rddynamic2`
|
||||
- `cnc_actf`
|
||||
- `cnc_acts`
|
||||
- `cnc_acts2`
|
||||
- `cnc_getpath`
|
||||
- `cnc_setpath`
|
||||
- `cnc_rdaxisname`
|
||||
- `cnc_rdspdlname`
|
||||
- `cnc_rdparam`
|
||||
- `cnc_wrparam`
|
||||
- `cnc_rdmacro`
|
||||
- `cnc_wrmacro`
|
||||
- `cnc_rdalmmsg2`
|
||||
- `pmc_rdpmcrng`
|
||||
- `pmc_wrpmcrng`
|
||||
- `cnc_rdopmsg`
|
||||
- `cnc_rdopmode`
|
||||
- `cnc_rdprgnum`
|
||||
- `cnc_exeprgname2`
|
||||
- `cnc_rdexecprog`
|
||||
- `cnc_rdseqnum`
|
||||
- `cnc_rdblkcount`
|
||||
- `cnc_rdproginfo`
|
||||
- `cnc_rdprogdir3`
|
||||
- `cnc_rdtimer`
|
||||
- `cnc_rdspmeter`
|
||||
- `cnc_rdsvmeter`
|
||||
- `cnc_rdspload`
|
||||
- `cnc_rdspgear`
|
||||
- `cnc_rdspmaxrpm`
|
||||
- `cnc_rddiagnum`
|
||||
- `cnc_rddiaginfo`
|
||||
- `cnc_diagnoss`
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is not a binary-compatible replacement for FANUC's DLLs.
|
||||
- Native FOCAS Ethernet support is intentionally scoped to the targeted API subset documented in `docs/FOCAS_WIRE_PROTOCOL.md`.
|
||||
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.
|
||||
@@ -0,0 +1,45 @@
|
||||
# focas-mock — vendored snapshot
|
||||
|
||||
Source: `C:\Users\dohertj2\Desktop\focas` (sibling project in this dev environment).
|
||||
|
||||
**Snapshot date:** 2026-04-24 (second refresh — pulled the native FOCAS2 Ethernet responder work in).
|
||||
|
||||
## Why vendored
|
||||
|
||||
OtOpcUa's FOCAS integration fixture runs against the Python mock server.
|
||||
The upstream lives in its own repo; this directory is a verbatim
|
||||
snapshot so CI can build the Docker image without network access to the
|
||||
source repo and so OtOpcUa's test matrix pins a known-good revision.
|
||||
|
||||
The managed `WireFocasClient` speaks the mock's native FOCAS2 Ethernet
|
||||
binary protocol directly — there's no longer a companion shim DLL.
|
||||
|
||||
## What's here
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `src/focas_mock/` | Python package — TCP JSON/line-delimited mock server with 6 Fanuc CNC profiles |
|
||||
| `pyproject.toml` | Package metadata; installs `focas-mock` CLI |
|
||||
| `Dockerfile` | `python:3.11-slim` image built by the parent `docker-compose.yml` |
|
||||
| `README.md` | Upstream README |
|
||||
| `LICENSE` | MIT — permissive, vendoring allowed |
|
||||
|
||||
|
||||
## Refreshing the snapshot
|
||||
|
||||
When upstream ships changes worth pulling:
|
||||
|
||||
```powershell
|
||||
$src = "C:\Users\dohertj2\Desktop\focas"
|
||||
$dest = "$PWD"
|
||||
Remove-Item -Recurse -Force "$dest\src" 2>$null
|
||||
Copy-Item -Recurse "$src\src" "$dest\src"
|
||||
Copy-Item "$src\pyproject.toml" "$dest\"
|
||||
Copy-Item "$src\README.md" "$dest\"
|
||||
Copy-Item "$src\LICENSE" "$dest\"
|
||||
Copy-Item "$src\Dockerfile" "$dest\"
|
||||
```
|
||||
|
||||
Update the snapshot date at the top of this file afterward. No other
|
||||
files belong here — the Docker build context is just the Python package
|
||||
and its metadata.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "focas-mock"
|
||||
version = "0.1.0"
|
||||
description = "Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
dependencies = ["pefile>=2024.8.26"]
|
||||
|
||||
[project.scripts]
|
||||
focas-mock = "focas_mock.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
focas_mock = ["builtin_profiles/*.json"]
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: focas-mock
|
||||
Version: 0.1.0
|
||||
Summary: Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports.
|
||||
License-Expression: MIT
|
||||
Requires-Python: >=3.11
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
Requires-Dist: pefile>=2024.8.26
|
||||
Dynamic: license-file
|
||||
|
||||
# focas-mock
|
||||
|
||||
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
|
||||
|
||||
The project is built from two inputs:
|
||||
|
||||
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
|
||||
- The vendor `fwlib.cs` interop file, used as the callable surface reference
|
||||
|
||||
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, and exposes a JSON-over-TCP mock API whose method names match common FOCAS entry points such as `cnc_allclibhndl3`, `cnc_sysinfo`, `cnc_statinfo`, and `cnc_rddynamic2`.
|
||||
|
||||
## What is included
|
||||
|
||||
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
|
||||
- A profile extractor that inspects PE exports with `pefile`
|
||||
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
|
||||
- Built-in profiles for:
|
||||
- `FWLIB64`
|
||||
- `fwlib0DN64`
|
||||
- `fwlib0iD64`
|
||||
- `fwlib30i64`
|
||||
- `fwlibe64`
|
||||
- `fwlibNCG64`
|
||||
- A stateful mock server with:
|
||||
- version/profile switching
|
||||
- forced error injection
|
||||
- runtime state patching
|
||||
- built-in default mock data
|
||||
|
||||
## Quick start
|
||||
|
||||
Install in editable mode:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
List the generated profiles:
|
||||
|
||||
```powershell
|
||||
focas-mock list-profiles
|
||||
```
|
||||
|
||||
Start the mock server with the 30i profile:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
|
||||
```
|
||||
|
||||
Start with a JSON patch file that overrides the default data:
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The server speaks newline-delimited JSON. Each request is one JSON object per line:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
|
||||
```
|
||||
|
||||
Supported admin methods:
|
||||
|
||||
- `mock_get_state`
|
||||
- `mock_patch`
|
||||
- `mock_reset`
|
||||
- `mock_load_profile`
|
||||
- `mock_list_methods`
|
||||
|
||||
Example patch request:
|
||||
|
||||
```json
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
|
||||
```
|
||||
|
||||
Example test setup over TCP:
|
||||
|
||||
```json
|
||||
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
|
||||
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
|
||||
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
|
||||
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
|
||||
```
|
||||
|
||||
## Regenerating profiles
|
||||
|
||||
The built-in JSON profiles are generated from the vendored binaries:
|
||||
|
||||
```powershell
|
||||
python -m focas_mock.cli extract-profiles
|
||||
```
|
||||
|
||||
By default this reads:
|
||||
|
||||
- `vendor/fanuc-cnc-api/64bit/*.dll`
|
||||
- `upstream/fwlib.cs`
|
||||
|
||||
and writes:
|
||||
|
||||
- `src/focas_mock/builtin_profiles/*.json`
|
||||
|
||||
## Testing Direct P/Invoke Clients
|
||||
|
||||
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
|
||||
|
||||
```powershell
|
||||
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
|
||||
.\shim\build.ps1
|
||||
$env:FOCAS_MOCK_HOST = "127.0.0.1"
|
||||
$env:FOCAS_MOCK_PORT = "8193"
|
||||
```
|
||||
|
||||
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
|
||||
|
||||
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
|
||||
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
|
||||
|
||||
## Implemented mock calls
|
||||
|
||||
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
|
||||
|
||||
- `cnc_allclibhndl`
|
||||
- `cnc_allclibhndl2`
|
||||
- `cnc_allclibhndl3`
|
||||
- `cnc_freelibhndl`
|
||||
- `cnc_sysinfo`
|
||||
- `cnc_statinfo`
|
||||
- `cnc_rddynamic2`
|
||||
- `cnc_actf`
|
||||
- `cnc_acts`
|
||||
- `cnc_acts2`
|
||||
- `cnc_getpath`
|
||||
- `cnc_setpath`
|
||||
- `cnc_rdaxisname`
|
||||
- `cnc_rdspdlname`
|
||||
- `cnc_rdparam`
|
||||
- `cnc_wrparam`
|
||||
- `cnc_rdmacro`
|
||||
- `cnc_wrmacro`
|
||||
- `cnc_rdalmmsg2`
|
||||
- `pmc_rdpmcrng`
|
||||
- `pmc_wrpmcrng`
|
||||
- `cnc_rdopmsg`
|
||||
- `cnc_rdopmode`
|
||||
- `cnc_rdprgnum`
|
||||
- `cnc_exeprgname2`
|
||||
- `cnc_rdexecprog`
|
||||
- `cnc_rdseqnum`
|
||||
- `cnc_rdblkcount`
|
||||
- `cnc_rdproginfo`
|
||||
- `cnc_rdprogdir3`
|
||||
- `cnc_rdtimer`
|
||||
- `cnc_rdspmeter`
|
||||
- `cnc_rdsvmeter`
|
||||
- `cnc_rdspload`
|
||||
- `cnc_rdspgear`
|
||||
- `cnc_rdspmaxrpm`
|
||||
- `cnc_rddiagnum`
|
||||
- `cnc_rddiaginfo`
|
||||
- `cnc_diagnoss`
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is not a binary-compatible replacement for FANUC's DLLs.
|
||||
- This is not a reverse-engineered implementation of FANUC's wire protocol.
|
||||
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
LICENSE
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/focas_mock/__init__.py
|
||||
src/focas_mock/cli.py
|
||||
src/focas_mock/constants.py
|
||||
src/focas_mock/data_store.py
|
||||
src/focas_mock/defaults.py
|
||||
src/focas_mock/export_introspection.py
|
||||
src/focas_mock/profiles.py
|
||||
src/focas_mock/server.py
|
||||
src/focas_mock.egg-info/PKG-INFO
|
||||
src/focas_mock.egg-info/SOURCES.txt
|
||||
src/focas_mock.egg-info/dependency_links.txt
|
||||
src/focas_mock.egg-info/entry_points.txt
|
||||
src/focas_mock.egg-info/requires.txt
|
||||
src/focas_mock.egg-info/top_level.txt
|
||||
src/focas_mock/builtin_profiles/FWLIB64.json
|
||||
src/focas_mock/builtin_profiles/fwlib0DN64.json
|
||||
src/focas_mock/builtin_profiles/fwlib0iD64.json
|
||||
src/focas_mock/builtin_profiles/fwlib30i64.json
|
||||
src/focas_mock/builtin_profiles/fwlibNCG64.json
|
||||
src/focas_mock/builtin_profiles/fwlibe64.json
|
||||
tests/test_profiles.py
|
||||
tests/test_server.py
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
focas-mock = focas_mock.cli:main
|
||||
+1
@@ -0,0 +1 @@
|
||||
pefile>=2024.8.26
|
||||
+1
@@ -0,0 +1 @@
|
||||
focas_mock
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]
|
||||
|
||||
+2677
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+1669
File diff suppressed because it is too large
Load Diff
+80
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .data_store import MockDataStore
|
||||
from .export_introspection import write_profiles
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
|
||||
def _default_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
root = _default_root()
|
||||
parser = argparse.ArgumentParser(prog="focas-mock")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
serve = sub.add_parser("serve", help="Start the mock server.")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8193)
|
||||
serve.add_argument("--profile", default="fwlib30i64")
|
||||
serve.add_argument("--data", help="Optional JSON patch file.")
|
||||
|
||||
sub.add_parser("list-profiles", help="List built-in profiles.")
|
||||
|
||||
dump_profile = sub.add_parser("dump-profile", help="Print one built-in profile as JSON.")
|
||||
dump_profile.add_argument("profile")
|
||||
|
||||
extract = sub.add_parser("extract-profiles", help="Regenerate JSON profiles from vendored DLLs.")
|
||||
extract.add_argument("--dll-dir", default=str(root / "vendor" / "fanuc-cnc-api" / "64bit"))
|
||||
extract.add_argument("--fwlib", default=str(root / "upstream" / "fwlib.cs"))
|
||||
extract.add_argument("--out-dir", default=str(root / "src" / "focas_mock" / "builtin_profiles"))
|
||||
return parser
|
||||
|
||||
|
||||
async def _run_server(args: argparse.Namespace) -> None:
|
||||
profile = load_profile(args.profile)
|
||||
store = MockDataStore(profile)
|
||||
if args.data:
|
||||
store.load_patch_file(args.data)
|
||||
server = FocasMockServer(args.host, args.port, profile, store)
|
||||
await server.start()
|
||||
print(f"focas-mock listening on {server.host}:{server.port} with profile {profile['profile_name']}")
|
||||
try:
|
||||
await server.serve_forever()
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "list-profiles":
|
||||
for profile in list_profiles():
|
||||
print(profile)
|
||||
return
|
||||
|
||||
if args.command == "dump-profile":
|
||||
print(json.dumps(load_profile(args.profile), indent=2))
|
||||
return
|
||||
|
||||
if args.command == "extract-profiles":
|
||||
written = write_profiles(args.dll_dir, args.fwlib, args.out_dir)
|
||||
for path in written:
|
||||
print(path)
|
||||
return
|
||||
|
||||
if args.command == "serve":
|
||||
asyncio.run(_run_server(args))
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
EW_PROTOCOL = -17
|
||||
EW_SOCKET = -16
|
||||
EW_NODLL = -15
|
||||
EW_BUS = -11
|
||||
EW_SYSTEM2 = -10
|
||||
EW_HSSB = -9
|
||||
EW_HANDLE = -8
|
||||
EW_VERSION = -7
|
||||
EW_UNEXP = -6
|
||||
EW_SYSTEM = -5
|
||||
EW_PARITY = -4
|
||||
EW_MMCSYS = -3
|
||||
EW_RESET = -2
|
||||
EW_BUSY = -1
|
||||
EW_OK = 0
|
||||
EW_FUNC = 1
|
||||
EW_LENGTH = 2
|
||||
EW_NUMBER = 3
|
||||
EW_ATTRIB = 4
|
||||
EW_DATA = 5
|
||||
EW_NOOPT = 6
|
||||
EW_PROT = 7
|
||||
EW_OVRFLOW = 8
|
||||
EW_PARAM = 9
|
||||
EW_BUFFER = 10
|
||||
EW_PATH = 11
|
||||
EW_MODE = 12
|
||||
EW_REJECT = 13
|
||||
EW_DTSRVR = 14
|
||||
EW_ALARM = 15
|
||||
EW_STOP = 16
|
||||
EW_PASSWD = 17
|
||||
|
||||
RC_LABELS = {
|
||||
EW_PROTOCOL: "EW_PROTOCOL",
|
||||
EW_SOCKET: "EW_SOCKET",
|
||||
EW_NODLL: "EW_NODLL",
|
||||
EW_BUS: "EW_BUS",
|
||||
EW_SYSTEM2: "EW_SYSTEM2",
|
||||
EW_HSSB: "EW_HSSB",
|
||||
EW_HANDLE: "EW_HANDLE",
|
||||
EW_VERSION: "EW_VERSION",
|
||||
EW_UNEXP: "EW_UNEXP",
|
||||
EW_SYSTEM: "EW_SYSTEM",
|
||||
EW_PARITY: "EW_PARITY",
|
||||
EW_MMCSYS: "EW_MMCSYS",
|
||||
EW_RESET: "EW_RESET",
|
||||
EW_BUSY: "EW_BUSY",
|
||||
EW_OK: "EW_OK",
|
||||
EW_FUNC: "EW_FUNC",
|
||||
EW_LENGTH: "EW_LENGTH",
|
||||
EW_NUMBER: "EW_NUMBER",
|
||||
EW_ATTRIB: "EW_ATTRIB",
|
||||
EW_DATA: "EW_DATA",
|
||||
EW_NOOPT: "EW_NOOPT",
|
||||
EW_PROT: "EW_PROT",
|
||||
EW_OVRFLOW: "EW_OVRFLOW",
|
||||
EW_PARAM: "EW_PARAM",
|
||||
EW_BUFFER: "EW_BUFFER",
|
||||
EW_PATH: "EW_PATH",
|
||||
EW_MODE: "EW_MODE",
|
||||
EW_REJECT: "EW_REJECT",
|
||||
EW_DTSRVR: "EW_DTSRVR",
|
||||
EW_ALARM: "EW_ALARM",
|
||||
EW_STOP: "EW_STOP",
|
||||
EW_PASSWD: "EW_PASSWD",
|
||||
}
|
||||
|
||||
IMPLEMENTED_FOCAS_METHODS = [
|
||||
"cnc_allclibhndl",
|
||||
"cnc_allclibhndl2",
|
||||
"cnc_allclibhndl3",
|
||||
"cnc_freelibhndl",
|
||||
"cnc_sysinfo",
|
||||
"cnc_statinfo",
|
||||
"cnc_rddynamic2",
|
||||
"cnc_actf",
|
||||
"cnc_acts",
|
||||
"cnc_acts2",
|
||||
"cnc_getpath",
|
||||
"cnc_setpath",
|
||||
"cnc_rdaxisname",
|
||||
"cnc_rdspdlname",
|
||||
"cnc_rdparam",
|
||||
"cnc_wrparam",
|
||||
"cnc_rdmacro",
|
||||
"cnc_wrmacro",
|
||||
"cnc_rdalmmsg2",
|
||||
"pmc_rdpmcrng",
|
||||
"pmc_wrpmcrng",
|
||||
"cnc_rdopmsg",
|
||||
"cnc_rdopmode",
|
||||
"cnc_rdprgnum",
|
||||
"cnc_exeprgname2",
|
||||
"cnc_rdexecprog",
|
||||
"cnc_rdseqnum",
|
||||
"cnc_rdblkcount",
|
||||
"cnc_rdproginfo",
|
||||
"cnc_rdprogdir3",
|
||||
"cnc_rdtimer",
|
||||
"cnc_rdspmeter",
|
||||
"cnc_rdsvmeter",
|
||||
"cnc_rdspload",
|
||||
"cnc_rdspgear",
|
||||
"cnc_rdspmaxrpm",
|
||||
"cnc_rddiagnum",
|
||||
"cnc_rddiaginfo",
|
||||
"cnc_diagnoss",
|
||||
]
|
||||
|
||||
ADMIN_METHODS = [
|
||||
"mock_get_state",
|
||||
"mock_patch",
|
||||
"mock_reset",
|
||||
"mock_load_profile",
|
||||
"mock_list_methods",
|
||||
"mock_schedule_alarms",
|
||||
]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .defaults import make_default_state
|
||||
|
||||
|
||||
def _deep_merge(target: dict[str, Any], patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, Mapping) and isinstance(target.get(key), dict):
|
||||
_deep_merge(target[key], value)
|
||||
else:
|
||||
target[key] = deepcopy(value)
|
||||
return target
|
||||
|
||||
|
||||
class MockDataStore:
|
||||
def __init__(self, profile: Mapping[str, Any]) -> None:
|
||||
self.profile = dict(profile)
|
||||
self._defaults = make_default_state(profile)
|
||||
self._state = deepcopy(self._defaults)
|
||||
|
||||
@property
|
||||
def state(self) -> dict[str, Any]:
|
||||
return self._state
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return deepcopy(self._state)
|
||||
|
||||
def reset(self) -> dict[str, Any]:
|
||||
self._state = deepcopy(self._defaults)
|
||||
return self.snapshot()
|
||||
|
||||
def merge_patch(self, patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
_deep_merge(self._state, patch)
|
||||
return self.snapshot()
|
||||
|
||||
def load_patch_file(self, path: str | Path) -> dict[str, Any]:
|
||||
patch = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return self.merge_patch(patch)
|
||||
|
||||
def consume_forced_error(self, method: str) -> tuple[int, str] | None:
|
||||
entry = self._state.get("forced_errors", {}).get(method)
|
||||
if not entry:
|
||||
return None
|
||||
if isinstance(entry, int):
|
||||
return entry, f"forced error for {method}"
|
||||
rc = int(entry.get("rc", 0))
|
||||
count = int(entry.get("count", 1))
|
||||
message = str(entry.get("message", f"forced error for {method}"))
|
||||
if count <= 1:
|
||||
self._state["forced_errors"].pop(method, None)
|
||||
else:
|
||||
entry["count"] = count - 1
|
||||
return rc, message
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _family_config(profile_name: str) -> tuple[str, int, int, int]:
|
||||
lowered = profile_name.lower()
|
||||
if "30i" in lowered or "ncg" in lowered:
|
||||
return ("30i", 32, 2, 4)
|
||||
if "0id" in lowered or "0dn" in lowered:
|
||||
return ("0i-D", 24, 1, 2)
|
||||
if "fwlibe64" in lowered:
|
||||
return ("e64", 8, 1, 2)
|
||||
return ("generic", 8, 1, 2)
|
||||
|
||||
|
||||
def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
|
||||
profile_name = str(profile.get("profile_name", "FWLIB64"))
|
||||
family, default_axes, max_path, max_spindles = _family_config(profile_name)
|
||||
max_axis = int(profile.get("max_axis_hint") or default_axes)
|
||||
axis_names = [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
]
|
||||
while len(axis_names) < max_axis:
|
||||
axis_names.append(f"A{len(axis_names) + 1}")
|
||||
|
||||
spindle_names = [f"S{i}" for i in range(1, max_spindles + 1)]
|
||||
programs = [
|
||||
{"number": 1, "comment": "MAIN", "length": 128},
|
||||
{"number": 100, "comment": "TOOLCHANGE", "length": 84},
|
||||
]
|
||||
|
||||
return {
|
||||
"sysinfo": {
|
||||
"addinfo": 0,
|
||||
"max_axis": max_axis,
|
||||
"cnc_type": family[:2].ljust(2),
|
||||
"mt_type": "M ",
|
||||
"series": family[:4].ljust(4),
|
||||
"version": "A1.0",
|
||||
"axes": str(max_axis).rjust(2, "0"),
|
||||
},
|
||||
"statinfo": {
|
||||
"aut": 1,
|
||||
"run": 3,
|
||||
"motion": 1,
|
||||
"mstb": 0,
|
||||
"emergency": 0,
|
||||
"alarm": 0,
|
||||
"edit": 0,
|
||||
},
|
||||
"paths": {"current": 1, "max": max_path},
|
||||
"dynamic": {
|
||||
"alarm": 0,
|
||||
"prgnum": 1,
|
||||
"prgmnum": 1,
|
||||
"seqnum": 120,
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"axes": {
|
||||
axis_names[0]: {"absolute": 123456, "machine": 123450, "relative": 6, "distance": 0},
|
||||
axis_names[1]: {"absolute": -22000, "machine": -22010, "relative": 10, "distance": 0},
|
||||
axis_names[2]: {"absolute": 8000, "machine": 7990, "relative": 10, "distance": 0},
|
||||
},
|
||||
},
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"acts2": [3200] + [0] * (max_spindles - 1),
|
||||
"axis_names": axis_names[:max_axis],
|
||||
"spindle_names": spindle_names,
|
||||
"parameters": {
|
||||
"6711": {"type": "long", "value": 1, "decimal": 0, "description": "example parameter"},
|
||||
"6712": {"type": "long", "value": 500, "decimal": 0, "description": "example parameter"},
|
||||
},
|
||||
"pmc": {
|
||||
"R": {
|
||||
"100": {"type": "byte", "value": 0},
|
||||
"101": {"type": "byte", "value": 1},
|
||||
"102": {"type": "byte", "value": 0},
|
||||
},
|
||||
},
|
||||
"macros": {
|
||||
"100": {"value": 12345, "decimal": 3},
|
||||
"101": {"value": 98765, "decimal": 3},
|
||||
},
|
||||
"alarms": [
|
||||
{"alm_no": 0, "type": 0, "axis": 0, "msg": ""},
|
||||
],
|
||||
"operator_messages": [
|
||||
{"number": 200, "type": 0, "char_num": 12, "data": "READY"},
|
||||
],
|
||||
"program": {
|
||||
"current": 1,
|
||||
"main": 1,
|
||||
"sequence": 120,
|
||||
"block_count": 42,
|
||||
"executing": "%\nO0001\nG90 G54 G00 X0 Y0\nM30\n%",
|
||||
"executing_path": "//CNC_MEM/USER/PATH1/O0001",
|
||||
"directory": programs,
|
||||
},
|
||||
"spindle": {
|
||||
"meter": [
|
||||
{"name": spindle_names[0], "value": 56, "unit": "%"},
|
||||
{"name": spindle_names[1], "value": 0, "unit": "%"},
|
||||
],
|
||||
"servo_meter": [
|
||||
{"name": axis_names[0], "value": 14, "unit": "%"},
|
||||
{"name": axis_names[1], "value": 8, "unit": "%"},
|
||||
{"name": axis_names[2], "value": 5, "unit": "%"},
|
||||
],
|
||||
"load": [
|
||||
{"name": spindle_names[0], "load": 56, "speed": 3200},
|
||||
{"name": spindle_names[1], "load": 0, "speed": 0},
|
||||
],
|
||||
"gear": [1] * max_spindles,
|
||||
"max_rpm": [6000] * max_spindles,
|
||||
},
|
||||
"timers": {
|
||||
"power_on": 86400,
|
||||
"operating": 7200,
|
||||
"cutting": 3600,
|
||||
"cycle": 95,
|
||||
},
|
||||
"operation_mode": {"mode": 1, "name": "MEM"},
|
||||
"diagnostics": {
|
||||
"300": {"type": "long", "value": 14, "description": "servo load X"},
|
||||
"301": {"type": "long", "value": 8, "description": "servo load Y"},
|
||||
"302": {"type": "long", "value": 5, "description": "servo load Z"},
|
||||
},
|
||||
"forced_errors": {},
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .constants import IMPLEMENTED_FOCAS_METHODS
|
||||
|
||||
|
||||
def parse_fwlib_imports(fwlib_cs_path: str | Path) -> list[str]:
|
||||
text = Path(fwlib_cs_path).read_text(encoding="utf-8", errors="ignore")
|
||||
imports = re.findall(r"extern short\s+([A-Za-z0-9_]+)\s*\(", text)
|
||||
return sorted(set(imports))
|
||||
|
||||
|
||||
def extract_dll_exports(dll_path: str | Path) -> list[str]:
|
||||
import pefile
|
||||
|
||||
pe = pefile.PE(str(dll_path))
|
||||
exports = [entry.name.decode("ascii", "ignore") for entry in pe.DIRECTORY_ENTRY_EXPORT.symbols if entry.name]
|
||||
return sorted(set(exports))
|
||||
|
||||
|
||||
def _infer_metadata(dll_name: str) -> tuple[str, int, int]:
|
||||
lowered = dll_name.lower()
|
||||
if lowered == "fwlib64.dll":
|
||||
return ("generic-ethernet", 8, 1)
|
||||
if lowered == "fwlibe64.dll":
|
||||
return ("embedded-ethernet", 8, 1)
|
||||
if "30i" in lowered:
|
||||
return ("30i/31i/32i", 32, 2)
|
||||
if "ncg" in lowered:
|
||||
return ("ncguide-family", 32, 2)
|
||||
if "0id" in lowered:
|
||||
return ("0i-d-family", 24, 1)
|
||||
if "0dn" in lowered:
|
||||
return ("0-dn-family", 24, 1)
|
||||
return ("unknown", 8, 1)
|
||||
|
||||
|
||||
def build_profile(dll_path: str | Path, fwlib_cs_path: str | Path) -> dict[str, Any]:
|
||||
dll_path = Path(dll_path)
|
||||
exports = extract_dll_exports(dll_path)
|
||||
wrapper_imports = set(parse_fwlib_imports(fwlib_cs_path))
|
||||
series_hint, max_axis_hint, max_path_hint = _infer_metadata(dll_path.name)
|
||||
export_set = set(exports)
|
||||
connection_methods = sorted(symbol for symbol in exports if symbol.startswith("cnc_allclibhndl"))
|
||||
mock_methods = sorted(set(IMPLEMENTED_FOCAS_METHODS) & export_set)
|
||||
wrapper_supported = sorted(wrapper_imports & export_set)
|
||||
return {
|
||||
"profile_name": dll_path.stem,
|
||||
"dll_name": dll_path.name,
|
||||
"series_hint": series_hint,
|
||||
"max_axis_hint": max_axis_hint,
|
||||
"max_path_hint": max_path_hint,
|
||||
"export_count": len(exports),
|
||||
"connection_methods": connection_methods,
|
||||
"mock_methods": mock_methods,
|
||||
"wrapper_supported_count": len(wrapper_supported),
|
||||
"wrapper_supported_methods": wrapper_supported,
|
||||
"exports": exports,
|
||||
"notes": [
|
||||
"Exports extracted directly from the 64-bit DLL PE export directory.",
|
||||
"Wrapper-supported methods are intersected with upstream fwlib.cs extern declarations.",
|
||||
"Axis and path hints are filename-family heuristics, not protocol-level proofs.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_profiles(dll_dir: str | Path, fwlib_cs_path: str | Path, out_dir: str | Path) -> list[Path]:
|
||||
dll_dir = Path(dll_dir)
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
written: list[Path] = []
|
||||
for dll_path in sorted(dll_dir.glob("*.dll")):
|
||||
profile = build_profile(dll_path, fwlib_cs_path)
|
||||
out_path = out_dir / f"{dll_path.stem}.json"
|
||||
out_path.write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
||||
written.append(out_path)
|
||||
return written
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROFILE_DIR = Path(__file__).resolve().parent / "builtin_profiles"
|
||||
|
||||
PROFILE_ALIASES = {
|
||||
"ZeroI_D": "fwlib0iD64",
|
||||
"ZeroI_F": "fwlib0iD64",
|
||||
"ZeroI_MF": "fwlib0iD64",
|
||||
"ZeroI_TF": "fwlib0iD64",
|
||||
"Sixteen_i": "FWLIB64",
|
||||
"Thirty_i": "fwlib30i64",
|
||||
"ThirtyOne_i": "fwlib30i64",
|
||||
"ThirtyTwo_i": "fwlib30i64",
|
||||
"PowerMotion_i": "fwlib0DN64",
|
||||
}
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
return sorted(path.stem for path in PROFILE_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def resolve_profile_name(profile_name: str) -> str:
|
||||
return PROFILE_ALIASES.get(profile_name, profile_name)
|
||||
|
||||
|
||||
def load_profile(profile_name: str) -> dict[str, Any]:
|
||||
profile_name = resolve_profile_name(profile_name)
|
||||
candidates = [
|
||||
PROFILE_DIR / f"{profile_name}.json",
|
||||
PROFILE_DIR / f"{Path(profile_name).stem}.json",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return json.loads(candidate.read_text(encoding="utf-8"))
|
||||
available = ", ".join(list_profiles())
|
||||
raise FileNotFoundError(f"Unknown profile '{profile_name}'. Available profiles: {available}")
|
||||
+1018
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Fixture for the focas-mock simulator. Probes the Docker mock at
|
||||
/// collection init; if reachable, exposes helpers that drive the mock's
|
||||
/// admin surface (<c>mock_load_profile</c>, <c>mock_patch</c>,
|
||||
/// <c>mock_reset</c>, <c>mock_schedule_alarms</c>) so tests can seed
|
||||
/// deterministic state before exercising the managed driver.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Single skip gate: <see cref="SkipReason"/> is non-null when the
|
||||
/// <c>localhost:8193</c> TCP probe fails. Tests call
|
||||
/// <c>Assert.Skip</c>.
|
||||
/// </remarks>
|
||||
public sealed class FocasSimFixture : IAsyncDisposable
|
||||
{
|
||||
private const string EndpointEnvVar = "OTOPCUA_FOCAS_SIM_ENDPOINT";
|
||||
private const string ProfileEnvVar = "OTOPCUA_FOCAS_SIM_PROFILE";
|
||||
private const string DefaultHost = "localhost";
|
||||
private const int DefaultPort = 8193;
|
||||
|
||||
public string Host { get; }
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>focas-mock profile stem the fixture should load (e.g. <c>fwlib30i64</c>,
|
||||
/// <c>ThirtyOne_i</c> — both resolve via the mock's alias table). Null when unset.</summary>
|
||||
public string? ExpectedProfile { get; }
|
||||
|
||||
/// <summary>When the <see cref="ExpectedProfile"/> maps to a concrete
|
||||
/// <see cref="FocasCncSeries"/>, this is it. Null otherwise.</summary>
|
||||
public FocasCncSeries? ExpectedSeries { get; }
|
||||
|
||||
/// <summary>Non-null when the mock probe failed — tests skip with this reason.</summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public FocasSimFixture()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? $"{DefaultHost}:{DefaultPort}";
|
||||
(Host, Port) = ParseEndpoint(endpoint);
|
||||
|
||||
ExpectedProfile = Environment.GetEnvironmentVariable(ProfileEnvVar);
|
||||
ExpectedSeries = ParseSeries(ExpectedProfile);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient(AddressFamily.InterNetwork);
|
||||
var addresses = System.Net.Dns.GetHostAddresses(Host);
|
||||
var ip = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? System.Net.IPAddress.Loopback;
|
||||
var task = client.ConnectAsync(ip, Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"focas-mock at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
$"Start it (`docker compose -f Docker/docker-compose.yml up -d`) " +
|
||||
$"or override {EndpointEnvVar}.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"focas-mock at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||
$"Start it or override {EndpointEnvVar}.";
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
// ---- Admin API helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Load a focas-mock profile. Accepts either the raw DLL-stem name
|
||||
/// (<c>fwlib30i64</c>) or the OtOpcUa-style alias (<c>ThirtyOne_i</c>);
|
||||
/// focas-mock's <c>PROFILE_ALIASES</c> resolves both.
|
||||
/// </summary>
|
||||
public Task<JsonElement> LoadProfileAsync(string profileName, CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_load_profile", new { profile = profileName }, ct);
|
||||
|
||||
/// <summary>Deep-merge <paramref name="state"/> into the mock's current state.</summary>
|
||||
public Task<JsonElement> PatchStateAsync(object state, CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_patch", new { state }, ct);
|
||||
|
||||
/// <summary>Reset the mock to the selected profile's default state.</summary>
|
||||
public Task<JsonElement> ResetAsync(CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_reset", new { }, ct);
|
||||
|
||||
/// <summary>Install a time-scheduled alarm raise / clear sequence.</summary>
|
||||
public Task<JsonElement> ScheduleAlarmsAsync(IEnumerable<object> sequence, CancellationToken ct = default) =>
|
||||
SendAdminAsync("mock_schedule_alarms", new { sequence }, ct);
|
||||
|
||||
/// <summary>Low-level JSON round-trip. One TCP connection per call — matches
|
||||
/// how the shim talks to the mock; simpler than pooling.</summary>
|
||||
public async Task<JsonElement> SendAdminAsync(string method, object @params, CancellationToken ct = default)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(Host, Port, ct).ConfigureAwait(false);
|
||||
using var stream = client.GetStream();
|
||||
|
||||
var request = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
id = Interlocked.Increment(ref _nextId),
|
||||
method,
|
||||
@params,
|
||||
});
|
||||
await stream.WriteAsync(request, ct).ConfigureAwait(false);
|
||||
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
|
||||
|
||||
var buffer = new byte[65536];
|
||||
var len = 0;
|
||||
while (len < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(len), ct).ConfigureAwait(false);
|
||||
if (read == 0) break;
|
||||
len += read;
|
||||
// focas-mock replies with a single newline-terminated JSON object.
|
||||
if (Array.IndexOf(buffer, (byte)'\n', 0, len) >= 0) break;
|
||||
}
|
||||
var newline = Array.IndexOf(buffer, (byte)'\n', 0, len);
|
||||
var jsonLen = newline >= 0 ? newline : len;
|
||||
var text = Encoding.UTF8.GetString(buffer, 0, jsonLen);
|
||||
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var rc = doc.RootElement.GetProperty("rc").GetInt32();
|
||||
if (rc != 0)
|
||||
{
|
||||
var message = doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : "?";
|
||||
throw new InvalidOperationException($"focas-mock {method} returned rc={rc} ({message}).");
|
||||
}
|
||||
// Return the "result" subtree cloned — document is disposed on exit.
|
||||
return doc.RootElement.GetProperty("result").Clone();
|
||||
}
|
||||
|
||||
private static int _nextId;
|
||||
|
||||
// ---- Parsing ----
|
||||
|
||||
private static (string Host, int Port) ParseEndpoint(string endpoint)
|
||||
{
|
||||
const string focasScheme = "focas://";
|
||||
var body = endpoint.StartsWith(focasScheme, StringComparison.OrdinalIgnoreCase)
|
||||
? endpoint[focasScheme.Length..]
|
||||
: endpoint;
|
||||
var slash = body.IndexOf('/');
|
||||
if (slash >= 0) body = body[..slash];
|
||||
var colon = body.LastIndexOf(':');
|
||||
if (colon < 0) return (body, DefaultPort);
|
||||
var host = body[..colon];
|
||||
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, DefaultPort);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map either a focas-mock DLL-stem profile (<c>fwlib30i64</c>) or a
|
||||
/// OtOpcUa-style alias (<c>ThirtyOne_i</c>) to the matching
|
||||
/// <see cref="FocasCncSeries"/>. Keeps tests able to assert
|
||||
/// series-gated behaviour regardless of how the profile was pinned.
|
||||
/// </summary>
|
||||
private static FocasCncSeries? ParseSeries(string? profile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile)) return null;
|
||||
var trimmed = profile.Trim();
|
||||
|
||||
// Try the OtOpcUa alias set first — it's a superset of human-readable names.
|
||||
// The docker-compose profile names (thirtyone / zerod / ...) are accepted too so
|
||||
// run-focas.ps1's -Profile argument threads straight through.
|
||||
var aliasMapped = trimmed switch
|
||||
{
|
||||
"ThirtyOne_i" or "Thirty_i" or "ThirtyTwo_i"
|
||||
or "thirtyone_i" or "thirty_i" or "thirtytwo_i"
|
||||
or "thirtyone" or "thirty" or "thirtytwo"
|
||||
or "fwlib30i64" => "ThirtyOne_i",
|
||||
"Sixteen_i" or "sixteen_i" or "sixteen" or "FWLIB64" => "Sixteen_i",
|
||||
"Zero_i_D" or "Zero_i_F" or "Zero_i_MF" or "Zero_i_TF"
|
||||
or "zero_i_d" or "zero_i_f" or "zero_i_mf" or "zero_i_tf"
|
||||
or "zerod" or "zerof" or "zeromf" or "zerotf"
|
||||
or "fwlib0iD64" => "Zero_i_D",
|
||||
"PowerMotion_i" or "powermotion_i" or "powermotion"
|
||||
or "fwlib0DN64" => "PowerMotion_i",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return aliasMapped is not null && Enum.TryParse<FocasCncSeries>(aliasMapped, out var parsed)
|
||||
? parsed : null;
|
||||
}
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class FocasSimCollection : Xunit.ICollectionFixture<FocasSimFixture>
|
||||
{
|
||||
public const string Name = "FocasSim";
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the driver capabilities that aren't part of
|
||||
/// the fixed-tree path: user-authored <c>PARAM:</c> / <c>MACRO:</c> / PMC
|
||||
/// reads, <c>DiscoverAsync</c> emission, <c>SubscribeAsync</c> +
|
||||
/// <c>OnDataChange</c>, <c>IAlarmSource</c> raise/clear, and
|
||||
/// <c>IHostConnectivityProbe</c> transitions. All via the managed
|
||||
/// <see cref="WireFocasClient"/> against the running focas-mock.
|
||||
/// </summary>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendCoverageTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private const string DeviceHost = "focas://127.0.0.1:8193";
|
||||
|
||||
[Fact]
|
||||
public async Task User_tag_reads_route_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
parameters = new Dictionary<string, object>
|
||||
{
|
||||
["6711"] = new { type = "long", value = 1234, @decimal = 0 },
|
||||
},
|
||||
macros = new Dictionary<string, object>
|
||||
{
|
||||
["500"] = new { value = 42000, @decimal = 3 },
|
||||
},
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["100"] = new { type = "byte", value = 7 },
|
||||
}},
|
||||
}, ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false),
|
||||
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
|
||||
new FocasTagDefinition("R100", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-usertags", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var snaps = await drv.ReadAsync(["Param6711", "Macro500", "R100"], ct);
|
||||
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToInt32(snaps[0].Value).ShouldBe(1234);
|
||||
Convert.ToDouble(snaps[1].Value).ShouldBe(42.0, tolerance: 0.001);
|
||||
Convert.ToInt32(snaps[2].Value).ShouldBe(7);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_device_folder_and_tag_variables()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost, DeviceName: "Lathe-1")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("Speed", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-discover", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, ct);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == DeviceHost && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Run");
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Speed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_fires_OnDataChange_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["100"] = new { type = "byte", value = 1 },
|
||||
}},
|
||||
}, ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-subscribe", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Run"], TimeSpan.FromMilliseconds(150), ct);
|
||||
await WaitFor(() => events.Count >= 1, TimeSpan.FromSeconds(3));
|
||||
Convert.ToInt32(events.First().Snapshot.Value).ShouldBe(1);
|
||||
|
||||
// Flip the PMC byte — next poll tick should emit a fresh OnDataChange.
|
||||
var before = events.Count;
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["100"] = new { type = "byte", value = 99 },
|
||||
}},
|
||||
}, ct);
|
||||
await WaitFor(() => events.Any(e => Convert.ToInt32(e.Snapshot.Value) == 99),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
await drv.UnsubscribeAsync(handle, ct);
|
||||
events.Count.ShouldBeGreaterThan(before);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
// Start with no active alarms.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], ct);
|
||||
|
||||
// Raise one alarm.
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
alarms = new[]
|
||||
{
|
||||
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
||||
},
|
||||
}, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
|
||||
|
||||
// Clear.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, ct);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(DeviceHost);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_against_live_mock()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
},
|
||||
}, driverInstanceId: "wire-probe", clientFactory: new WireFocasClientFactory());
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
await WaitFor(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(5));
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Dual-run companion to <see cref="FixedTreePopulatesTests"/> — exercises the same
|
||||
/// fixed-tree scenarios through the pure-managed <see cref="WireFocasClient"/>
|
||||
/// instead of the shim/P-Invoke path. Proves both backends observe identical
|
||||
/// state against the same focas-mock instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scheduled for removal in Wire migration phase 3 (task #104) once the shim is
|
||||
/// deleted — at that point only this class survives and becomes the canonical
|
||||
/// fixed-tree integration test.
|
||||
/// </remarks>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
public WireBackendTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private const string DeviceHost = "focas://127.0.0.1:8193";
|
||||
|
||||
[Fact]
|
||||
public async Task Identity_axes_and_dynamic_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "3 ",
|
||||
},
|
||||
axis_names = new[] { "X", "Y", "Z" },
|
||||
rddynamic2 = new
|
||||
{
|
||||
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 42,
|
||||
actf = 1500, acts = 3200,
|
||||
pos = new { absolute = 123456, machine = 123450, relative = 6, distance = 0 },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-identity", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, TimeSpan.FromSeconds(5));
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
state.FixedTreeCache.ShouldNotBeNull();
|
||||
state.FixedTreeCache.SysInfo.Series.ShouldStartWith("30i");
|
||||
state.FixedTreeCache.Axes.Count.ShouldBe(3);
|
||||
state.FixedTreeCache.Axes[0].Display.ShouldBe("X");
|
||||
|
||||
await WaitFor(() =>
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/X/AbsolutePosition"),
|
||||
TimeSpan.FromSeconds(3));
|
||||
state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"].ShouldBe(123456);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Program_and_operation_mode_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "1 ",
|
||||
},
|
||||
axis_names = new[] { "X" },
|
||||
rddynamic2 = new
|
||||
{
|
||||
axis = 1, alarm = 0, prgnum = 42, prgmnum = 42, seqnum = 100,
|
||||
actf = 0, acts = 0,
|
||||
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
|
||||
},
|
||||
program = new
|
||||
{
|
||||
current = 42, main = 42, sequence = 100, block_count = 17,
|
||||
executing_path = "O0042.NC",
|
||||
},
|
||||
operation_mode = new { mode = 3 },
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-program", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { LastProgramInfo: not null },
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Program/Name",
|
||||
$"{DeviceHost}/Program/ONumber",
|
||||
$"{DeviceHost}/Program/BlockCount",
|
||||
$"{DeviceHost}/OperationMode/Mode"], ct);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
snapshots[0].Value!.ToString().ShouldStartWith("O0042");
|
||||
Convert.ToInt32(snapshots[1].Value).ShouldBe(42);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(17);
|
||||
Convert.ToInt32(snapshots[3].Value).ShouldBe(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timers_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
axis_names = new[] { "X" },
|
||||
timers = new
|
||||
{
|
||||
power_on = 3600,
|
||||
operating = 7200,
|
||||
cutting = 1800,
|
||||
cycle = 120,
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
TimerPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
ProgramPollInterval = TimeSpan.Zero,
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-timers", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state is not null && state.LastTimers.Count == 4;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Timers/PowerOnSeconds",
|
||||
$"{DeviceHost}/Timers/OperatingSeconds",
|
||||
$"{DeviceHost}/Timers/CuttingSeconds",
|
||||
$"{DeviceHost}/Timers/CycleSeconds"], ct);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToDouble(snapshots[0].Value).ShouldBe(3600.0, tolerance: 1);
|
||||
Convert.ToDouble(snapshots[1].Value).ShouldBe(7200.0, tolerance: 1);
|
||||
Convert.ToDouble(snapshots[2].Value).ShouldBe(1800.0, tolerance: 1);
|
||||
Convert.ToDouble(snapshots[3].Value).ShouldBe(120.0, tolerance: 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
axis_names = new[] { "X" },
|
||||
spindle_names = new[] { "S1", "S2" },
|
||||
spindle = new
|
||||
{
|
||||
load = new object[]
|
||||
{
|
||||
new { name = "S1", load = 56, speed = 3200 },
|
||||
new { name = "S2", load = 12, speed = 1800 },
|
||||
},
|
||||
max_rpm = new[] { 6000, 4500 },
|
||||
},
|
||||
rddynamic2 = new
|
||||
{
|
||||
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1,
|
||||
actf = 0, acts = 0,
|
||||
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.Zero,
|
||||
TimerPollInterval = TimeSpan.Zero,
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-spindle", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
{
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true }
|
||||
&& state.LastSpindleLoads.Count >= 2;
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
[$"{DeviceHost}/Spindle/S1/Load",
|
||||
$"{DeviceHost}/Spindle/S1/MaxRpm",
|
||||
$"{DeviceHost}/Spindle/S2/Load",
|
||||
$"{DeviceHost}/Spindle/S2/MaxRpm"], ct);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(56);
|
||||
Convert.ToInt32(snapshots[1].Value).ShouldBe(6000);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(12);
|
||||
Convert.ToInt32(snapshots[3].Value).ShouldBe(4500);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -6,7 +6,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests</RootNamespace>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -20,12 +20,14 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
<!-- Docker/ (the Python simulator + profiles) is part of the project so the
|
||||
file tree stays discoverable, but it doesn't need to be copied to bin/;
|
||||
tests run it via docker compose, not via the test-output dir. -->
|
||||
<None Include="Docker\**\*" Pack="false" CopyToOutputDirectory="Never"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,280 +0,0 @@
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
|
||||
/// <c>[Key]</c>-tagged fields survive serialize -> deserialize without loss so the
|
||||
/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ContractRoundTripTests
|
||||
{
|
||||
private static T RoundTrip<T>(T value)
|
||||
{
|
||||
var bytes = MessagePackSerializer.Serialize(value);
|
||||
return MessagePackSerializer.Deserialize<T>(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<int>(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<double>(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<bool>(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);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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<Hello>(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<InvalidDataException>(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<EndOfStreamException>(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<long>();
|
||||
while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
|
||||
{
|
||||
frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
|
||||
seen.Add(FrameReader.Deserialize<Heartbeat>(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);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,52 @@ internal class FakeFocasClient : IFocasClient
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
public List<FocasActiveAlarm> Alarms { get; } = [];
|
||||
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
|
||||
// ---- Fixed-tree T1 ----
|
||||
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
|
||||
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
|
||||
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
|
||||
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
|
||||
|
||||
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
|
||||
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
|
||||
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
|
||||
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
|
||||
{
|
||||
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
|
||||
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
return Task.FromResult(snap);
|
||||
}
|
||||
|
||||
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
|
||||
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
||||
Task.FromResult(ProgramInfo);
|
||||
|
||||
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
||||
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
|
||||
{
|
||||
if (!Timers.TryGetValue(kind, out var t))
|
||||
t = new FocasTimer(kind, 0, 0);
|
||||
return Task.FromResult(t);
|
||||
}
|
||||
|
||||
public List<FocasServoLoad> ServoLoads { get; } = [];
|
||||
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
||||
|
||||
public List<int> SpindleLoads { get; } = [];
|
||||
public List<int> SpindleMaxRpms { get; } = [];
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #220 — covers the DriverConfig JSON contract that
|
||||
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
|
||||
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
|
||||
/// or CNC required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDriverFactoryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_adds_FOCAS_entry_to_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("FOCAS").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_is_case_insensitive_via_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("focas").ShouldNotBeNull();
|
||||
registry.TryGet("Focas").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "ipc",
|
||||
"PipeName": "OtOpcUaFocasHost",
|
||||
"SharedSecret": "secret-for-test",
|
||||
"ConnectTimeoutMs": 5000,
|
||||
"Series": "Thirty_i",
|
||||
"TimeoutMs": 3000,
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
|
||||
],
|
||||
"Tags": [
|
||||
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "R100", "DataType": "Int32", "Writable": true }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
|
||||
|
||||
driver.ShouldNotBeNull();
|
||||
driver.DriverInstanceId.ShouldBe("focas-0");
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
|
||||
{
|
||||
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
|
||||
const string json = """
|
||||
{ "PipeName": "p", "SharedSecret": "s" }
|
||||
""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_PipeName_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
|
||||
.Message.ShouldContain("PipeName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
|
||||
.Message.ShouldContain("SharedSecret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
|
||||
{
|
||||
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
|
||||
// natively on first use.
|
||||
const string json = """{ "Backend": "fwlib" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-fwlib");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
|
||||
{
|
||||
// Useful for staging DriverInstance rows in the config DB before the Host is
|
||||
// actually deployed — the server boots but reads/writes surface clear errors.
|
||||
const string json = """{ "Backend": "unimplemented" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-unimpl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unknown_backend_throws_with_expected_list()
|
||||
{
|
||||
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
|
||||
.Message.ShouldContain("gibberish");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_unknown_Series()
|
||||
{
|
||||
const string json = """
|
||||
{ "Backend": "fwlib", "Series": "NotARealSeries" }
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
|
||||
.Message.ShouldContain("NotARealSeries");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_tag_with_missing_DataType()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "fwlib",
|
||||
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
|
||||
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
|
||||
}
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
|
||||
.Message.ShouldContain("DataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_null_or_whitespace_args_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_twice_throws()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.Register(registry));
|
||||
}
|
||||
}
|
||||
@@ -219,11 +219,11 @@ public sealed class FocasScaffoldingTests
|
||||
// ---- UnimplementedFocasClientFactory ----
|
||||
|
||||
[Fact]
|
||||
public void Default_factory_throws_on_Create_with_deployment_pointer()
|
||||
public void Unimplemented_factory_throws_on_Create_with_config_pointer()
|
||||
{
|
||||
var factory = new UnimplementedFocasClientFactory();
|
||||
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
|
||||
ex.Message.ShouldContain("Fwlib32.dll");
|
||||
ex.Message.ShouldContain("licensed");
|
||||
ex.Message.ShouldContain("wire");
|
||||
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the managed helpers inside FwlibNative + FwlibFocasClient that don't require the
|
||||
/// licensed Fwlib32.dll — letter→ADR_* mapping, FocasDataType→data-type mapping, byte encoding.
|
||||
/// The actual P/Invoke calls can only run where the DLL is present; field testing covers those.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FwlibNativeHelperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("G", 0)]
|
||||
[InlineData("F", 1)]
|
||||
[InlineData("Y", 2)]
|
||||
[InlineData("X", 3)]
|
||||
[InlineData("A", 4)]
|
||||
[InlineData("R", 5)]
|
||||
[InlineData("T", 6)]
|
||||
[InlineData("K", 7)]
|
||||
[InlineData("C", 8)]
|
||||
[InlineData("D", 9)]
|
||||
[InlineData("E", 10)]
|
||||
[InlineData("g", 0)] // case-insensitive
|
||||
public void PmcAddrType_maps_every_valid_letter(string letter, short expected)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z")]
|
||||
[InlineData("")]
|
||||
[InlineData("XX")]
|
||||
public void PmcAddrType_rejects_unknown_letters(string letter)
|
||||
{
|
||||
FocasPmcAddrType.FromLetter(letter).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasDataType.Bit, 0)] // byte
|
||||
[InlineData(FocasDataType.Byte, 0)]
|
||||
[InlineData(FocasDataType.Int16, 1)] // word
|
||||
[InlineData(FocasDataType.Int32, 2)] // long
|
||||
[InlineData(FocasDataType.Float32, 4)]
|
||||
[InlineData(FocasDataType.Float64, 5)]
|
||||
public void PmcDataType_maps_FocasDataType_to_FOCAS_code(FocasDataType input, short expected)
|
||||
{
|
||||
FocasPmcDataType.FromFocasDataType(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Byte_writes_signed_byte_at_offset_0()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Byte, (sbyte)-5, bitIndex: null);
|
||||
((sbyte)buf[0]).ShouldBe((sbyte)-5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int16_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int16, (short)0x1234, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x34);
|
||||
buf[1].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int32, 0x12345678, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0x78);
|
||||
buf[1].ShouldBe((byte)0x56);
|
||||
buf[2].ShouldBe((byte)0x34);
|
||||
buf[3].ShouldBe((byte)0x12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodePmcValue_Bit_without_bit_index_writes_byte_boolean()
|
||||
{
|
||||
// Task #181 closed the Bit-write gap — PMC Bit with a bitIndex now routes through
|
||||
// WritePmcBitAsync's RMW path upstream, and raw EncodePmcValue only gets the
|
||||
// no-bit-index case (treated as a whole-byte boolean).
|
||||
var buf = new byte[40];
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)1);
|
||||
|
||||
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, false, bitIndex: null);
|
||||
buf[0].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeParamValue_Int32_writes_little_endian()
|
||||
{
|
||||
var buf = new byte[32];
|
||||
FwlibFocasClient.EncodeParamValue(buf, FocasDataType.Int32, 0x0A0B0C0D);
|
||||
buf[0].ShouldBe((byte)0x0D);
|
||||
buf[1].ShouldBe((byte)0x0C);
|
||||
buf[2].ShouldBe((byte)0x0B);
|
||||
buf[3].ShouldBe((byte)0x0A);
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end IPC round-trips over an in-memory loopback: <c>IpcFocasClient</c> talks
|
||||
/// to a test fake that plays the Host's role by reading frames, dispatching on kind,
|
||||
/// and responding with canned DTOs. Validates that every <see cref="IFocasClient"/>
|
||||
/// method translates to the right wire frame + decodes the response correctly.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IpcFocasClientTests
|
||||
{
|
||||
private const string Secret = "test-secret";
|
||||
|
||||
private static async Task ServerLoopAsync(Stream serverSide, Func<FocasMessageKind, byte[], FrameWriter, Task> dispatch, CancellationToken ct)
|
||||
{
|
||||
using var reader = new FrameReader(serverSide, leaveOpen: true);
|
||||
using var writer = new FrameWriter(serverSide, leaveOpen: true);
|
||||
|
||||
// Hello handshake.
|
||||
var first = await reader.ReadFrameAsync(ct);
|
||||
if (first is null) return;
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
var accepted = hello.SharedSecret == Secret;
|
||||
await writer.WriteAsync(FocasMessageKind.HelloAck,
|
||||
new HelloAck { Accepted = accepted, RejectReason = accepted ? null : "wrong-secret" }, ct);
|
||||
if (!accepted) return;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(ct);
|
||||
if (frame is null) return;
|
||||
await dispatch(frame.Value.Kind, frame.Value.Body, writer);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_sends_OpenSessionRequest_and_caches_session_id()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
OpenSessionRequest? received = null;
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
if (kind == FocasMessageKind.OpenSessionRequest)
|
||||
{
|
||||
received = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 42 }, cts.Token);
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc, FocasCncSeries.Thirty_i);
|
||||
await client.ConnectAsync(new FocasHostAddress("192.168.1.50", 8193), TimeSpan.FromSeconds(2), cts.Token);
|
||||
|
||||
client.IsConnected.ShouldBeTrue();
|
||||
received.ShouldNotBeNull();
|
||||
received!.HostAddress.ShouldBe("192.168.1.50:8193");
|
||||
received.CncSeries.ShouldBe((int)FocasCncSeries.Thirty_i);
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_throws_when_host_rejects()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
if (kind == FocasMessageKind.OpenSessionRequest)
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = false, Error = "unreachable", ErrorCode = "EW_SOCKET" }, cts.Token);
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await client.ConnectAsync(new FocasHostAddress("10.0.0.1", 8193), TimeSpan.FromSeconds(1), cts.Token));
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_sends_ReadRequest_and_decodes_response()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
ReadRequest? received = null;
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.ReadRequest:
|
||||
received = MessagePackSerializer.Deserialize<ReadRequest>(body);
|
||||
await writer.WriteAsync(FocasMessageKind.ReadResponse,
|
||||
new ReadResponse
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = 0,
|
||||
ValueBytes = MessagePackSerializer.Serialize((int)12345),
|
||||
ValueTypeCode = FocasDataTypeCode.Int32,
|
||||
}, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
|
||||
var addr = new FocasAddress(FocasAreaKind.Parameter, null, 1815, null);
|
||||
var (value, status) = await client.ReadAsync(addr, FocasDataType.Int32, cts.Token);
|
||||
status.ShouldBe(0u);
|
||||
value.ShouldBe(12345);
|
||||
received!.Address.Number.ShouldBe(1815);
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_sends_WriteRequest_and_returns_status()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.WriteRequest:
|
||||
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
|
||||
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14);
|
||||
await writer.WriteAsync(FocasMessageKind.WriteResponse,
|
||||
new WriteResponse { Success = true, StatusCode = 0 }, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
|
||||
var status = await client.WriteAsync(new FocasAddress(FocasAreaKind.Macro, null, 500, null),
|
||||
FocasDataType.Float64, 3.14, cts.Token);
|
||||
status.ShouldBe(0u);
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_pmc_bit_sends_first_class_RMW_frame()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
PmcBitWriteRequest? received = null;
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.PmcBitWriteRequest:
|
||||
received = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
|
||||
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse,
|
||||
new PmcBitWriteResponse { Success = true, StatusCode = 0 }, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
|
||||
var addr = new FocasAddress(FocasAreaKind.Pmc, "R", 100, BitIndex: 5);
|
||||
var status = await client.WriteAsync(addr, FocasDataType.Bit, true, cts.Token);
|
||||
status.ShouldBe(0u);
|
||||
received.ShouldNotBeNull();
|
||||
received!.BitIndex.ShouldBe(5);
|
||||
received.Value.ShouldBeTrue();
|
||||
received.Address.PmcLetter.ShouldBe("R");
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_round_trips_health_from_host()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||
break;
|
||||
case FocasMessageKind.ProbeRequest:
|
||||
await writer.WriteAsync(FocasMessageKind.ProbeResponse,
|
||||
new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 }, cts.Token);
|
||||
break;
|
||||
}
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||
(await client.ProbeAsync(cts.Token)).ShouldBeTrue();
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Error_response_from_host_surfaces_as_FocasIpcException()
|
||||
{
|
||||
await using var loop = new IpcLoopback();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = "backend-exception", Message = "simulated" }, cts.Token);
|
||||
}, cts.Token));
|
||||
|
||||
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||
var client = new IpcFocasClient(ipc);
|
||||
var ex = await Should.ThrowAsync<FocasIpcException>(async () =>
|
||||
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token));
|
||||
ex.Code.ShouldBe("backend-exception");
|
||||
|
||||
cts.Cancel();
|
||||
try { await server; } catch { }
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System.IO.Pipelines;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bidirectional in-memory stream pair for IPC tests. Two <c>System.IO.Pipelines.Pipe</c>
|
||||
/// instances — one per direction — exposed as <see cref="System.IO.Stream"/> endpoints
|
||||
/// via <c>PipeReader.AsStream</c> / <c>PipeWriter.AsStream</c>. Lets the test set up a
|
||||
/// <c>FocasIpcClient</c> on one end and a minimal fake server loop on the other without
|
||||
/// standing up a real named pipe.
|
||||
/// </summary>
|
||||
internal sealed class IpcLoopback : IAsyncDisposable
|
||||
{
|
||||
public Stream ClientSide { get; }
|
||||
public Stream ServerSide { get; }
|
||||
|
||||
public IpcLoopback()
|
||||
{
|
||||
var clientToServer = new Pipe();
|
||||
var serverToClient = new Pipe();
|
||||
|
||||
ClientSide = new DuplexPipeStream(serverToClient.Reader.AsStream(), clientToServer.Writer.AsStream());
|
||||
ServerSide = new DuplexPipeStream(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream());
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ClientSide.DisposeAsync();
|
||||
await ServerSide.DisposeAsync();
|
||||
}
|
||||
|
||||
private sealed class DuplexPipeStream(Stream read, Stream write) : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
public override bool CanWrite => true;
|
||||
public override bool CanSeek => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => read.Read(buffer, offset, count);
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
|
||||
read.ReadAsync(buffer, offset, count, ct);
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) =>
|
||||
read.ReadAsync(buffer, ct);
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => write.Write(buffer, offset, count);
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
|
||||
write.WriteAsync(buffer, offset, count, ct);
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default) =>
|
||||
write.WriteAsync(buffer, ct);
|
||||
|
||||
public override void Flush() => write.Flush();
|
||||
public override Task FlushAsync(CancellationToken ct) => write.FlushAsync(ct);
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
read.Dispose();
|
||||
write.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Proxy-side <see cref="PostMortemReader"/> must read the Host's MMF format
|
||||
/// (magic 'OFPC', 256-byte entries). This test writes a hand-crafted file that mimics
|
||||
/// the Host's layout exactly + asserts the reader decodes it correctly. Keeps the two
|
||||
/// codebases in lockstep on the wire format without needing to reference the net48
|
||||
/// Host assembly from the net10 test project.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PostMortemReaderCompatibilityTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-compat-{Guid.NewGuid():N}.bin");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_tempPath)) File.Delete(_tempPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reader_parses_host_format_and_returns_entries_in_oldest_first_order()
|
||||
{
|
||||
const int magic = 0x4F465043;
|
||||
const int capacity = 5;
|
||||
const int headerBytes = 16;
|
||||
const int entryBytes = 256;
|
||||
const int messageOffset = 16;
|
||||
var fileBytes = headerBytes + capacity * entryBytes;
|
||||
|
||||
using (var fs = new FileStream(_tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read))
|
||||
{
|
||||
fs.SetLength(fileBytes);
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||
using var acc = mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||
acc.Write(0, magic);
|
||||
acc.Write(4, 1);
|
||||
acc.Write(8, capacity);
|
||||
acc.Write(12, 2); // writeIndex — next write would land at slot 2
|
||||
|
||||
void WriteEntry(int slot, long ts, long op, string msg)
|
||||
{
|
||||
var offset = headerBytes + slot * entryBytes;
|
||||
acc.Write(offset + 0, ts);
|
||||
acc.Write(offset + 8, op);
|
||||
var bytes = Encoding.UTF8.GetBytes(msg);
|
||||
acc.WriteArray(offset + messageOffset, bytes, 0, bytes.Length);
|
||||
acc.Write(offset + messageOffset + bytes.Length, (byte)0);
|
||||
}
|
||||
|
||||
WriteEntry(0, 100, 1, "op-a");
|
||||
WriteEntry(1, 200, 2, "op-b");
|
||||
// Slots 2,3 unwritten (ts=0) — reader must skip.
|
||||
WriteEntry(4, 50, 9, "old-wrapped");
|
||||
}
|
||||
|
||||
var entries = new PostMortemReader(_tempPath).ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
// writeIndex=2 means the ring walk starts at slot 2, so iteration order is 2→3→4→0→1.
|
||||
// Slots 2 and 3 are empty; 4 yields "old-wrapped"; then 0="op-a", 1="op-b".
|
||||
entries[0].Message.ShouldBe("old-wrapped");
|
||||
entries[1].Message.ShouldBe("op-a");
|
||||
entries[2].Message.ShouldBe("op-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reader_returns_empty_when_file_missing()
|
||||
{
|
||||
new PostMortemReader(_tempPath + "-does-not-exist").ReadAll().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reader_returns_empty_when_magic_mismatches()
|
||||
{
|
||||
File.WriteAllBytes(_tempPath, new byte[1024]);
|
||||
new PostMortemReader(_tempPath).ReadAll().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BackoffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_sequence_is_5s_15s_60s_then_clamped()
|
||||
{
|
||||
var b = new Backoff();
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordStableRun_resets_the_ladder_to_the_start()
|
||||
{
|
||||
var b = new Backoff();
|
||||
b.Next(); b.Next();
|
||||
b.AttemptIndex.ShouldBe(2);
|
||||
b.RecordStableRun();
|
||||
b.AttemptIndex.ShouldBe(0);
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CircuitBreakerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Allows_crashes_below_threshold()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var now = DateTime.UtcNow;
|
||||
b.TryRecordCrash(now, out _).ShouldBeTrue();
|
||||
b.TryRecordCrash(now.AddSeconds(1), out _).ShouldBeTrue();
|
||||
b.TryRecordCrash(now.AddSeconds(2), out _).ShouldBeTrue();
|
||||
b.StickyAlertActive.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opens_when_exceeding_threshold_in_window()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var now = DateTime.UtcNow;
|
||||
b.TryRecordCrash(now, out _);
|
||||
b.TryRecordCrash(now.AddSeconds(1), out _);
|
||||
b.TryRecordCrash(now.AddSeconds(2), out _);
|
||||
b.TryRecordCrash(now.AddSeconds(3), out var cooldown).ShouldBeFalse();
|
||||
cooldown.ShouldBe(TimeSpan.FromHours(1));
|
||||
b.StickyAlertActive.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escalates_cooldown_after_second_open()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
// First burst — 4 crashes opens breaker with 1h cooldown.
|
||||
for (var i = 0; i < 4; i++) b.TryRecordCrash(t0.AddSeconds(i), out _);
|
||||
b.StickyAlertActive.ShouldBeTrue();
|
||||
|
||||
// Wait past cooldown. The first crash after cooldown-elapsed resets _openSinceUtc and
|
||||
// bumps escalation level; the next 3 crashes then re-open with the escalated 4h cooldown.
|
||||
b.TryRecordCrash(t0.AddHours(1).AddMinutes(1), out _);
|
||||
var t1 = t0.AddHours(1).AddMinutes(1).AddSeconds(1);
|
||||
b.TryRecordCrash(t1, out _);
|
||||
b.TryRecordCrash(t1.AddSeconds(1), out _);
|
||||
b.TryRecordCrash(t1.AddSeconds(2), out var cooldown).ShouldBeFalse();
|
||||
cooldown.ShouldBe(TimeSpan.FromHours(4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualReset_clears_everything()
|
||||
{
|
||||
var b = new CircuitBreaker();
|
||||
var now = DateTime.UtcNow;
|
||||
for (var i = 0; i < 5; i++) b.TryRecordCrash(now.AddSeconds(i), out _);
|
||||
b.StickyAlertActive.ShouldBeTrue();
|
||||
b.ManualReset();
|
||||
b.StickyAlertActive.ShouldBeFalse();
|
||||
b.TryRecordCrash(now.AddSeconds(10), out _).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeartbeatMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Three_consecutive_misses_declares_dead()
|
||||
{
|
||||
var m = new HeartbeatMonitor();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ack_resets_the_miss_counter()
|
||||
{
|
||||
var m = new HeartbeatMonitor();
|
||||
m.RecordMiss(); m.RecordMiss();
|
||||
m.ConsecutiveMisses.ShouldBe(2);
|
||||
m.RecordAck(DateTime.UtcNow);
|
||||
m.ConsecutiveMisses.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasHostSupervisorTests
|
||||
{
|
||||
private sealed class FakeLauncher : IHostProcessLauncher
|
||||
{
|
||||
public int LaunchAttempts { get; private set; }
|
||||
public int Terminations { get; private set; }
|
||||
public Queue<Func<IFocasClient>> Plan { get; } = new();
|
||||
public bool IsProcessAlive { get; set; }
|
||||
|
||||
public Task<IFocasClient> LaunchAsync(CancellationToken ct)
|
||||
{
|
||||
LaunchAttempts++;
|
||||
if (Plan.Count == 0) throw new InvalidOperationException("FakeLauncher plan exhausted");
|
||||
var next = Plan.Dequeue()();
|
||||
IsProcessAlive = true;
|
||||
return Task.FromResult(next);
|
||||
}
|
||||
|
||||
public Task TerminateAsync(CancellationToken ct)
|
||||
{
|
||||
Terminations++;
|
||||
IsProcessAlive = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubFocasClient : IFocasClient
|
||||
{
|
||||
public bool IsConnected => true;
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct) =>
|
||||
Task.FromResult<(object?, uint)>((0, 0));
|
||||
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => Task.FromResult(0u);
|
||||
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrLaunch_returns_client_on_first_success()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
var supervisor = new FocasHostSupervisor(launcher);
|
||||
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
client.ShouldNotBeNull();
|
||||
launcher.LaunchAttempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrLaunch_retries_after_transient_failure_with_backoff()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => throw new TimeoutException("pipe not ready"));
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
|
||||
var backoff = new Backoff([TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20)]);
|
||||
var supervisor = new FocasHostSupervisor(launcher, backoff);
|
||||
|
||||
var unavailableMessages = new List<string>();
|
||||
supervisor.OnUnavailable += m => unavailableMessages.Add(m);
|
||||
|
||||
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
client.ShouldNotBeNull();
|
||||
launcher.LaunchAttempts.ShouldBe(2);
|
||||
unavailableMessages.Count.ShouldBe(1);
|
||||
unavailableMessages[0].ShouldContain("launch-failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_launch_failures_open_breaker_and_surface_InvalidOperation()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
for (var i = 0; i < 10; i++)
|
||||
launcher.Plan.Enqueue(() => throw new InvalidOperationException("simulated host refused"));
|
||||
|
||||
var supervisor = new FocasHostSupervisor(
|
||||
launcher,
|
||||
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
||||
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 2, Window = TimeSpan.FromMinutes(5) });
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("circuit breaker");
|
||||
supervisor.StickyAlertActive.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyHostDeadAsync_terminates_current_and_fans_out_unavailable()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
var supervisor = new FocasHostSupervisor(launcher);
|
||||
|
||||
var messages = new List<string>();
|
||||
supervisor.OnUnavailable += m => messages.Add(m);
|
||||
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await supervisor.NotifyHostDeadAsync("heartbeat-loss", TestContext.Current.CancellationToken);
|
||||
|
||||
launcher.Terminations.ShouldBe(1);
|
||||
messages.ShouldContain("heartbeat-loss");
|
||||
supervisor.ObservedCrashes.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAndReset_clears_sticky_alert()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
for (var i = 0; i < 10; i++)
|
||||
launcher.Plan.Enqueue(() => throw new InvalidOperationException("refused"));
|
||||
var supervisor = new FocasHostSupervisor(
|
||||
launcher,
|
||||
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
||||
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 1 });
|
||||
|
||||
try { await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken); } catch { }
|
||||
supervisor.StickyAlertActive.ShouldBeTrue();
|
||||
|
||||
supervisor.AcknowledgeAndReset();
|
||||
supervisor.StickyAlertActive.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_terminates_host_process()
|
||||
{
|
||||
var launcher = new FakeLauncher();
|
||||
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||
var supervisor = new FocasHostSupervisor(launcher);
|
||||
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
supervisor.Dispose();
|
||||
launcher.Terminations.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user