diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index a51c259..2750269 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -26,6 +26,8 @@ + + @@ -48,6 +50,8 @@ + + diff --git a/docs/Driver.AbCip.Cli.md b/docs/Driver.AbCip.Cli.md new file mode 100644 index 0000000..8140aeb --- /dev/null +++ b/docs/Driver.AbCip.Cli.md @@ -0,0 +1,83 @@ +# `otopcua-abcip-cli` — AB CIP test client + +Ad-hoc probe / read / write / subscribe tool for ControlLogix / CompactLogix / +Micro800 / GuardLogix PLCs, talking to the **same** `AbCipDriver` the OtOpcUa +server uses (libplctag under the hood). + +Second of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 → +TwinCAT). Shares `Driver.Cli.Common` with the others. + +## Build + run + +```powershell +dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help +``` + +## Common flags + +| Flag | Default | Purpose | +|---|---|---| +| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` | +| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix | +| `--timeout-ms` | `5000` | Per-operation timeout | +| `--verbose` | off | Serilog debug output | + +Family ↔ CIP-path cheat sheet: +- **ControlLogix / CompactLogix / GuardLogix** — `1,0` (slot 0 of chassis) +- **Micro800** — empty path, just `ab://host/` +- **Sub-slot Logix** (rare) — `1,3` for slot 3 + +## Commands + +### `probe` — is the PLC up? + +```powershell +# ControlLogix — read the canonical libplctag system tag +otopcua-abcip-cli probe -g ab://10.0.0.5/1,0 -t @raw_cpu_type --type DInt + +# Micro800 — point at a user-supplied global +otopcua-abcip-cli probe -g ab://10.0.0.6/ -f Micro800 -t _SYSVA_CLOCK_HOUR --type DInt +``` + +### `read` — single Logix tag + +```powershell +# Controller scope +otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real + +# Program scope +otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Program:Main.Counter" --type DInt + +# Array element +otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real + +# UDT member (dotted path) +otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real +``` + +### `write` — single Logix tag + +Same shape as `read` plus `-v`. Values parse per `--type` using invariant +culture. Booleans accept `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`. +Structure (UDT) writes need the member layout declared in a real driver config +and are refused by the CLI. + +```powershell +otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -v 3.14 +otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t StartCommand --type Bool -v true +``` + +### `subscribe` — watch a tag until Ctrl+C + +```powershell +otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i 500 +``` + +## Typical workflows + +- **"Is the PLC reachable?"** → `probe`. +- **"Did my recipe write land?"** → `write` + `read` back. +- **"Why is tag X flipping?"** → `subscribe`. +- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and + read the status code — safety tags surface `BadNotWritable` / CIP errors, + non-safety tags surface `Good`. diff --git a/docs/Driver.AbLegacy.Cli.md b/docs/Driver.AbLegacy.Cli.md new file mode 100644 index 0000000..5f34cf3 --- /dev/null +++ b/docs/Driver.AbLegacy.Cli.md @@ -0,0 +1,105 @@ +# `otopcua-ablegacy-cli` — AB Legacy (PCCC) test client + +Ad-hoc probe / read / write / subscribe tool for SLC 500 / MicroLogix 1100 / +MicroLogix 1400 / PLC-5 devices, talking to the **same** `AbLegacyDriver` the +OtOpcUa server uses (libplctag PCCC back-end). + +Third of four driver test-client CLIs. Shares `Driver.Cli.Common` with the +others. + +## Build + run + +```powershell +dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- --help +``` + +## Common flags + +| Flag | Default | Purpose | +|---|---|---| +| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` | +| `-P` / `--plc-type` | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc | +| `--timeout-ms` | `5000` | Per-operation timeout | +| `--verbose` | off | Serilog debug output | + +Family ↔ CIP-path cheat sheet: +- **SLC 5/05 / PLC-5** — `1,0` +- **MicroLogix 1100 / 1400** — empty path (`ab://host/`) — they use direct EIP + with no backplane +- **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility + layer; rare) + +## PCCC address primer + +File letters imply data type; type flag still required so the CLI knows how to +parse your `--value`. + +| File | Type | CLI `--type` | +|---|---|---| +| `N` | signed int16 | `Int` | +| `F` | float32 | `Float` | +| `B` | bit-packed (`B3:0/3` addresses bit 3 of word 0) | `Bit` | +| `L` | long int32 (SLC 5/05+ only) | `Long` | +| `A` | analog int (semantically like N) | `AnalogInt` | +| `ST` | ASCII string (82-byte + length header) | `String` | +| `T` | timer sub-element (`T4:0.ACC` / `.PRE` / `.EN` / `.DN`) | `TimerElement` | +| `C` | counter sub-element (`C5:0.ACC` / `.PRE` / `.CU` / `.CD` / `.DN`) | `CounterElement` | +| `R` | control sub-element (`R6:0.LEN` / `.POS` / `.EN` / `.DN` / `.ER`) | `ControlElement` | + +## Commands + +### `probe` + +```powershell +# SLC 5/05 — default probe address N7:0 +otopcua-ablegacy-cli probe -g ab://192.168.1.20/1,0 + +# MicroLogix 1100 — status file first word +otopcua-ablegacy-cli probe -g ab://192.168.1.30/ -P MicroLogix -a S:0 +``` + +### `read` + +```powershell +# Integer +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:10 -t Int + +# Float +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a F8:0 -t Float + +# Bit-within-word +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit + +# Long (SLC 5/05+) +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long + +# Timer ACC +otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement +``` + +### `write` + +```powershell +otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a N7:10 -t Int -v 42 +otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a F8:0 -t Float -v 3.14 +otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit -v on +``` + +Writes to timer / counter / control sub-elements land at the wire level but +the PLC's runtime semantics (EN/DN edge-triggering, preset reload) are +PLC-managed — use with caution. + +### `subscribe` + +```powershell +otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 +``` + +## Known caveat — ab_server upstream gap + +The integration-fixture `ab_server` Docker container accepts TCP but its PCCC +dispatcher doesn't actually respond — see +[`tests/...AbLegacy.IntegrationTests/Docker/README.md`](../tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md). +Point `--gateway` at real hardware or an RSEmulate 500 box for end-to-end +wire-level validation. The CLI itself is correct regardless of which endpoint +you target. diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs new file mode 100644 index 0000000..7f8fe81 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs @@ -0,0 +1,59 @@ +using CliFx.Attributes; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli; + +/// +/// Base for every AB CIP CLI command. Carries the libplctag endpoint options +/// (--gateway + --family) and exposes so each +/// command can synthesise an from CLI flags + its own +/// tag list. +/// +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; + + /// + public override TimeSpan Timeout + { + get => TimeSpan.FromMilliseconds(TimeoutMs); + init { /* driven by TimeoutMs */ } + } + + /// + /// Build an 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. + /// + protected AbCipDriverOptions BuildOptions(IReadOnlyList 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, + }; + + /// + /// Short instance id used in Serilog output so operators running the CLI against + /// multiple gateways in parallel can distinguish the logs. + /// + protected string DriverInstanceId => $"abcip-cli-{Gateway}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ProbeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ProbeCommand.cs new file mode 100644 index 0000000..bbe05e8 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ProbeCommand.cs @@ -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; + +/// +/// 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?". +/// +[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); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ReadCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ReadCommand.cs new file mode 100644 index 0000000..3aaf546 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/ReadCommand.cs @@ -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; + +/// +/// Read one Logix tag by symbolic path. Operator specifies --tag + --type; +/// 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. +/// +[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); + } + } + + /// + /// 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. + /// + internal static string SynthesiseTagName(string tagPath, AbCipDataType type) + => $"{tagPath}:{type}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs new file mode 100644 index 0000000..15a7a9c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs @@ -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; + +/// +/// Watch a Logix tag via polled subscription until Ctrl+C. Uses the driver's +/// ISubscribable surface (PollGroupEngine under the hood). Prints each change +/// event with an HH:mm:ss.fff timestamp. +/// +[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); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/WriteCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/WriteCommand.cs new file mode 100644 index 0000000..32ec025 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/WriteCommand.cs @@ -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; + +/// +/// Write one value to a Logix tag by symbolic path. Mirrors 's +/// flag shape + adds --value. Value parsing respects --type so you can +/// write --value 3.14 --type Real without hex-encoding. GuardLogix safety tags +/// are refused at the driver level (they're forced to ViewOnly by PR 12). +/// +[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); + } + } + + /// + /// Parse the operator's --value string into the CLR type the driver expects + /// for the declared . Invariant culture everywhere. + /// + 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."), + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs new file mode 100644 index 0000000..6232f51 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs @@ -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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj new file mode 100644 index 0000000..da0af01 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli + otopcua-abcip-cli + + + + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs new file mode 100644 index 0000000..8e53728 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs @@ -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; + +/// +/// Base for every AB Legacy CLI command. Carries the PCCC-specific endpoint options +/// (--gateway + --plc-type) on top of 's +/// shared verbose + timeout + logging helpers. +/// +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; + + /// + public override TimeSpan Timeout + { + get => TimeSpan.FromMilliseconds(TimeoutMs); + init { /* driven by TimeoutMs */ } + } + + /// + /// Build an with the device + tag list a subclass + /// supplies. Probe disabled for CLI one-shot runs. + /// + protected AbLegacyDriverOptions BuildOptions(IReadOnlyList 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}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs new file mode 100644 index 0000000..4c1d76c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs @@ -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; + +/// +/// Probes an AB Legacy (PCCC) endpoint: reads one N-file word + reports driver health. +/// Default probe address N7:0 matches the integration-fixture seed so operators +/// can point the CLI at the ab_server Docker container + real hardware interchangeably. +/// +[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); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs new file mode 100644 index 0000000..0fb5e3f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs @@ -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; + +/// +/// Read one PCCC address (N7:0, F8:0, B3:0/3, L19:0, ST17:0, T4:0.ACC, etc.). +/// +[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); + } + } + + /// Tag-name key the driver uses internally. Address+type is already unique. + internal static string SynthesiseTagName(string address, AbLegacyDataType type) + => $"{address}:{type}"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/SubscribeCommand.cs new file mode 100644 index 0000000..439890b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/SubscribeCommand.cs @@ -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; + +/// +/// Watch a PCCC file address via polled subscription until Ctrl+C. Mirrors the Modbus / +/// AB CIP subscribe shape — PollGroupEngine handles the tick loop. +/// +[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); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs new file mode 100644 index 0000000..4e71bf5 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs @@ -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; + +/// +/// 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. +/// +[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); + } + } + + /// Parse --value per , invariant culture. + 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."), + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs new file mode 100644 index 0000000..7db0c5f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs @@ -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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj new file mode 100644 index 0000000..d89d5c8 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli + otopcua-ablegacy-cli + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/WriteCommandParseValueTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/WriteCommandParseValueTests.cs new file mode 100644 index 0000000..a6afb9b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/WriteCommandParseValueTests.cs @@ -0,0 +1,99 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests; + +/// +/// Covers . Every Logix atomic type has at least +/// one happy-path case plus a failure case for unparseable input. +/// +[Trait("Category", "Unit")] +public sealed class WriteCommandParseValueTests +{ + [Theory] + [InlineData("true", true)] + [InlineData("0", false)] + [InlineData("on", true)] + [InlineData("NO", false)] + public void ParseValue_Bool_accepts_common_aliases(string raw, bool expected) + { + WriteCommand.ParseValue(raw, AbCipDataType.Bool).ShouldBe(expected); + } + + [Fact] + public void ParseValue_Bool_rejects_garbage() + { + Should.Throw( + () => WriteCommand.ParseValue("maybe", AbCipDataType.Bool)); + } + + [Fact] + public void ParseValue_SInt_widens_to_sbyte() + { + WriteCommand.ParseValue("-128", AbCipDataType.SInt).ShouldBe((sbyte)-128); + WriteCommand.ParseValue("127", AbCipDataType.SInt).ShouldBe((sbyte)127); + } + + [Fact] + public void ParseValue_Int_signed_16bit() + { + WriteCommand.ParseValue("-32768", AbCipDataType.Int).ShouldBe((short)-32768); + } + + [Fact] + public void ParseValue_DInt_and_Dt_both_land_on_int() + { + WriteCommand.ParseValue("42", AbCipDataType.DInt).ShouldBeOfType(); + WriteCommand.ParseValue("1234567", AbCipDataType.Dt).ShouldBeOfType(); + } + + [Fact] + public void ParseValue_LInt_64bit() + { + WriteCommand.ParseValue("9223372036854775807", AbCipDataType.LInt).ShouldBe(long.MaxValue); + } + + [Fact] + public void ParseValue_unsigned_range_respects_bounds() + { + WriteCommand.ParseValue("255", AbCipDataType.USInt).ShouldBeOfType(); + WriteCommand.ParseValue("65535", AbCipDataType.UInt).ShouldBeOfType(); + WriteCommand.ParseValue("4294967295", AbCipDataType.UDInt).ShouldBeOfType(); + } + + [Fact] + public void ParseValue_Real_invariant_culture_decimal() + { + WriteCommand.ParseValue("3.14", AbCipDataType.Real).ShouldBe(3.14f); + } + + [Fact] + public void ParseValue_LReal_handles_double_precision() + { + WriteCommand.ParseValue("2.718281828", AbCipDataType.LReal).ShouldBeOfType(); + } + + [Fact] + public void ParseValue_String_passthrough() + { + WriteCommand.ParseValue("hello logix", AbCipDataType.String).ShouldBe("hello logix"); + } + + [Fact] + public void ParseValue_non_numeric_for_numeric_types_throws() + { + Should.Throw( + () => WriteCommand.ParseValue("xyz", AbCipDataType.DInt)); + } + + [Theory] + [InlineData("Motor01_Speed", AbCipDataType.Real, "Motor01_Speed:Real")] + [InlineData("Program:Main.Counter", AbCipDataType.DInt, "Program:Main.Counter:DInt")] + [InlineData("Recipe[3]", AbCipDataType.Int, "Recipe[3]:Int")] + public void SynthesiseTagName_preserves_path_verbatim( + string path, AbCipDataType type, string expected) + { + ReadCommand.SynthesiseTagName(path, type).ShouldBe(expected); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj new file mode 100644 index 0000000..a045171 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/WriteCommandParseValueTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/WriteCommandParseValueTests.cs new file mode 100644 index 0000000..5c4049b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/WriteCommandParseValueTests.cs @@ -0,0 +1,91 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests; + +/// +/// Covers . PCCC types are narrower than AB CIP +/// (no 64-bit, no unsigned variants, no Structure / Dt) so the matrix is smaller. +/// +[Trait("Category", "Unit")] +public sealed class WriteCommandParseValueTests +{ + [Theory] + [InlineData("true", true)] + [InlineData("0", false)] + [InlineData("yes", true)] + [InlineData("OFF", false)] + public void ParseValue_Bit_accepts_common_aliases(string raw, bool expected) + { + WriteCommand.ParseValue(raw, AbLegacyDataType.Bit).ShouldBe(expected); + } + + [Fact] + public void ParseValue_Int_signed_16bit() + { + WriteCommand.ParseValue("-32768", AbLegacyDataType.Int).ShouldBe((short)-32768); + WriteCommand.ParseValue("32767", AbLegacyDataType.Int).ShouldBe((short)32767); + } + + [Fact] + public void ParseValue_AnalogInt_parses_same_as_Int() + { + // A-file uses N-file semantics — 16-bit signed with the same wire format. + WriteCommand.ParseValue("100", AbLegacyDataType.AnalogInt).ShouldBeOfType(); + } + + [Fact] + public void ParseValue_Long_32bit() + { + WriteCommand.ParseValue("-2147483648", AbLegacyDataType.Long).ShouldBe(int.MinValue); + WriteCommand.ParseValue("2147483647", AbLegacyDataType.Long).ShouldBe(int.MaxValue); + } + + [Fact] + public void ParseValue_Float_invariant_culture() + { + WriteCommand.ParseValue("3.14", AbLegacyDataType.Float).ShouldBe(3.14f); + } + + [Fact] + public void ParseValue_String_passthrough() + { + WriteCommand.ParseValue("hello slc", AbLegacyDataType.String).ShouldBe("hello slc"); + } + + [Theory] + [InlineData(AbLegacyDataType.TimerElement)] + [InlineData(AbLegacyDataType.CounterElement)] + [InlineData(AbLegacyDataType.ControlElement)] + public void ParseValue_Element_types_land_on_int32(AbLegacyDataType type) + { + // T/C/R sub-elements are 32-bit at the wire level regardless of semantic meaning. + WriteCommand.ParseValue("42", type).ShouldBeOfType(); + } + + [Fact] + public void ParseValue_Bit_rejects_unknown_strings() + { + Should.Throw( + () => WriteCommand.ParseValue("perhaps", AbLegacyDataType.Bit)); + } + + [Fact] + public void ParseValue_non_numeric_for_numeric_types_throws() + { + Should.Throw( + () => WriteCommand.ParseValue("xyz", AbLegacyDataType.Int)); + } + + [Theory] + [InlineData("N7:0", AbLegacyDataType.Int, "N7:0:Int")] + [InlineData("B3:0/3", AbLegacyDataType.Bit, "B3:0/3:Bit")] + [InlineData("F8:10", AbLegacyDataType.Float, "F8:10:Float")] + [InlineData("T4:0.ACC", AbLegacyDataType.TimerElement, "T4:0.ACC:TimerElement")] + public void SynthesiseTagName_preserves_PCCC_address_verbatim( + string address, AbLegacyDataType type, string expected) + { + ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj new file mode 100644 index 0000000..fe9a48e --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +