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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
};
|
||||
}
|
||||
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal file
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal 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}";
|
||||
}
|
||||
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal file
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal 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);
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user