Task #253 — E2E CLI test scripts + FOCAS test-client CLI

The driver-layer integration tests confirm the driver sees the PLC, and
the Client.CLI tests confirm the client sees the server. Nothing glued
them end-to-end until this PR.

- scripts/e2e/_common.ps1: shared helpers — CLI invocation (published-
  binary OR `dotnet run` fallback), Test-Probe / Test-DriverLoopback /
  Test-ServerBridge (all return @{Passed;Reason} hashtables).
- scripts/e2e/test-<modbus|abcip|ablegacy|s7|focas|twincat>.ps1: per-
  driver three-stage script (probe → driver-loopback → server-bridge).
  AB Legacy / FOCAS / TwinCAT are gated behind *_TRUST_WIRE env vars
  since they need real hardware (#222) or a licensed runtime (#221).
- scripts/e2e/test-phase7-virtualtags.ps1: writes a Modbus HR, reads
  the server-side VirtualTag (VT = input * 2) back via OPC UA, triggers
  + clears a scripted alarm. Exercises the Phase 7 CachedTagUpstreamSource
  + ScriptedAlarmEngine path.
- scripts/e2e/test-all.ps1: reads e2e-config.json sidecar, runs each
  present driver, prints a FINAL MATRIX (PASS/FAIL/SKIP). Missing
  sections SKIP rather than fail hard.
- scripts/e2e/e2e-config.sample.json: commented sample — each dev's
  NodeIds are local-seed-specific so e2e-config.json is .gitignore-d.
- scripts/e2e/README.md: full walkthrough — prereqs, three-stage design,
  env-var gates, expected matrix, why this is separate from `dotnet test`.

Tasks #249-#251 shipped Modbus/AbCip/AbLegacy/S7/TwinCAT CLIs but left
FOCAS out. Since test-focas.ps1 needs it, the 6th CLI ships here:

- src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli: probe/read/write/subscribe
  commands, AssemblyName `otopcua-focas-cli`. WriteCommand.ParseValue
  handles the full FocasDataType enum (Bit/Byte/Int16/Int32/Float32/
  Float64/String — no UInt variants; the FOCAS protocol exposes signed
  PMC + Fanuc-Float only). Default DataType is Int16 to match the PMC
  register convention.

Full-solution build clean (0 errors). FOCAS CLI wired into
ZB.MOM.WW.OtOpcUa.slnx. No .Tests project for the FOCAS CLI yet —
symmetric with how ProbeCommand has no unit-testable pure logic in the
other 5 CLIs either; WriteCommand.ParseValue parity will land in a
follow-up to keep this PR scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-21 09:51:13 -04:00
parent 1507486b45
commit 8d92e00e38
20 changed files with 1599 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. No public
/// simulator exists — this command only produces meaningful results against a real
/// CNC with Fwlib32.dll present. Against a dev box it surfaces
/// <c>BadCommunicationError</c> (DLL missing) which is still a useful signal that
/// the CLI wire-up is correct.
/// </summary>
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
public sealed class ProbeCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description =
"FOCAS address to probe (default R100 — PMC R-file register 100).")]
public string Address { get; init; } = "R100";
[CommandOption("type", Description = "Data type (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var probeTag = new FocasTagDefinition(
Name: "__probe",
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([probeTag]);
await using var driver = new FocasDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"CNC: {CncHost}:{CncPort}");
await console.Output.WriteLineAsync($"Series: {Series}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
}

View File

@@ -0,0 +1,52 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Read one FOCAS address (PMC R/G/F file, parameter, macro, axis register).
/// </summary>
[Command("read", Description = "Read a single FOCAS address.")]
public sealed class ReadCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description =
"FOCAS address. Examples: R100 (PMC R-file word); X0.0 (PMC X-bit); " +
"PARAM:1815/0 (parameter 1815, axis 0); MACRO:500 (macro variable 500).",
IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Address, DataType);
var tag = new FocasTagDefinition(
Name: tagName,
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new FocasDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
internal static string SynthesiseTagName(string address, FocasDataType type)
=> $"{address}:{type}";
}

View File

@@ -0,0 +1,76 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Watch a FOCAS address via polled subscription until Ctrl+C. FOCAS has no push
/// model; <c>PollGroupEngine</c> handles the tick loop.
/// </summary>
[Command("subscribe", Description = "Watch a FOCAS address via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
public int IntervalMs { get; init; } = 1000;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
var tag = new FocasTagDefinition(
Name: tagName,
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new FocasDriver(options, DriverInstanceId);
ISubscriptionHandle? handle = null;
try
{
await driver.InitializeAsync("{}", ct);
driver.OnDataChange += (_, e) =>
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
console.Output.WriteLine(line);
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
await console.Output.WriteLineAsync(
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
}
catch (OperationCanceledException)
{
// Expected on Ctrl+C.
}
}
finally
{
if (handle is not null)
{
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
catch { /* teardown best-effort */ }
}
await driver.ShutdownAsync(CancellationToken.None);
}
}
}

View File

@@ -0,0 +1,77 @@
using System.Globalization;
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Write one value to a FOCAS address. PMC G/R writes are real — be careful
/// which file you hit on a running machine. Parameter writes may require the
/// CNC to be in MDI mode + the parameter-write switch enabled.
/// </summary>
[Command("write", Description = "Write a single FOCAS address.")]
public sealed class WriteCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
var tag = new FocasTagDefinition(
Name: tagName,
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: true);
var options = BuildOptions([tag]);
var parsed = ParseValue(Value, DataType);
await using var driver = new FocasDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
internal static object ParseValue(string raw, FocasDataType type) => type switch
{
FocasDataType.Bit => ParseBool(raw),
FocasDataType.Byte => sbyte.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.String => raw,
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
};
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
{
"1" or "true" or "on" or "yes" => true,
"0" or "false" or "off" or "no" => false,
_ => throw new CliFx.Exceptions.CommandException(
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
};
}

View File

@@ -0,0 +1,58 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
/// <summary>
/// Base for every FOCAS CLI command. Carries the CNC endpoint options
/// (host / port / series) + exposes <see cref="BuildOptions"/> so each command
/// can synthesise a <see cref="FocasDriverOptions"/> with one device + one tag.
/// </summary>
public abstract class FocasCommandBase : DriverCommandBase
{
[CommandOption("cnc-host", 'h', Description =
"CNC IP address or hostname. FOCAS-over-EIP listens on port 8193 by default.",
IsRequired = true)]
public string CncHost { get; init; } = default!;
[CommandOption("cnc-port", 'p', Description = "FOCAS TCP port (default 8193).")]
public int CncPort { get; init; } = 8193;
[CommandOption("series", 's', Description =
"CNC series: Unknown / Zero_i_D / Zero_i_F / Zero_i_MF / Zero_i_TF / Sixteen_i / " +
"Thirty_i / ThirtyOne_i / ThirtyTwo_i / PowerMotion_i (default Unknown).")]
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
public int TimeoutMs { get; init; } = 2000;
/// <inheritdoc />
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
init { /* driven by TimeoutMs */ }
}
/// <summary>Canonical FOCAS host-address string, shape <c>focas://host:port</c>.</summary>
protected string HostAddress => $"focas://{CncHost}:{CncPort}";
/// <summary>
/// Build a <see cref="FocasDriverOptions"/> with the CNC target this base collected
/// + the tag list a subclass supplies. Probe disabled; the default
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
/// surfaced through the driver as <c>BadCommunicationError</c>.
/// </summary>
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
{
Devices = [new FocasDeviceOptions(
HostAddress: HostAddress,
DeviceName: $"cli-{CncHost}:{CncPort}",
Series: Series)],
Tags = tags,
Timeout = Timeout,
Probe = new FocasProbeOptions { Enabled = false },
};
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
}

View File

@@ -0,0 +1,12 @@
using CliFx;
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName("otopcua-focas-cli")
.SetDescription(
"OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads/writes + polled " +
"subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Requires a real CNC + a " +
"licensed Fwlib32.dll on PATH (or next to the executable) — no public simulator " +
"exists. Addresses use FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
.Build()
.RunAsync(args);

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli</RootNamespace>
<AssemblyName>otopcua-focas-cli</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.6"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
</Project>