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>
77 lines
2.8 KiB
C#
77 lines
2.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|