#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 — stress stage that opens + closes 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 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 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 }