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>
221 lines
8.1 KiB
PowerShell
221 lines
8.1 KiB
PowerShell
#Requires -Version 7.0
|
||
<#
|
||
.SYNOPSIS
|
||
End-to-end CLI test for the FOCAS (Fanuc CNC) driver.
|
||
|
||
.DESCRIPTION
|
||
Runs the CLI against either the managed wire client (default — Driver.FOCAS.Cli
|
||
dials the CNC on TCP:8193 directly, no native dependencies) or the focas-mock
|
||
Docker fixture. Hardware-gated by default because the default CncHost is
|
||
127.0.0.1; set FOCAS_TRUST_WIRE=1 once -CncHost points at a real CNC, or pass
|
||
-ProfileName to run against the Docker sim.
|
||
|
||
The script also supports three nice-to-have modes shipped 2026-04-24:
|
||
|
||
-Series — per-series matrix mode. Accepts a comma-separated list; the
|
||
core stages are run once per series, swapping the -Address to
|
||
the supplied per-series probe. Fails fast if any series's
|
||
configured address is outside the documented range (the driver
|
||
itself enforces that at InitializeAsync).
|
||
|
||
-ProfileName — for use with the Python Docker simulator (see
|
||
docs/v2/implementation/focas-simulator-plan.md). Selects a
|
||
docker-compose profile + matching -Series. When set, the
|
||
FOCAS_TRUST_WIRE gate is considered satisfied because the sim
|
||
is a legitimate non-hardware target.
|
||
|
||
-HandleLeakCycles <int> — stress stage that opens + closes <N> sessions
|
||
via the CLI's `probe` command with a short sleep between
|
||
cycles. Exercises the Tier-C supervisor's handle-recycle path
|
||
without touching user data. Typical values: 100–1000. A CNC's
|
||
FWLIB handle pool is finite (~5–10), so this shakes out
|
||
handle-leak bugs if either side forgets to free.
|
||
|
||
.PARAMETER CncHost
|
||
IP or hostname of the CNC. Default 127.0.0.1 — override for real runs.
|
||
|
||
.PARAMETER CncPort
|
||
FOCAS TCP port. Default 8193.
|
||
|
||
.PARAMETER Address
|
||
FOCAS address to exercise. Default R100 (PMC R-file register). Ignored
|
||
when -Series is set and the series profile supplies its own probe.
|
||
|
||
.PARAMETER Series
|
||
Comma-separated list of CNC series to run the matrix against. Known:
|
||
ZeroI_D, ZeroI_F, ZeroI_MF, ZeroI_TF, Sixteen_i, Thirty_i, ThirtyOne_i,
|
||
ThirtyTwo_i, PowerMotion_i. When empty the script runs a single pass
|
||
without a series constraint.
|
||
|
||
.PARAMETER ProfileName
|
||
docker-compose profile name from tests/.../Docker/profiles/. When set,
|
||
the script assumes the Python simulator is the target + un-gates
|
||
FOCAS_TRUST_WIRE.
|
||
|
||
.PARAMETER HandleLeakCycles
|
||
Run a handle-leak stress stage with <N> open/close cycles. 0 = skip.
|
||
|
||
.PARAMETER OpcUaUrl
|
||
OtOpcUa server endpoint.
|
||
|
||
.PARAMETER BridgeNodeId
|
||
NodeId at which the server publishes the Address.
|
||
#>
|
||
|
||
param(
|
||
[string]$CncHost = "127.0.0.1",
|
||
[int]$CncPort = 8193,
|
||
[string]$Address = "R100",
|
||
[string]$Series = "",
|
||
[string]$ProfileName = "",
|
||
[int]$HandleLeakCycles = 0,
|
||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||
)
|
||
|
||
$ErrorActionPreference = "Stop"
|
||
. "$PSScriptRoot/_common.ps1"
|
||
|
||
$simGated = -not [string]::IsNullOrWhiteSpace($ProfileName)
|
||
if (-not $simGated -and -not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
|
||
Write-Skip "FOCAS_TRUST_WIRE not set. Pass -ProfileName <profile> to run against the Docker mock in tests/.../Driver.FOCAS.IntegrationTests/Docker/, or set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC."
|
||
exit 0
|
||
}
|
||
|
||
if ($simGated) {
|
||
Write-Info "Sim mode — profile '$ProfileName'. FOCAS_TRUST_WIRE gate bypassed."
|
||
}
|
||
|
||
# Per-series probe addresses — each one is inside the authoritative range for
|
||
# that series (docs/v2/focas-version-matrix.md). Picking one representative per
|
||
# kind (PMC / parameter / macro) is enough to exercise the driver's validator.
|
||
$seriesProbes = @{
|
||
"ZeroI_D" = "R100"
|
||
"ZeroI_F" = "R100"
|
||
"ZeroI_MF" = "R100"
|
||
"ZeroI_TF" = "R100"
|
||
"Sixteen_i" = "R100"
|
||
"Thirty_i" = "R100"
|
||
"ThirtyOne_i" = "R100"
|
||
"ThirtyTwo_i" = "R100"
|
||
"PowerMotion_i"= "R100"
|
||
}
|
||
|
||
$seriesList = @()
|
||
if (-not [string]::IsNullOrWhiteSpace($Series)) {
|
||
$seriesList = @($Series.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||
$unknown = @($seriesList | Where-Object { -not $seriesProbes.ContainsKey($_) })
|
||
if ($unknown.Count -gt 0) {
|
||
Write-Fail "Unknown -Series entries: $($unknown -join ', '). Known: $($seriesProbes.Keys -join ', ')."
|
||
exit 2
|
||
}
|
||
}
|
||
|
||
$focasCli = Get-CliInvocation `
|
||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" `
|
||
-ExeName "otopcua-focas-cli"
|
||
$opcUaCli = Get-CliInvocation `
|
||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||
-ExeName "otopcua-cli"
|
||
|
||
$allResults = @()
|
||
|
||
function Invoke-FocasCore {
|
||
param(
|
||
[string]$Label,
|
||
[string]$ProbeAddress
|
||
)
|
||
|
||
Write-Header "FOCAS stages — $Label"
|
||
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
||
$results = @()
|
||
|
||
$results += Test-Probe `
|
||
-Cli $focasCli `
|
||
-ProbeArgs (@("probe") + $commonFocas + @("-a", $ProbeAddress, "--type", "Int16"))
|
||
|
||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||
$results += Test-DriverLoopback `
|
||
-Cli $focasCli `
|
||
-WriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $writeValue)) `
|
||
-ReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
|
||
-ExpectedValue "$writeValue"
|
||
|
||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||
$results += Test-ServerBridge `
|
||
-DriverCli $focasCli `
|
||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $bridgeValue)) `
|
||
-OpcUaCli $opcUaCli `
|
||
-OpcUaUrl $OpcUaUrl `
|
||
-OpcUaNodeId $BridgeNodeId `
|
||
-ExpectedValue "$bridgeValue"
|
||
|
||
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||
$results += Test-OpcUaWriteBridge `
|
||
-OpcUaCli $opcUaCli `
|
||
-OpcUaUrl $OpcUaUrl `
|
||
-OpcUaNodeId $BridgeNodeId `
|
||
-DriverCli $focasCli `
|
||
-DriverReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
|
||
-ExpectedValue "$reverseValue"
|
||
|
||
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||
$results += Test-SubscribeSeesChange `
|
||
-OpcUaCli $opcUaCli `
|
||
-OpcUaUrl $OpcUaUrl `
|
||
-OpcUaNodeId $BridgeNodeId `
|
||
-DriverCli $focasCli `
|
||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $subValue)) `
|
||
-ExpectedValue "$subValue"
|
||
|
||
return $results
|
||
}
|
||
|
||
function Invoke-HandleLeakStage {
|
||
param([int]$Cycles)
|
||
|
||
Write-Header "FOCAS handle-leak stress — $Cycles cycles"
|
||
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
||
$failed = 0
|
||
for ($i = 1; $i -le $Cycles; $i++) {
|
||
$probe = Test-Probe `
|
||
-Cli $focasCli `
|
||
-ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
|
||
if (-not $probe.Passed) {
|
||
$failed++
|
||
# First 3 failures are informative; the rest just tally.
|
||
if ($failed -le 3) {
|
||
Write-Fail "cycle $i failed: $($probe.Reason)"
|
||
}
|
||
}
|
||
# Tiny delay so a broken loop can't DDoS the CNC; FWLIB handles take a
|
||
# few tens of ms to recycle in practice.
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
$passed = $Cycles - $failed
|
||
if ($failed -eq 0) {
|
||
Write-Pass "handle-leak stress: $passed/$Cycles cycles succeeded"
|
||
return @{ Passed = $true; Reason = "$passed/$Cycles" }
|
||
} else {
|
||
Write-Fail "handle-leak stress: $failed/$Cycles cycles failed"
|
||
return @{ Passed = $false; Reason = "$failed/$Cycles failed" }
|
||
}
|
||
}
|
||
|
||
if ($seriesList.Count -eq 0) {
|
||
$allResults += Invoke-FocasCore -Label "single" -ProbeAddress $Address
|
||
} else {
|
||
foreach ($series in $seriesList) {
|
||
$probeAddr = $seriesProbes[$series]
|
||
Write-Info "Running matrix pass for series '$series' with address $probeAddr"
|
||
$allResults += Invoke-FocasCore -Label $series -ProbeAddress $probeAddr
|
||
}
|
||
}
|
||
|
||
if ($HandleLeakCycles -gt 0) {
|
||
$allResults += Invoke-HandleLeakStage -Cycles $HandleLeakCycles
|
||
}
|
||
|
||
Write-Summary -Title "FOCAS e2e" -Results $allResults
|
||
if ($allResults | Where-Object { -not $_.Passed }) { exit 1 }
|