chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every AB CIP CLI command. Carries the libplctag endpoint options
|
||||
/// (<c>--gateway</c> + <c>--family</c>) and exposes <see cref="BuildOptions"/> so each
|
||||
/// command can synthesise an <see cref="AbCipDriverOptions"/> from CLI flags + its own
|
||||
/// tag list.
|
||||
/// </summary>
|
||||
public abstract class AbCipCommandBase : DriverCommandBase
|
||||
{
|
||||
[CommandOption("gateway", 'g', Description =
|
||||
"Canonical AB CIP gateway: ab://host[:port]/cip-path. Port defaults to 44818 " +
|
||||
"(EtherNet/IP). cip-path is family-specific: ControlLogix / CompactLogix need " +
|
||||
"'1,0' to reach slot 0 of the CPU chassis; Micro800 takes an empty path; " +
|
||||
"GuardLogix typically '1,0' same as ControlLogix.",
|
||||
IsRequired = true)]
|
||||
public string Gateway { get; init; } = default!;
|
||||
|
||||
[CommandOption("family", 'f', Description =
|
||||
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
|
||||
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
|
||||
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AbCipDriverOptions"/> with the device + tag list a subclass
|
||||
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
|
||||
/// probe loop would race the operator's own reads.
|
||||
/// </summary>
|
||||
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(
|
||||
HostAddress: Gateway,
|
||||
PlcFamily: Family,
|
||||
DeviceName: $"cli-{Family}")],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = false,
|
||||
EnableAlarmProjection = false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Short instance id used in Serilog output so operators running the CLI against
|
||||
/// multiple gateways in parallel can distinguish the logs.
|
||||
/// </summary>
|
||||
protected string DriverInstanceId => $"abcip-cli-{Gateway}";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes an AB CIP gateway: initialises the driver (connects via libplctag), reads a
|
||||
/// single tag, and prints health + the read result. Fastest way to answer "is the PLC
|
||||
/// up + reachable + speaking CIP via this path?".
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the AB CIP gateway is reachable and a sample tag reads.")]
|
||||
public sealed class ProbeCommand : AbCipCommandBase
|
||||
{
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Tag path to probe. ControlLogix default is '@raw_cpu_type' (the canonical libplctag " +
|
||||
"system tag); Micro800 takes a user-supplied global (e.g. '_SYSVA_CLOCK_HOUR').",
|
||||
IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", Description =
|
||||
"Logix atomic type of the probe tag (default DInt).")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var probeTag = new AbCipTagDefinition(
|
||||
Name: "__probe",
|
||||
DeviceHostAddress: Gateway,
|
||||
TagPath: TagPath,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||
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(TagPath, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Read one Logix tag by symbolic path. Operator specifies <c>--tag</c> + <c>--type</c>;
|
||||
/// the CLI synthesises a one-tag driver config, reads once, prints the snapshot, shuts
|
||||
/// down. UDT / Structure reads are out of scope here — those need the member layout
|
||||
/// declared, which belongs in a real driver config.
|
||||
/// </summary>
|
||||
[Command("read", Description = "Read a single Logix tag by symbolic path.")]
|
||||
public sealed class ReadCommand : AbCipCommandBase
|
||||
{
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Logix symbolic path. Controller scope: 'Motor01_Speed'. Program scope: " +
|
||||
"'Program:Main.Motor01_Speed'. Array element: 'Recipe[3]'. UDT member: " +
|
||||
"'Motor01.Speed'.", IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", Description =
|
||||
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||
"String / Dt / Structure (default DInt).")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(TagPath, DataType);
|
||||
var tag = new AbCipTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
TagPath: TagPath,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag-name key the driver uses internally. The path + type pair is already unique
|
||||
/// so we use them verbatim — keeps tag-level diagnostics readable without mangling.
|
||||
/// </summary>
|
||||
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
|
||||
=> $"{tagPath}:{type}";
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Watch a Logix tag via polled subscription until Ctrl+C. Uses the driver's
|
||||
/// <c>ISubscribable</c> surface (PollGroupEngine under the hood). Prints each change
|
||||
/// event with an HH:mm:ss.fff timestamp.
|
||||
/// </summary>
|
||||
[Command("subscribe", Description = "Watch a Logix tag via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : AbCipCommandBase
|
||||
{
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", Description =
|
||||
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||
"String / Dt (default DInt).")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
[CommandOption("interval-ms", 'i', Description =
|
||||
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
|
||||
"sub-250ms values.")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
||||
var tag = new AbCipTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
TagPath: TagPath,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new AbCipDriver(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 {TagPath} @ {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,94 @@
|
||||
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.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to a Logix tag by symbolic path. Mirrors <see cref="ReadCommand"/>'s
|
||||
/// flag shape + adds <c>--value</c>. Value parsing respects <c>--type</c> so you can
|
||||
/// write <c>--value 3.14 --type Real</c> without hex-encoding. GuardLogix safety tags
|
||||
/// are refused at the driver level (they're forced to ViewOnly by PR 12).
|
||||
/// </summary>
|
||||
[Command("write", Description = "Write a single Logix tag by symbolic path.")]
|
||||
public sealed class WriteCommand : AbCipCommandBase
|
||||
{
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", Description =
|
||||
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||
"String / Dt (default DInt).")]
|
||||
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||
|
||||
[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();
|
||||
|
||||
if (DataType == AbCipDataType.Structure)
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
|
||||
"config JSON for those. The CLI covers atomic types only.");
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
||||
var tag = new AbCipTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
TagPath: TagPath,
|
||||
DataType: DataType,
|
||||
Writable: true);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||
/// for the declared <see cref="AbCipDataType"/>. Invariant culture everywhere.
|
||||
/// </summary>
|
||||
internal static object ParseValue(string raw, AbCipDataType type) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => ParseBool(raw),
|
||||
AbCipDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.DInt or AbCipDataType.Dt => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbCipDataType.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."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-abcip-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
|
||||
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
|
||||
"via libplctag. Second of four driver CLIs; mirrors otopcua-modbus-cli's shape.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -0,0 +1,29 @@
|
||||
<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.AbCip.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-abcip-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.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every AB Legacy CLI command. Carries the PCCC-specific endpoint options
|
||||
/// (<c>--gateway</c> + <c>--plc-type</c>) on top of <see cref="DriverCommandBase"/>'s
|
||||
/// shared verbose + timeout + logging helpers.
|
||||
/// </summary>
|
||||
public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
{
|
||||
[CommandOption("gateway", 'g', Description =
|
||||
"Canonical AB Legacy gateway: ab://host[:port]/cip-path. Port defaults to 44818. " +
|
||||
"cip-path depends on the family: SLC 5/05 + PLC-5 typically '1,0'; MicroLogix " +
|
||||
"1100/1400 takes an empty path (direct EIP, no backplane).",
|
||||
IsRequired = true)]
|
||||
public string Gateway { get; init; } = default!;
|
||||
|
||||
[CommandOption("plc-type", 'P', Description =
|
||||
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
|
||||
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
|
||||
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
|
||||
/// supplies. Probe disabled for CLI one-shot runs.
|
||||
/// </summary>
|
||||
protected AbLegacyDriverOptions BuildOptions(IReadOnlyList<AbLegacyTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(
|
||||
HostAddress: Gateway,
|
||||
PlcFamily: PlcType,
|
||||
DeviceName: $"cli-{PlcType}")],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"ablegacy-cli-{Gateway}";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes an AB Legacy (PCCC) endpoint: reads one N-file word + reports driver health.
|
||||
/// Default probe address <c>N7:0</c> matches the integration-fixture seed so operators
|
||||
/// can point the CLI at the ab_server Docker container + real hardware interchangeably.
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the AB Legacy endpoint is reachable and a sample PCCC read succeeds.")]
|
||||
public sealed class ProbeCommand : AbLegacyCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"PCCC address to probe (default N7:0). Use S:0 for the status file when you want " +
|
||||
"the pre-populated register every SLC / MicroLogix / PLC-5 ships with.")]
|
||||
public string Address { get; init; } = "N7:0";
|
||||
|
||||
[CommandOption("type", Description =
|
||||
"PCCC data type of the probe address (default Int — matches N files).")]
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var probeTag = new AbLegacyTagDefinition(
|
||||
Name: "__probe",
|
||||
DeviceHostAddress: Gateway,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||
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,55 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Read one PCCC address (N7:0, F8:0, B3:0/3, L19:0, ST17:0, T4:0.ACC, etc.).
|
||||
/// </summary>
|
||||
[Command("read", Description = "Read a single PCCC file address.")]
|
||||
public sealed class ReadCommand : AbLegacyCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"PCCC file address. File letter implies storage; bit-within-word via slash " +
|
||||
"(B3:0/3 or N7:0/5). Sub-element access for timers/counters/controls uses " +
|
||||
"dot notation (T4:0.ACC, C5:0.PRE, R6:0.LEN).",
|
||||
IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||
"ControlElement (default Int).")]
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(Address, DataType);
|
||||
var tag = new AbLegacyTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new AbLegacyDriver(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
|
||||
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
|
||||
=> $"{address}:{type}";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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.AbLegacy.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Watch a PCCC file address via polled subscription until Ctrl+C. Mirrors the Modbus /
|
||||
/// AB CIP subscribe shape — PollGroupEngine handles the tick loop.
|
||||
/// </summary>
|
||||
[Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||
"ControlElement (default Int).")]
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
[CommandOption("interval-ms", 'i', Description =
|
||||
"Publishing interval in milliseconds (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 AbLegacyTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new AbLegacyDriver(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,81 @@
|
||||
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.AbLegacy.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to a PCCC file address. Writes to timer / counter / control
|
||||
/// sub-elements go through at the wire level but land on the integer field of the
|
||||
/// sub-element — the PLC's runtime semantics (edge-triggered EN/DN bits, preset reloads)
|
||||
/// are PLC-managed, not CLI-manipulable; write these with caution.
|
||||
/// </summary>
|
||||
[Command("write", Description = "Write a single PCCC file address.")]
|
||||
public sealed class WriteCommand : AbLegacyCommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"PCCC file address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||
"ControlElement (default Int).")]
|
||||
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||
|
||||
[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 AbLegacyTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: true);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
await using var driver = new AbLegacyDriver(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="AbLegacyDataType"/>, invariant culture.</summary>
|
||||
internal static object ParseValue(string raw, AbLegacyDataType type) => type switch
|
||||
{
|
||||
AbLegacyDataType.Bit => ParseBool(raw),
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbLegacyDataType.Long => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbLegacyDataType.Float => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
AbLegacyDataType.String => raw,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
_ => 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."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-ablegacy-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
|
||||
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
|
||||
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -0,0 +1,29 @@
|
||||
<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.AbLegacy.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-ablegacy-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.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,60 @@
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Shared base for every driver test-client command (Modbus / AB CIP / AB Legacy / S7 /
|
||||
/// TwinCAT). Carries the options that are meaningful regardless of protocol — verbose
|
||||
/// logging + the standard timeout — plus helpers every command implementation wants:
|
||||
/// Serilog configuration + cancellation-token capture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each driver CLI sub-classes this with its own protocol-specific base (e.g.
|
||||
/// <c>ModbusCommandBase</c>) that adds host/port/unit-id + a <c>BuildDriver()</c>
|
||||
/// factory. That second layer is the point where the driver's <c>{Driver}DriverOptions</c>
|
||||
/// type plugs in; keeping it out of this common base lets each driver CLI stay a thin
|
||||
/// executable with no dependency on the other drivers' projects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why a shared base at all — without this every CLI re-authored the same ~40 lines
|
||||
/// of Serilog wiring + cancel-token plumbing + verbose flag.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class DriverCommandBase : ICommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable Serilog debug-level output. Leave off for clean one-line-per-call output;
|
||||
/// switch on when diagnosing a connect / PDU-framing / retry problem.
|
||||
/// </summary>
|
||||
[CommandOption("verbose", Description = "Enable verbose/debug Serilog output")]
|
||||
public bool Verbose { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request-level timeout used by the driver's <c>Initialize</c> / <c>Read</c> /
|
||||
/// <c>Write</c> / probe calls. Defaults per-protocol (Modbus: 2s, AB: 5s, S7: 5s,
|
||||
/// TwinCAT: 5s) — each driver CLI overrides this property with the appropriate
|
||||
/// <c>[CommandOption]</c> default.
|
||||
/// </summary>
|
||||
public abstract TimeSpan Timeout { get; init; }
|
||||
|
||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the process-global Serilog logger. Commands call this at the top of
|
||||
/// <see cref="ExecuteAsync"/> so driver-internal <c>Log.Logger</c> writes land on the
|
||||
/// same sink as the CLI's operator-facing output.
|
||||
/// </summary>
|
||||
protected void ConfigureLogging()
|
||||
{
|
||||
var config = new LoggerConfiguration();
|
||||
if (Verbose)
|
||||
config.MinimumLevel.Debug().WriteTo.Console();
|
||||
else
|
||||
config.MinimumLevel.Warning().WriteTo.Console();
|
||||
Log.Logger = config.CreateLogger();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Renders <see cref="DataValueSnapshot"/> + <see cref="WriteResult"/> payloads as the
|
||||
/// plain-text lines every driver CLI prints to its console. Matches the one-field-per-line
|
||||
/// style the existing OPC UA <c>otopcua-cli</c> uses so combined runs (read a tag via both
|
||||
/// CLIs side-by-side) look coherent.
|
||||
/// </summary>
|
||||
public static class SnapshotFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Single-tag multi-line render. Shape:
|
||||
/// <code>
|
||||
/// Tag: <name>
|
||||
/// Value: <value>
|
||||
/// Status: 0x... (Good|BadCommunicationError|...)
|
||||
/// Source Time: 2026-04-21T12:34:56.789Z
|
||||
/// Server Time: 2026-04-21T12:34:56.790Z
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static string Format(string tagName, DataValueSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var lines = new[]
|
||||
{
|
||||
$"Tag: {tagName}",
|
||||
$"Value: {FormatValue(snapshot.Value)}",
|
||||
$"Status: {FormatStatus(snapshot.StatusCode)}",
|
||||
$"Source Time: {FormatTimestamp(snapshot.SourceTimestampUtc)}",
|
||||
$"Server Time: {FormatTimestamp(snapshot.ServerTimestampUtc)}",
|
||||
};
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-result render, one line: <c>Write <tag>: 0x... (Good|...)</c>.
|
||||
/// </summary>
|
||||
public static string FormatWrite(string tagName, WriteResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
return $"Write {tagName}: {FormatStatus(result.StatusCode)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Table-style render for batch reads. Emits an aligned 4-column layout:
|
||||
/// tag / value / status / source-time.
|
||||
/// </summary>
|
||||
public static string FormatTable(
|
||||
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagNames);
|
||||
ArgumentNullException.ThrowIfNull(snapshots);
|
||||
if (tagNames.Count != snapshots.Count)
|
||||
throw new ArgumentException(
|
||||
$"tagNames ({tagNames.Count}) and snapshots ({snapshots.Count}) must be the same length");
|
||||
|
||||
var rows = tagNames.Select((t, i) => new
|
||||
{
|
||||
Tag = t,
|
||||
Value = FormatValue(snapshots[i].Value),
|
||||
Status = FormatStatus(snapshots[i].StatusCode),
|
||||
Time = FormatTimestamp(snapshots[i].SourceTimestampUtc),
|
||||
}).ToArray();
|
||||
|
||||
int tagW = Math.Max("TAG".Length, rows.Max(r => r.Tag.Length));
|
||||
int valW = Math.Max("VALUE".Length, rows.Max(r => r.Value.Length));
|
||||
int statW = Math.Max("STATUS".Length, rows.Max(r => r.Status.Length));
|
||||
// source-time column is fixed-width (ISO-8601 to ms) so no max-measurement needed.
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("TAG".PadRight(tagW)).Append(" ")
|
||||
.Append("VALUE".PadRight(valW)).Append(" ")
|
||||
.Append("STATUS".PadRight(statW)).Append(" ")
|
||||
.Append("SOURCE TIME").AppendLine();
|
||||
sb.Append(new string('-', tagW)).Append(" ")
|
||||
.Append(new string('-', valW)).Append(" ")
|
||||
.Append(new string('-', statW)).Append(" ")
|
||||
.Append(new string('-', "SOURCE TIME".Length)).AppendLine();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
sb.Append(r.Tag.PadRight(tagW)).Append(" ")
|
||||
.Append(r.Value.PadRight(valW)).Append(" ")
|
||||
.Append(r.Status.PadRight(statW)).Append(" ")
|
||||
.Append(r.Time).AppendLine();
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public static string FormatValue(object? value) => value switch
|
||||
{
|
||||
null => "<null>",
|
||||
bool b => b ? "true" : "false",
|
||||
string s => $"\"{s}\"",
|
||||
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString() ?? "<null>",
|
||||
};
|
||||
|
||||
public static string FormatStatus(uint statusCode)
|
||||
{
|
||||
// Match the OPC UA shorthand for the statuses most-likely to land in a CLI run.
|
||||
// Anything outside this short-list surfaces as hex — operators can cross-reference
|
||||
// against OPC UA Part 6 § 7.34 (StatusCode tables) or Core.Abstractions status mappers.
|
||||
var name = statusCode switch
|
||||
{
|
||||
0x00000000u => "Good",
|
||||
0x80000000u => "Bad",
|
||||
0x80050000u => "BadCommunicationError",
|
||||
0x80060000u => "BadTimeout",
|
||||
0x80070000u => "BadNoCommunication",
|
||||
0x80080000u => "BadWaitingForInitialData",
|
||||
0x80340000u => "BadNodeIdUnknown",
|
||||
0x80350000u => "BadNodeIdInvalid",
|
||||
0x80740000u => "BadTypeMismatch",
|
||||
0x40000000u => "Uncertain",
|
||||
_ => null,
|
||||
};
|
||||
return name is null
|
||||
? $"0x{statusCode:X8}"
|
||||
: $"0x{statusCode:X8} ({name})";
|
||||
}
|
||||
|
||||
public static string FormatTimestamp(DateTime? ts)
|
||||
{
|
||||
if (ts is null) return "-";
|
||||
var utc = ts.Value.Kind == DateTimeKind.Utc ? ts.Value : ts.Value.ToUniversalTime();
|
||||
return utc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<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.Cli.Common</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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. Uses the managed
|
||||
/// <c>WireFocasClient</c> on TCP:8193. Against an unreachable endpoint it surfaces
|
||||
/// <c>BadCommunicationError</c> which is still a useful signal that the CLI wire-up is
|
||||
/// correct. Also runs cleanly against the focas-mock Docker fixture in
|
||||
/// <c>tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/</c>.
|
||||
/// </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."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 driver's default managed
|
||||
/// wire client opens a TCP:8193 session to the CNC and surfaces unreachable endpoints
|
||||
/// 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}";
|
||||
}
|
||||
@@ -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 + polled " +
|
||||
"subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Uses the managed " +
|
||||
"WireFocasClient on TCP:8193 directly; no native dependencies. Addresses use " +
|
||||
"FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
|
||||
<!-- CLI runs the managed WireFocasClient and talks to the CNC over TCP:8193
|
||||
directly — no Fwlib64.dll copy step needed. -->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes a Modbus-TCP endpoint: opens a socket via <see cref="ModbusDriver"/>'s
|
||||
/// <c>InitializeAsync</c>, issues a single FC03 at the configured probe address, and
|
||||
/// prints the driver's <c>GetHealth()</c>. Fastest way to answer "is the PLC up + talking
|
||||
/// Modbus on this host:port?".
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the Modbus-TCP endpoint is reachable and speaks Modbus.")]
|
||||
public sealed class ProbeCommand : ModbusCommandBase
|
||||
{
|
||||
[CommandOption("probe-address", Description =
|
||||
"Holding-register address used as the cheap-read probe (default 0). Some PLCs lock " +
|
||||
"register 0 — set this to a known-good address on your device.")]
|
||||
public ushort ProbeAddress { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
// Build with one probe tag + Probe.Enabled=false so InitializeAsync connects the
|
||||
// transport, we issue a single read to verify the device responds, then shut down.
|
||||
var probeTag = new ModbusTagDefinition(
|
||||
Name: "__probe",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: ProbeAddress,
|
||||
DataType: ModbusDataType.UInt16);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Host: {Host}:{Port} (unit {UnitId})");
|
||||
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($"HR[{ProbeAddress}]", snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Read one Modbus register / coil. Operator specifies the address via
|
||||
/// <c>--region</c> + <c>--address</c> + <c>--type</c>; the CLI synthesises a single
|
||||
/// <see cref="ModbusTagDefinition"/>, spins up the driver, reads once, prints the snapshot,
|
||||
/// and shuts down. Multi-register types (Int32 / Float32 / String / BCD32) respect
|
||||
/// <c>--byte-order</c> the same way real driver configs do.
|
||||
/// </summary>
|
||||
[Command("read", Description = "Read a single Modbus register or coil.")]
|
||||
public sealed class ReadCommand : ModbusCommandBase
|
||||
{
|
||||
[CommandOption("region", 'r', Description =
|
||||
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||
public ModbusRegion Region { get; init; }
|
||||
|
||||
[CommandOption("address", 'a', Description =
|
||||
"Zero-based address within the region.", IsRequired = true)]
|
||||
public ushort Address { get; init; }
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||
public ModbusDataType DataType { get; init; }
|
||||
|
||||
[CommandOption("byte-order", Description =
|
||||
"BigEndian (default, spec ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
[CommandOption("bit-index", Description =
|
||||
"For type=BitInRegister: bit 0-15 LSB-first.")]
|
||||
public byte BitIndex { get; init; }
|
||||
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: character count (2 per register, rounded up).")]
|
||||
public ushort StringLength { get; init; }
|
||||
|
||||
[CommandOption("string-byte-order", Description =
|
||||
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC et al).")]
|
||||
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(Region, Address, DataType);
|
||||
var tag = new ModbusTagDefinition(
|
||||
Name: tagName,
|
||||
Region: Region,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false,
|
||||
ByteOrder: ByteOrder,
|
||||
BitIndex: BitIndex,
|
||||
StringLength: StringLength,
|
||||
StringByteOrder: StringByteOrder);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(tagName, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a human-readable tag name matching the operator's conceptual model
|
||||
/// (<c>HR[100]</c>, <c>Coil[5]</c>, <c>IR[42]</c>) — the driver treats the name
|
||||
/// purely as a lookup key, so any stable string works.
|
||||
/// </summary>
|
||||
internal static string SynthesiseTagName(
|
||||
ModbusRegion region, ushort address, ModbusDataType type)
|
||||
{
|
||||
var prefix = region switch
|
||||
{
|
||||
ModbusRegion.Coils => "Coil",
|
||||
ModbusRegion.DiscreteInputs => "DI",
|
||||
ModbusRegion.InputRegisters => "IR",
|
||||
ModbusRegion.HoldingRegisters => "HR",
|
||||
_ => "Reg",
|
||||
};
|
||||
return $"{prefix}[{address}]:{type}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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.Modbus.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Long-running poll of one Modbus register via the driver's <c>ISubscribable</c> surface
|
||||
/// (under the hood: <c>PollGroupEngine</c>). Prints each data-change event until the
|
||||
/// operator Ctrl+C's the CLI. Useful for watching a changing PLC signal during
|
||||
/// commissioning or while reproducing a customer bug.
|
||||
/// </summary>
|
||||
[Command("subscribe", Description = "Watch a Modbus register via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : ModbusCommandBase
|
||||
{
|
||||
[CommandOption("region", 'r', Description =
|
||||
"Coils / DiscreteInputs / InputRegisters / HoldingRegisters", IsRequired = true)]
|
||||
public ModbusRegion Region { get; init; }
|
||||
|
||||
[CommandOption("address", 'a', Description = "Zero-based address within the region.", IsRequired = true)]
|
||||
public ushort Address { get; init; }
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||
public ModbusDataType DataType { get; init; }
|
||||
|
||||
[CommandOption("interval-ms", 'i', Description =
|
||||
"Publishing interval in milliseconds (default 1000). The PollGroupEngine enforces " +
|
||||
"a floor of ~250ms; values below it get rounded up.")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
[CommandOption("byte-order", Description =
|
||||
"BigEndian (default) or WordSwap.")]
|
||||
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
|
||||
var tag = new ModbusTagDefinition(
|
||||
Name: tagName,
|
||||
Region: Region,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false,
|
||||
ByteOrder: ByteOrder);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
// Route every data-change event to the CliFx console (not System.Console — the
|
||||
// analyzer flags it + IConsole is the testable abstraction).
|
||||
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 {tagName} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||
try
|
||||
{
|
||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on Ctrl+C — fall through to the unsubscribe in finally.
|
||||
}
|
||||
}
|
||||
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,118 @@
|
||||
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.Modbus.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to a Modbus coil or holding register. Mirrors <see cref="ReadCommand"/>'s
|
||||
/// region / address / type flags + adds <c>--value</c>. Input parsing respects the
|
||||
/// declared <c>--type</c> so you can write <c>--value=3.14 --type=Float32</c> without
|
||||
/// hex-encoding floats. The write is non-idempotent by default (driver's
|
||||
/// <c>WriteIdempotent=false</c>) — replay is the operator's choice, not the driver's.
|
||||
/// </summary>
|
||||
[Command("write", Description = "Write a single Modbus coil or holding register.")]
|
||||
public sealed class WriteCommand : ModbusCommandBase
|
||||
{
|
||||
[CommandOption("region", 'r', Description =
|
||||
"Coils or HoldingRegisters (the only writable regions per the protocol spec).",
|
||||
IsRequired = true)]
|
||||
public ModbusRegion Region { get; init; }
|
||||
|
||||
[CommandOption("address", 'a', Description =
|
||||
"Zero-based address within the region.", IsRequired = true)]
|
||||
public ushort Address { get; init; }
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"BitInRegister / String / Bcd16 / Bcd32", IsRequired = true)]
|
||||
public ModbusDataType DataType { get; init; }
|
||||
|
||||
[CommandOption("value", 'v', Description =
|
||||
"Value to write. Parsed per --type (booleans accept true/false/0/1).",
|
||||
IsRequired = true)]
|
||||
public string Value { get; init; } = default!;
|
||||
|
||||
[CommandOption("byte-order", Description =
|
||||
"BigEndian (default, ABCD) or WordSwap (CDAB). Ignored for single-register types.")]
|
||||
public ModbusByteOrder ByteOrder { get; init; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
[CommandOption("bit-index", Description =
|
||||
"For type=BitInRegister: which bit of the holding register (0-15, LSB-first).")]
|
||||
public byte BitIndex { get; init; }
|
||||
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: character count (2 per register, rounded up).")]
|
||||
public ushort StringLength { get; init; }
|
||||
|
||||
[CommandOption("string-byte-order", Description =
|
||||
"For type=String: HighByteFirst (standard) or LowByteFirst (DirectLOGIC).")]
|
||||
public ModbusStringByteOrder StringByteOrder { get; init; } = ModbusStringByteOrder.HighByteFirst;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
if (Region is not (ModbusRegion.Coils or ModbusRegion.HoldingRegisters))
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
$"Region '{Region}' is read-only in the Modbus spec; writes require Coils or HoldingRegisters.");
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
|
||||
var tag = new ModbusTagDefinition(
|
||||
Name: tagName,
|
||||
Region: Region,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: true,
|
||||
ByteOrder: ByteOrder,
|
||||
BitIndex: BitIndex,
|
||||
StringLength: StringLength,
|
||||
StringByteOrder: StringByteOrder);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
await using var driver = new ModbusDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(tagName, results[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||
/// for the declared <see cref="ModbusDataType"/>. Uses invariant culture everywhere
|
||||
/// so <c>3.14</c> and <c>3,14</c> don't swap meaning between runs.
|
||||
/// </summary>
|
||||
internal static object ParseValue(string raw, ModbusDataType type) => type switch
|
||||
{
|
||||
ModbusDataType.Bool or ModbusDataType.BitInRegister => ParseBool(raw),
|
||||
ModbusDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.UInt16 or ModbusDataType.Bcd16 => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.UInt32 or ModbusDataType.Bcd32 => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||
ModbusDataType.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."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every Modbus CLI command. Carries the Modbus-TCP endpoint options
|
||||
/// (host / port / unit-id) on top of <see cref="DriverCommandBase"/>'s verbose + timeout
|
||||
/// + logging helpers, and exposes <see cref="BuildOptions"/> so each command can turn its
|
||||
/// parsed flags into a <see cref="ModbusDriverOptions"/> ready to hand to the driver ctor.
|
||||
/// </summary>
|
||||
public abstract class ModbusCommandBase : DriverCommandBase
|
||||
{
|
||||
[CommandOption("host", 'h', Description = "Modbus-TCP server hostname or IP", IsRequired = true)]
|
||||
public string Host { get; init; } = default!;
|
||||
|
||||
[CommandOption("port", 'p', Description = "Modbus-TCP port (default 502)")]
|
||||
public int Port { get; init; } = 502;
|
||||
|
||||
[CommandOption("unit-id", 'U', Description = "Modbus unit / slave ID (1-247, default 1)")]
|
||||
public byte UnitId { get; init; } = 1;
|
||||
|
||||
[CommandOption("timeout-ms", Description = "Per-PDU timeout in milliseconds (default 2000)")]
|
||||
public int TimeoutMs { get; init; } = 2000;
|
||||
|
||||
[CommandOption("disable-reconnect", Description =
|
||||
"Disable the built-in mid-transaction reconnect-and-retry. Matches the driver's " +
|
||||
"AutoReconnect=false setting — use when diagnosing socket teardown behaviour.")]
|
||||
public bool DisableAutoReconnect { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs property; setter required to satisfy base's init contract */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construct a <see cref="ModbusDriverOptions"/> with the endpoint fields this base
|
||||
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe is
|
||||
/// disabled — CLI runs are one-shot, the probe loop would race the operator's
|
||||
/// command against its own keep-alive reads.
|
||||
/// </summary>
|
||||
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
UnitId = UnitId,
|
||||
Timeout = Timeout,
|
||||
AutoReconnect = !DisableAutoReconnect,
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Short instance id used in Serilog output so operators running the CLI against
|
||||
/// multiple endpoints in parallel can distinguish the logs.
|
||||
/// </summary>
|
||||
protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-modbus-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa Modbus test-client — ad-hoc connectivity + register reads/writes + polled " +
|
||||
"subscriptions against Modbus-TCP devices. Mirrors the otopcua-cli shape for v1-style " +
|
||||
"manual validation against PLCs + the integration fixture. See docs/Driver.Modbus.Cli.md.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -0,0 +1,29 @@
|
||||
<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.Modbus.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-modbus-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.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes an S7 endpoint: connects via S7.Net, reads one merker word, prints health.
|
||||
/// If the PLC is fresh out of TIA Portal the probe will surface
|
||||
/// <c>BadNotSupported</c> — PUT/GET communication has to be enabled in the hardware
|
||||
/// config for any S7-1200/1500 for the driver to get past the handshake.
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the S7 endpoint is reachable and a sample read succeeds.")]
|
||||
public sealed class ProbeCommand : S7CommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"Probe address (default MW0 — merker word 0). DB1.DBW0 if your PLC project " +
|
||||
"reserves a fingerprint DB.")]
|
||||
public string Address { get; init; } = "MW0";
|
||||
|
||||
[CommandOption("type", Description = "Probe data type (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var probeTag = new S7TagDefinition(
|
||||
Name: "__probe",
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||
await console.Output.WriteLineAsync($"CPU: {CpuType} rack={Rack} slot={Slot}");
|
||||
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,61 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Read one S7 address (DB / M / I / Q area). Addresses use S7.Net grammar — the driver
|
||||
/// parses them via <c>S7AddressParser</c> so whatever the server accepts the CLI accepts
|
||||
/// too.
|
||||
/// </summary>
|
||||
[Command("read", Description = "Read a single S7 address.")]
|
||||
public sealed class ReadCommand : S7CommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"S7 address. Examples: DB1.DBW0 (DB1, word 0); M0.0 (merker bit); IW4 (input word 4); " +
|
||||
"QD8 (output dword 8); DB2.DBD20 (DB2, dword 20); DB5.DBX4.3 (DB5, byte 4, bit 3); " +
|
||||
"DB10.STRING[0] (DB10 string). Bit addresses use dot notation.",
|
||||
IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"String / DateTime (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.Int16;
|
||||
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: S7-string max length (default 254, S7 max).")]
|
||||
public int StringLength { get; init; } = 254;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(Address, DataType);
|
||||
var tag = new S7TagDefinition(
|
||||
Name: tagName,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false,
|
||||
StringLength: StringLength);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new S7Driver(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
|
||||
internal static string SynthesiseTagName(string address, S7DataType 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.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Watch an S7 address via polled subscription until Ctrl+C. S7comm has no native push
|
||||
/// model so this goes through <c>PollGroupEngine</c> same as Modbus / AB.
|
||||
/// </summary>
|
||||
[Command("subscribe", Description = "Watch an S7 address via polled subscription until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : S7CommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description = "S7 address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"String / DateTime (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.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 S7TagDefinition(
|
||||
Name: tagName,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new S7Driver(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,89 @@
|
||||
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.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to an S7 address. Mirrors <see cref="ReadCommand"/>'s flag shape.
|
||||
/// Writes to M (merker) bits or Q (output) coils that drive edge-triggered routines
|
||||
/// are real — be careful what you hit on a running PLC.
|
||||
/// </summary>
|
||||
[Command("write", Description = "Write a single S7 address.")]
|
||||
public sealed class WriteCommand : S7CommandBase
|
||||
{
|
||||
[CommandOption("address", 'a', Description =
|
||||
"S7 address — same format as `read`.", IsRequired = true)]
|
||||
public string Address { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / Byte / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Float32 / Float64 / " +
|
||||
"String / DateTime (default Int16).")]
|
||||
public S7DataType DataType { get; init; } = S7DataType.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!;
|
||||
|
||||
[CommandOption("string-length", Description =
|
||||
"For type=String: S7-string max length (default 254).")]
|
||||
public int StringLength { get; init; } = 254;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||
var tag = new S7TagDefinition(
|
||||
Name: tagName,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: true,
|
||||
StringLength: StringLength);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
await using var driver = new S7Driver(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
|
||||
internal static object ParseValue(string raw, S7DataType type) => type switch
|
||||
{
|
||||
S7DataType.Bool => ParseBool(raw),
|
||||
S7DataType.Byte => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.UInt16 => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.UInt32 => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Int64 => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.UInt64 => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||
S7DataType.String => raw,
|
||||
S7DataType.DateTime => DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
|
||||
_ => 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."),
|
||||
};
|
||||
}
|
||||
11
src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
11
src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-s7-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa S7 test-client — ad-hoc probe + S7comm reads/writes + polled subscriptions " +
|
||||
"against Siemens S7-300 / S7-400 / S7-1200 / S7-1500 (and compatible soft-PLCs) via " +
|
||||
"S7.Net / ISO-on-TCP port 102. Addresses use S7.Net syntax: DB1.DBW0, M0.0, IW4, QD8.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -0,0 +1,61 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every S7 CLI command. Carries the ISO-on-TCP endpoint options
|
||||
/// (host / port / CPU type / rack / slot) that S7.Net needs for its handshake +
|
||||
/// exposes <see cref="BuildOptions"/> so each command can synthesise an
|
||||
/// <see cref="S7DriverOptions"/> on demand.
|
||||
/// </summary>
|
||||
public abstract class S7CommandBase : DriverCommandBase
|
||||
{
|
||||
[CommandOption("host", 'h', Description = "PLC IP address or hostname.", IsRequired = true)]
|
||||
public string Host { get; init; } = default!;
|
||||
|
||||
[CommandOption("port", 'p', Description = "ISO-on-TCP port (default 102).")]
|
||||
public int Port { get; init; } = 102;
|
||||
|
||||
[CommandOption("cpu", 'c', Description =
|
||||
"S7 CPU family: S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 " +
|
||||
"(default S71500). Determines the ISO-TSAP slot byte.")]
|
||||
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
|
||||
|
||||
[CommandOption("rack", Description = "Rack number (default 0 — single-rack).")]
|
||||
public short Rack { get; init; } = 0;
|
||||
|
||||
[CommandOption("slot", Description =
|
||||
"CPU slot. S7-300 = 2, S7-400 = 2 or 3, S7-1200 / S7-1500 = 0 (default 0).")]
|
||||
public short Slot { get; init; } = 0;
|
||||
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="S7DriverOptions"/> with the endpoint fields this base
|
||||
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe
|
||||
/// disabled — CLI runs are one-shot.
|
||||
/// </summary>
|
||||
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
CpuType = CpuType,
|
||||
Rack = Rack,
|
||||
Slot = Slot,
|
||||
Timeout = Timeout,
|
||||
Tags = tags,
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<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.S7.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-s7-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.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,101 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Walk the target's symbol table (ADS <c>SymbolLoaderFactory</c>, flat mode) and print every
|
||||
/// symbol the driver's atomic-type mapper recognizes. Same path <c>DiscoverAsync</c> takes
|
||||
/// when <c>EnableControllerBrowse = true</c> — structured UDTs / function-block instances
|
||||
/// won't appear because the driver filters to the supported primitive surface.
|
||||
/// </summary>
|
||||
[Command("browse", Description = "Enumerate controller symbols via the driver's DiscoverAsync walk.")]
|
||||
public sealed class BrowseCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("prefix", Description =
|
||||
"Case-sensitive instance-path prefix to filter on (e.g. 'GVL_Fixture' or " +
|
||||
"'MAIN.'). Empty (default) prints everything.")]
|
||||
public string? Prefix { get; init; }
|
||||
|
||||
[CommandOption("max", Description =
|
||||
"Maximum number of symbols to print. 0 = unbounded (default 500 for large " +
|
||||
"controllers — flat-mode symbol counts easily top 10k).")]
|
||||
public int Max { get; init; } = 500;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
// Browse-only — no declared tags. EnableControllerBrowse=true flips DiscoverAsync's
|
||||
// symbol-walk on so every recognized primitive surfaces through the builder.
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Gateway, $"cli-{AmsNetId}:{AmsPort}")],
|
||||
Tags = [],
|
||||
Timeout = Timeout,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = !PollOnly,
|
||||
EnableControllerBrowse = true,
|
||||
};
|
||||
|
||||
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||
var builder = new CollectingAddressSpaceBuilder();
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await driver.DiscoverAsync(builder, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var matched = builder.Variables
|
||||
.Where(v => string.IsNullOrEmpty(Prefix) || v.BrowseName.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
var printLimit = Max <= 0 ? matched.Count : Math.Min(Max, matched.Count);
|
||||
|
||||
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Symbols: {matched.Count} matched ({builder.Variables.Count} total), showing {printLimit}");
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
foreach (var v in matched.Take(printLimit))
|
||||
{
|
||||
var access = v.Info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
|
||||
await console.Output.WriteLineAsync($" [{access}] {v.Info.DriverDataType,-8} {v.BrowseName}");
|
||||
}
|
||||
|
||||
if (matched.Count > printLimit)
|
||||
await console.Output.WriteLineAsync(
|
||||
$" … {matched.Count - printLimit} more — raise --max or tighten --prefix");
|
||||
}
|
||||
|
||||
private sealed class CollectingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{
|
||||
Variables.Add((browseName, info));
|
||||
return new Handle(info.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes a TwinCAT runtime: opens an ADS session, reads one symbol, prints driver health.
|
||||
/// Use this first after configuring a new AMS route — it'll surface "no route" /
|
||||
/// "port unreachable" / "AMS router down" errors up-front before you bring the OtOpcUa
|
||||
/// server near the endpoint.
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the TwinCAT runtime is reachable and a sample symbol reads.")]
|
||||
public sealed class ProbeCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path to probe. System-global examples: " +
|
||||
"'TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt', 'MAIN.bRunning'. " +
|
||||
"User-project: a GVL or program variable.",
|
||||
IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", Description = "Data type (default DInt — TwinCAT DINT maps to int32).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var probeTag = new TwinCATTagDefinition(
|
||||
Name: "__probe",
|
||||
DeviceHostAddress: Gateway,
|
||||
SymbolPath: SymbolPath,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([probeTag]);
|
||||
|
||||
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
|
||||
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(SymbolPath, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Read one TwinCAT symbol by path. Structure writes/reads are out of scope — fan the
|
||||
/// member list into individual reads if you need them.
|
||||
/// </summary>
|
||||
[Command("read", Description = "Read a single TwinCAT symbol.")]
|
||||
public sealed class ReadCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
|
||||
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
|
||||
IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(SymbolPath, DataType);
|
||||
var tag = new TwinCATTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
SymbolPath: SymbolPath,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string SynthesiseTagName(string symbolPath, TwinCATDataType type)
|
||||
=> $"{symbolPath}:{type}";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Watch a TwinCAT symbol until Ctrl+C. Native ADS notifications by default (TwinCAT
|
||||
/// pushes on its own cycle); pass <c>--poll-only</c> to fall through to PollGroupEngine.
|
||||
/// </summary>
|
||||
[Command("subscribe", Description = "Watch a TwinCAT symbol via ADS notification or poll, until Ctrl+C.")]
|
||||
public sealed class SubscribeCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("symbol", 's', Description = "Symbol path — same format as `read`.", IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
[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(SymbolPath, DataType);
|
||||
var tag = new TwinCATTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
SymbolPath: SymbolPath,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new TwinCATDriver(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);
|
||||
|
||||
var mode = PollOnly ? "polling" : "ADS notification";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to {SymbolPath} @ {IntervalMs}ms ({mode}). 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,94 @@
|
||||
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.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Write one value to a TwinCAT symbol. Structure writes refused — drop to driver config
|
||||
/// JSON for those.
|
||||
/// </summary>
|
||||
[Command("write", Description = "Write a single TwinCAT symbol.")]
|
||||
public sealed class WriteCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path — same format as `read`.", IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("type", 't', Description =
|
||||
"Bool / SInt / USInt / Int / UInt / DInt / UDInt / LInt / ULInt / Real / LReal / " +
|
||||
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
|
||||
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
|
||||
|
||||
[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();
|
||||
|
||||
if (DataType == TwinCATDataType.Structure)
|
||||
throw new CliFx.Exceptions.CommandException(
|
||||
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
|
||||
"config JSON for those. The CLI covers atomic types only.");
|
||||
|
||||
var tagName = ReadCommand.SynthesiseTagName(SymbolPath, DataType);
|
||||
var tag = new TwinCATTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Gateway,
|
||||
SymbolPath: SymbolPath,
|
||||
DataType: DataType,
|
||||
Writable: true);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(SymbolPath, results[0]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
|
||||
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool => ParseBool(raw),
|
||||
TwinCATDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.DInt => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||
TwinCATDataType.String or TwinCATDataType.WString => raw,
|
||||
// IEC 61131-3 time/date types are stored as UDINT on the wire — accept a numeric raw
|
||||
// value + let the caller handle the encoding semantics.
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay
|
||||
=> uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||
_ => 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."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-twincat-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa TwinCAT test-client — ad-hoc probe + ADS symbolic reads/writes + " +
|
||||
"subscriptions against Beckhoff TwinCAT 2/3 runtimes. Requires a reachable AMS " +
|
||||
"router (local TwinCAT XAR or the Beckhoff.TwinCAT.Ads.TcpRouter NuGet). Addresses " +
|
||||
"use symbolic paths: MAIN.bStart, GVL.Counter, Motor1.Status.Running.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -0,0 +1,62 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Base for every TwinCAT CLI command. Carries the AMS target options
|
||||
/// (<c>--ams-net-id</c> + <c>--ams-port</c>) + the notification-mode toggle that the
|
||||
/// driver itself takes. Exposes <see cref="BuildOptions"/> so each command can build a
|
||||
/// single-device / single-tag <see cref="TwinCATDriverOptions"/> from flag input.
|
||||
/// </summary>
|
||||
public abstract class TwinCATCommandBase : DriverCommandBase
|
||||
{
|
||||
[CommandOption("ams-net-id", 'n', Description =
|
||||
"AMS Net ID of the target runtime (e.g. '192.168.1.40.1.1' or '127.0.0.1.1.1' for local).",
|
||||
IsRequired = true)]
|
||||
public string AmsNetId { get; init; } = default!;
|
||||
|
||||
[CommandOption("ams-port", 'p', Description =
|
||||
"AMS port. TwinCAT 3 PLC runtime defaults to 851; TwinCAT 2 uses 801.")]
|
||||
public int AmsPort { get; init; } = 851;
|
||||
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
[CommandOption("poll-only", Description =
|
||||
"Disable native ADS notifications and fall through to the shared PollGroupEngine " +
|
||||
"(same as setting UseNativeNotifications=false in a real driver config).")]
|
||||
public bool PollOnly { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||
init { /* driven by TimeoutMs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical TwinCAT gateway string the driver's <c>TwinCATAmsAddress.TryParse</c>
|
||||
/// consumes — shape <c>ads://{AmsNetId}:{AmsPort}</c>.
|
||||
/// </summary>
|
||||
protected string Gateway => $"ads://{AmsNetId}:{AmsPort}";
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
|
||||
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
|
||||
/// native notifications toggled by <see cref="PollOnly"/>.
|
||||
/// </summary>
|
||||
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(
|
||||
HostAddress: Gateway,
|
||||
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = !PollOnly,
|
||||
EnableControllerBrowse = false,
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"twincat-cli-{AmsNetId}:{AmsPort}";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<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.TwinCAT.Cli</RootNamespace>
|
||||
<AssemblyName>otopcua-twincat-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.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user