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>
193 lines
8.4 KiB
C#
193 lines
8.4 KiB
C#
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";
|
|
}
|