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
+
+
+
+
+
+
+
+