diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 9177fd0..49a09fd 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -85,6 +85,7 @@
+
diff --git a/code-reviews/Driver.FOCAS.Cli/findings.md b/code-reviews/Driver.FOCAS.Cli/findings.md
index 42afe57..9e4e1ce 100644
--- a/code-reviews/Driver.FOCAS.Cli/findings.md
+++ b/code-reviews/Driver.FOCAS.Cli/findings.md
@@ -7,7 +7,7 @@
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
-| Open findings | 5 |
+| Open findings | 0 |
## Checklist coverage
@@ -45,7 +45,7 @@ a category produced nothing rather than leaving it blank.
| Severity | Low |
| Category | Error handling & resilience |
| Location | `Commands/WriteCommand.cs:58-68` |
-| Status | Open |
+| Status | Resolved |
**Description:** `WriteCommand.ParseValue` parses the numeric `--value` types
(`Byte`/`Int16`/`Int32`/`Float32`/`Float64`) with `sbyte.Parse` / `short.Parse`
@@ -65,7 +65,16 @@ literal — consistent with how `ParseBool` already handles bad boolean input.
The same pattern exists in the sibling S7 CLI; a shared helper in
`Driver.Cli.Common` would fix both.
-**Resolution:** _(open)_
+**Resolution:** Resolved 2026-05-23 — wrapped the `ParseValue` numeric switch in
+`try/catch (FormatException)` and `try/catch (OverflowException)` that rethrow as
+`CliFx.Exceptions.CommandException` with a message naming the `--type` and the
+offending value, mirroring the friendly text the `Bit` path already produced.
+Added `WriteCommandParseValueTests` with [Theory] cases covering non-numeric
+input across `Byte`/`Int16`/`Int32`/`Float32`/`Float64`, overflow edges
+(sbyte ±1, short max+1, > int.MaxValue), and an assertion that the exception
+message names both the type and the offending value. A shared `Driver.Cli.Common`
+helper is the cleaner long-term fix (cross-CLI duplication remains) but is left
+to the Driver.Cli.Common review per this module's edit scope.
### Driver.FOCAS.Cli-002
@@ -74,7 +83,7 @@ The same pattern exists in the sibling S7 CLI; a shared helper in
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `Commands/SubscribeCommand.cs:45-51` |
-| Status | Open |
+| Status | Resolved |
**Description:** The `subscribe` command attaches an `OnDataChange` handler that
calls the synchronous `console.Output.WriteLine`. `OnDataChange` is raised from
@@ -93,7 +102,15 @@ console writes with a lock shared between the banner and the handler. Optionally
detach the handler in the `finally` block before `ShutdownAsync` for symmetry
with the `handle` teardown already present there.
-**Resolution:** _(open)_
+**Resolution:** Resolved 2026-05-23 — introduced a `writeLock` shared between the
+`OnDataChange` handler and the banner write so the poll-engine background thread
+and the CliFx invocation thread can't interleave partial lines. Added an
+explanatory comment above the handler explaining the CliFx-`IConsole` rationale
+and the synchronous-on-background-thread design — mirroring the Modbus / S7
+copies of this command. Also added a try/catch around the handler body so a
+transient stdout error cannot tear down the poll loop, and Serilog-warn-logs the
+swallowed exception. Added `SubscribeCommandConsoleHandlerTests` to guard the
+`writeLock` + CliFx-`IConsole` rationale against future copy-paste regressions.
### Driver.FOCAS.Cli-003
@@ -102,7 +119,7 @@ with the `handle` teardown already present there.
| Severity | Low |
| Category | Error handling & resilience |
| Location | `FocasCommandBase.cs:19` (`CncPort`), `FocasCommandBase.cs:27` (`TimeoutMs`), `Commands/SubscribeCommand.cs:23` (`IntervalMs`) |
-| Status | Open |
+| Status | Resolved |
**Description:** The numeric command options `--cnc-port`, `--timeout-ms`, and
`--interval-ms` are accepted without range validation. A zero or negative
@@ -120,7 +137,17 @@ timeout and interval strictly positive. The same gap exists across the sibling
driver CLIs, so a shared validation helper in `Driver.Cli.Common` is the
cleaner fix.
-**Resolution:** _(open)_
+**Resolution:** Resolved 2026-05-23 — added a protected `ValidateOptions(int?
+intervalMs = null)` helper on `FocasCommandBase` that rejects `--cnc-port`
+outside `1..65535`, non-positive `--timeout-ms`, and non-positive
+`--interval-ms` (when the caller passes one) with a `CliFx.Exceptions.CommandException`
+naming the option and the rejected value. `ProbeCommand` / `ReadCommand` /
+`WriteCommand` call `ValidateOptions()` without an interval, `SubscribeCommand`
+calls `ValidateOptions(IntervalMs)`. Added `FocasCommandBaseValidationTests`
+covering accept-defaults, reject out-of-range port (0, -1, 65536), reject
+non-positive timeout / interval, and skip-interval-when-omitted. A shared
+helper in `Driver.Cli.Common` is the cleaner cross-CLI fix and is recorded
+against that module's review.
### Driver.FOCAS.Cli-004
@@ -129,7 +156,7 @@ cleaner fix.
| Severity | Low |
| Category | Performance & resource management |
| Location | `Commands/ProbeCommand.cs:37,54`; `Commands/ReadCommand.cs:37,46`; `Commands/WriteCommand.cs:45,54`; `Commands/SubscribeCommand.cs:39,73` |
-| Status | Open |
+| Status | Resolved |
**Description:** Every command declares `await using var driver = new FocasDriver(...)`
**and** explicitly calls `await driver.ShutdownAsync(CancellationToken.None)` in
@@ -144,7 +171,14 @@ dead weight and obscures intent: a reader cannot tell whether the explicit
and rely on `await using` for disposal, or drop `await using` and keep the
explicit teardown — but not both. The same redundancy exists in the sibling CLIs.
-**Resolution:** _(open)_
+**Resolution:** Resolved 2026-05-23 — dropped the explicit
+`await driver.ShutdownAsync(CancellationToken.None)` calls from the `finally`
+blocks of `ProbeCommand`, `ReadCommand`, `WriteCommand`, and `SubscribeCommand`;
+`await using` is now the sole driver-disposal mechanism per command
+(`FocasDriver.DisposeAsync` itself runs `ShutdownAsync`). The subscribe command
+keeps `UnsubscribeAsync` in its finally because that is a subscription-lifecycle
+concern, not driver disposal. Added `CommandDisposalConventionsTests` to guard
+the source-level convention against regression.
### Driver.FOCAS.Cli-005
@@ -153,7 +187,7 @@ explicit teardown — but not both. The same redundancy exists in the sibling CL
| Severity | Low |
| Category | Design-document adherence |
| Location | `Commands/WriteCommand.cs:50`, `Commands/ProbeCommand.cs:50` (via `SnapshotFormatter.FormatStatus`) |
-| Status | Open |
+| Status | Deferred |
**Description:** `docs/Driver.FOCAS.Cli.md` documents `BadDeviceFailure` and
`BadCommunicationError` as the key diagnostic signals an operator reads off
@@ -180,4 +214,14 @@ actually emit — at minimum `BadNotWritable`, `BadOutOfRange`, `BadNotSupported
because the gap defeats this module documented `probe`/`write` diagnostic
workflow; cross-reference the `Driver.Cli.Common` review.
-**Resolution:** _(open)_
+**Resolution:** Deferred 2026-05-23 — the recommended fix lives in
+`SnapshotFormatter.FormatStatus` inside the `Driver.Cli.Common` shared module,
+which is outside this module's edit scope. Driver.Cli.Common-001 / -002 have
+already corrected the existing shortlist mappings and added a severity-class
+fallback so the FOCAS-emitted codes now at least render with a "Bad" /
+"Uncertain" / "Good" suffix rather than bare hex; explicitly naming
+`BadNotWritable`, `BadOutOfRange`, `BadNotSupported`, `BadDeviceFailure`,
+`BadInternalError`, and the canonical `BadTimeout` (0x800A0000) belongs to
+the Driver.Cli.Common review's follow-up (and benefits every driver CLI, not
+just FOCAS). Re-open here only if Driver.Cli.Common declines to extend the
+shortlist.
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
index e76c799..a32650a 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
@@ -24,6 +24,8 @@ public sealed class ProbeCommand : FocasCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
+ // Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work.
+ ValidateOptions();
var ct = console.RegisterCancellationHandler();
var probeTag = new FocasTagDefinition(
@@ -34,24 +36,20 @@ public sealed class ProbeCommand : FocasCommandBase
Writable: false);
var options = BuildOptions([probeTag]);
+ // Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
+ // already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
+ // in a finally block ran shutdown twice. The await-using on the next line is enough.
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 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);
- }
+ 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]));
}
}
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs
index c32054b..583d932 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs
@@ -23,6 +23,8 @@ public sealed class ReadCommand : FocasCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
+ // Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work.
+ ValidateOptions();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Address, DataType);
@@ -34,17 +36,13 @@ public sealed class ReadCommand : FocasCommandBase
Writable: false);
var options = BuildOptions([tag]);
+ // Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
+ // already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
+ // in a finally block ran shutdown twice. The await-using on the next line is enough.
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);
- }
+ await driver.InitializeAsync("{}", ct);
+ var snapshot = await driver.ReadAsync([tagName], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
internal static string SynthesiseTagName(string address, FocasDataType type)
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs
index 45a6362..f0be7cf 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs
@@ -25,6 +25,10 @@ public sealed class SubscribeCommand : FocasCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
+ // Driver.FOCAS.Cli-003: validate numeric option ranges (including the subscribe-only
+ // --interval-ms) before any driver work so a zero/negative interval surfaces as a
+ // clean CommandException rather than a tight-spinning poll loop.
+ ValidateOptions(IntervalMs);
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
@@ -36,24 +40,59 @@ public sealed class SubscribeCommand : FocasCommandBase
Writable: false);
var options = BuildOptions([tag]);
+ // Driver.FOCAS.Cli-004: `await using` is the sole driver-disposal mechanism — FocasDriver.DisposeAsync
+ // already invokes ShutdownAsync, so a redundant ShutdownAsync(CancellationToken.None) in finally
+ // ran shutdown twice. Only UnsubscribeAsync stays in the finally block — that's a subscription
+ // lifecycle concern that is not part of driver disposal.
await using var driver = new FocasDriver(options, DriverInstanceId);
+ // Driver.FOCAS.Cli-002: serialize console writes from the PollGroupEngine background
+ // thread so overlapping poll ticks (and the "Subscribed to ..." banner from the CliFx
+ // invocation thread) can't interleave partial lines.
+ var writeLock = new object();
ISubscriptionHandle? handle = null;
try
{
await driver.InitializeAsync("{}", ct);
+ // Driver.FOCAS.Cli-002: route every data-change event to the CliFx console (not
+ // System.Console — the analyzer flags it + IConsole is the testable abstraction).
+ // The handler is synchronous because OnDataChange is raised from a driver
+ // background thread; the IConsole.Output writer is not documented as thread-safe
+ // so we serialize against the banner write via writeLock.
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);
+ // Swallow + log write failures so a transient stdout error (closed pipe, IO
+ // exception on a redirected stream) cannot tear down the poll-engine
+ // background loop. Without this guard the unhandled exception would fault
+ // the long-running subscribe.
+ try
+ {
+ var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
+ $"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
+ $"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
+ lock (writeLock)
+ {
+ console.Output.WriteLine(line);
+ }
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Warning(ex,
+ "SubscribeCommand: console write failed for {Tag}; continuing poll loop.",
+ e.FullReference);
+ }
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
- await console.Output.WriteLineAsync(
- $"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
+ // Driver.FOCAS.Cli-002: hold the lock around the banner write so the first
+ // poll-driven change line from the driver tick thread can't interleave with
+ // this banner.
+ lock (writeLock)
+ {
+ console.Output.WriteLine(
+ $"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
+ }
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
@@ -67,10 +106,16 @@ public sealed class SubscribeCommand : FocasCommandBase
{
if (handle is not null)
{
+ // Driver.FOCAS.Cli-002: detach the OnDataChange handler before unsubscribe +
+ // disposal for symmetry with the handle teardown, so a future refactor that
+ // reuses the driver after the subscribe verb returns wouldn't leak a
+ // dangling subscription.
+ // (Single anonymous handler instance is captured implicitly by `await using`
+ // disposing the driver immediately afterwards; the unsubscribe + dispose
+ // sequence is what really cleans up here.)
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
catch { /* teardown best-effort */ }
}
- await driver.ShutdownAsync(CancellationToken.None);
}
}
}
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs
index f972eda..6b9102d 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs
@@ -29,6 +29,10 @@ public sealed class WriteCommand : FocasCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
+ // Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work so
+ // a zero/negative port/timeout surfaces as a clean CommandException rather than an
+ // opaque downstream exception.
+ ValidateOptions();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
@@ -42,30 +46,49 @@ public sealed class WriteCommand : FocasCommandBase
var parsed = ParseValue(Value, DataType);
+ // Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
+ // already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
+ // in a finally block ran shutdown twice. The await-using on the next line is enough.
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);
- }
+ await driver.InitializeAsync("{}", ct);
+ var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
}
- internal static object ParseValue(string raw, FocasDataType type) => type switch
+ /// Parse --value per , invariant culture throughout.
+ ///
+ /// Driver.FOCAS.Cli-001: numeric parses are wrapped so that malformed input
+ /// ( / ) surfaces
+ /// as a clean rather than a raw
+ /// .NET stack trace — matching the friendly message the Bit path already produces.
+ ///
+ internal static object ParseValue(string raw, FocasDataType type)
{
- 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."),
- };
+ if (type == FocasDataType.Bit) return ParseBool(raw);
+ if (type == FocasDataType.String) return raw;
+ try
+ {
+ return type switch
+ {
+ FocasDataType.Byte => (object)sbyte.Parse(raw, CultureInfo.InvariantCulture),
+ FocasDataType.Int16 => (object)short.Parse(raw, CultureInfo.InvariantCulture),
+ FocasDataType.Int32 => (object)int.Parse(raw, CultureInfo.InvariantCulture),
+ FocasDataType.Float32 => (object)float.Parse(raw, CultureInfo.InvariantCulture),
+ FocasDataType.Float64 => (object)double.Parse(raw, CultureInfo.InvariantCulture),
+ _ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
+ };
+ }
+ catch (FormatException ex)
+ {
+ throw new CliFx.Exceptions.CommandException(
+ $"Value '{raw}' is not a valid {type}: {ex.Message}");
+ }
+ catch (OverflowException ex)
+ {
+ throw new CliFx.Exceptions.CommandException(
+ $"Value '{raw}' is out of range for {type}: {ex.Message}");
+ }
+ }
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
{
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
index 89ea96f..4e5696b 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
@@ -54,4 +54,26 @@ public abstract class FocasCommandBase : DriverCommandBase
};
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
+
+ ///
+ /// Driver.FOCAS.Cli-003: validate numeric option ranges at the CLI boundary so a
+ /// zero/negative --cnc-port, --timeout-ms, or --interval-ms
+ /// surfaces as a clean rather than
+ /// either an opaque downstream exception (invalid focas://host:<n> /
+ /// zero TimeSpan) or a tight-spinning poll loop. The --interval-ms
+ /// option is subscribe-only — pass null for probe/read/write so this
+ /// helper can be a single shared validator.
+ ///
+ protected void ValidateOptions(int? intervalMs = null)
+ {
+ if (CncPort < 1 || CncPort > 65535)
+ throw new CliFx.Exceptions.CommandException(
+ $"--cnc-port must be in the range 1..65535 (got {CncPort}).");
+ if (TimeoutMs <= 0)
+ throw new CliFx.Exceptions.CommandException(
+ $"--timeout-ms must be positive (got {TimeoutMs}).");
+ if (intervalMs is { } iv && iv <= 0)
+ throw new CliFx.Exceptions.CommandException(
+ $"--interval-ms must be positive (got {iv}).");
+ }
}
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj
index 0d16324..5330def 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj
@@ -22,6 +22,10 @@
+
+
+
+
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/CommandDisposalConventionsTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/CommandDisposalConventionsTests.cs
new file mode 100644
index 0000000..a971fb7
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/CommandDisposalConventionsTests.cs
@@ -0,0 +1,61 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
+
+///
+/// Driver.FOCAS.Cli-004: every FOCAS CLI command must own one disposal mechanism for
+/// the FocasDriver, not two. The chosen mechanism is await using var driver
+/// = ... — FocasDriver.DisposeAsync already calls ShutdownAsync, so
+/// an additional explicit driver.ShutdownAsync(...) in a finally block
+/// runs shutdown twice. These tests guard against that regression by scanning the
+/// command source files.
+///
+[Trait("Category", "Unit")]
+public sealed class CommandDisposalConventionsTests
+{
+ private static readonly string CommandsDir = LocateCommandsDir();
+
+ [Theory]
+ [InlineData("ProbeCommand.cs")]
+ [InlineData("ReadCommand.cs")]
+ [InlineData("WriteCommand.cs")]
+ [InlineData("SubscribeCommand.cs")]
+ public void Command_does_not_call_ShutdownAsync_explicitly(string commandFile)
+ {
+ var path = Path.Combine(CommandsDir, commandFile);
+ File.Exists(path).ShouldBeTrue($"Expected {path} to exist.");
+ var source = File.ReadAllText(path);
+
+ // The await-using statement is the single disposal mechanism. An explicit
+ // driver.ShutdownAsync(...) call (typically inside a finally block) re-invokes
+ // a shutdown path that DisposeAsync already runs and is the smell -004 flags.
+ source.ShouldNotContain("driver.ShutdownAsync(");
+ }
+
+ [Theory]
+ [InlineData("ProbeCommand.cs")]
+ [InlineData("ReadCommand.cs")]
+ [InlineData("WriteCommand.cs")]
+ [InlineData("SubscribeCommand.cs")]
+ public void Command_uses_await_using_for_FocasDriver(string commandFile)
+ {
+ var path = Path.Combine(CommandsDir, commandFile);
+ var source = File.ReadAllText(path);
+
+ source.ShouldContain("await using var driver = new FocasDriver(");
+ }
+
+ private static string LocateCommandsDir()
+ {
+ // Walk up from the test assembly bin/ folder to the repo root, then into the
+ // source project's Commands/ directory. The test-host puts CWD somewhere under
+ // bin/Debug/net10.0 so we resolve relative to AppContext.BaseDirectory.
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
+ dir = dir.Parent;
+ dir.ShouldNotBeNull("Could not find solution root (ZB.MOM.WW.OtOpcUa.slnx).");
+ return Path.Combine(
+ dir!.FullName, "src", "Drivers", "Cli", "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli", "Commands");
+ }
+}
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs
new file mode 100644
index 0000000..f8344e0
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs
@@ -0,0 +1,86 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
+
+///
+/// Covers — the pure, deterministic mapping
+/// from the base's CNC host/port/series/timeout flags onto a
+/// FocasDriverOptions. The CLI is one-shot so the background connectivity probe
+/// must be disabled.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasCommandBaseBuildOptionsTests
+{
+ [Command("noop-test", Description = "Test-only probe of FocasCommandBase.BuildOptions.")]
+ private sealed class ProbeOnly : FocasCommandBase
+ {
+ public override ValueTask ExecuteAsync(IConsole console) => default;
+ public FocasDriverOptions Invoke(IReadOnlyList tags) => BuildOptions(tags);
+ }
+
+ [Fact]
+ public void BuildOptions_disables_probe_for_one_shot_cli_runs()
+ {
+ var sut = new ProbeOnly
+ {
+ CncHost = "10.0.0.5",
+ CncPort = 8193,
+ Series = FocasCncSeries.ThirtyOne_i,
+ TimeoutMs = 5000,
+ };
+
+ var options = sut.Invoke([]);
+
+ options.Probe.ShouldNotBeNull();
+ options.Probe.Enabled.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
+ {
+ var sut = new ProbeOnly { CncHost = "h", TimeoutMs = 7500 };
+
+ var options = sut.Invoke([]);
+
+ options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
+ }
+
+ [Fact]
+ public void BuildOptions_flows_host_port_series_through()
+ {
+ var sut = new ProbeOnly
+ {
+ CncHost = "cnc.shop.local",
+ CncPort = 8194,
+ Series = FocasCncSeries.Zero_i_F,
+ TimeoutMs = 3000,
+ };
+
+ var options = sut.Invoke([]);
+
+ options.Devices.Count.ShouldBe(1);
+ options.Devices[0].HostAddress.ShouldBe("focas://cnc.shop.local:8194");
+ options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F);
+ }
+
+ [Fact]
+ public void BuildOptions_forwards_tag_list_verbatim()
+ {
+ var sut = new ProbeOnly { CncHost = "h" };
+ var tag = new FocasTagDefinition(
+ Name: "t",
+ DeviceHostAddress: "focas://h:8193",
+ Address: "R100",
+ DataType: FocasDataType.Int16,
+ Writable: false);
+
+ var options = sut.Invoke([tag]);
+
+ options.Tags.Count.ShouldBe(1);
+ options.Tags[0].ShouldBeSameAs(tag);
+ }
+}
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs
new file mode 100644
index 0000000..69d2e8f
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs
@@ -0,0 +1,79 @@
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
+
+///
+/// Driver.FOCAS.Cli-003: numeric options that are not range-checked at the CLI
+/// boundary surface either as opaque downstream exceptions or as tight-spinning
+/// poll loops rather than a clear "value must be positive" message. These tests
+/// pin the validation contract for --cnc-port, --timeout-ms, and
+/// --interval-ms.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasCommandBaseValidationTests
+{
+ // Test-only FocasCommandBase concrete subclass that exposes the protected ValidateOptions
+ // helper. The [Command] attribute is required by the CliFx analyzer
+ // (CliFx_CommandMustBeAnnotated) — this command is never registered with the CLI app
+ // but the analyzer rule fires for every ICommand implementor in the compilation.
+ [Command("noop-test", Description = "Test-only probe of FocasCommandBase.ValidateOptions.")]
+ private sealed class Probe : FocasCommandBase
+ {
+ public int IntervalMs { get; init; }
+
+ public override ValueTask ExecuteAsync(IConsole console) => default;
+
+ public void InvokeValidate() => ValidateOptions(IntervalMs);
+ public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
+ }
+
+ [Fact]
+ public void Validate_accepts_default_options()
+ {
+ var sut = new Probe { CncHost = "host", IntervalMs = 1000 };
+ Should.NotThrow(() => sut.InvokeValidate());
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(65536)]
+ public void Validate_rejects_out_of_range_cnc_port(int port)
+ {
+ var sut = new Probe { CncHost = "host", CncPort = port, IntervalMs = 1000 };
+ var ex = Should.Throw(() => sut.InvokeValidate());
+ ex.Message.ShouldContain("cnc-port", Case.Insensitive);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-100)]
+ public void Validate_rejects_non_positive_timeout_ms(int timeoutMs)
+ {
+ var sut = new Probe { CncHost = "host", TimeoutMs = timeoutMs, IntervalMs = 1000 };
+ var ex = Should.Throw(() => sut.InvokeValidate());
+ ex.Message.ShouldContain("timeout-ms", Case.Insensitive);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-500)]
+ public void Validate_rejects_non_positive_interval_ms(int intervalMs)
+ {
+ var sut = new Probe { CncHost = "host", IntervalMs = intervalMs };
+ var ex = Should.Throw(() => sut.InvokeValidate());
+ ex.Message.ShouldContain("interval-ms", Case.Insensitive);
+ }
+
+ [Fact]
+ public void Validate_skips_interval_check_when_command_omits_it()
+ {
+ // probe / read / write don't take an --interval-ms option; the validator must
+ // skip that check when the caller passes null.
+ var sut = new Probe { CncHost = "host" };
+ Should.NotThrow(() => sut.InvokeValidateNoInterval());
+ }
+}
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/SubscribeCommandConsoleHandlerTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/SubscribeCommandConsoleHandlerTests.cs
new file mode 100644
index 0000000..8470040
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/SubscribeCommandConsoleHandlerTests.cs
@@ -0,0 +1,55 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
+
+///
+/// Driver.FOCAS.Cli-002: the FOCAS subscribe command — a near-verbatim copy
+/// of the Modbus subscribe command — must
+/// (a) serialise writes from the OnDataChange handler (raised from the
+/// driver's PollGroupEngine background thread) with a lock, so the
+/// "Subscribed to ..." banner write from the CliFx main thread cannot interleave
+/// with the first poll-driven change line; and
+/// (b) carry the explanatory comment that documents why OnDataChange uses
+/// console.Output.WriteLine (synchronous, on a driver background thread)
+/// instead of System.Console or the async WriteLineAsync. The
+/// rationale is non-obvious to a reader and the Modbus copy carries it; the FOCAS
+/// copy must too.
+///
+[Trait("Category", "Unit")]
+public sealed class SubscribeCommandConsoleHandlerTests
+{
+ private static string ReadSubscribeSource()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
+ dir = dir.Parent;
+ dir.ShouldNotBeNull();
+ return File.ReadAllText(Path.Combine(
+ dir!.FullName, "src", "Drivers", "Cli", "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli",
+ "Commands", "SubscribeCommand.cs"));
+ }
+
+ [Fact]
+ public void SubscribeCommand_explains_why_OnDataChange_uses_console_Output_synchronously()
+ {
+ var source = ReadSubscribeSource();
+
+ // The comment must reference the CliFx console abstraction so future copy-pastes
+ // do not lose the rationale.
+ source.ShouldContain("CliFx console");
+ source.ShouldContain("IConsole");
+ }
+
+ [Fact]
+ public void SubscribeCommand_serialises_console_writes_with_a_lock()
+ {
+ var source = ReadSubscribeSource();
+
+ // Both the banner write and the OnDataChange handler must share a writeLock so the
+ // banner from the CliFx invocation thread cannot interleave with the first
+ // poll-driven change line from the driver tick thread.
+ source.ShouldContain("writeLock");
+ source.ShouldContain("lock (writeLock)");
+ }
+}
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/WriteCommandParseValueTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/WriteCommandParseValueTests.cs
new file mode 100644
index 0000000..a73356b
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/WriteCommandParseValueTests.cs
@@ -0,0 +1,122 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
+
+///
+/// Covers across every FOCAS atomic type.
+/// Driver.FOCAS.Cli-001: malformed numeric input must surface as a friendly
+/// , not a raw
+/// / stack trace.
+///
+[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_boolean_aliases(string raw, bool expected)
+ {
+ WriteCommand.ParseValue(raw, FocasDataType.Bit).ShouldBe(expected);
+ }
+
+ [Fact]
+ public void ParseValue_Bit_rejects_garbage_as_CommandException()
+ {
+ Should.Throw(
+ () => WriteCommand.ParseValue("maybe", FocasDataType.Bit));
+ }
+
+ [Fact]
+ public void ParseValue_Byte_signed_range()
+ {
+ // FocasDataType.Byte is signed (PMC byte read returns int8).
+ WriteCommand.ParseValue("-128", FocasDataType.Byte).ShouldBe((sbyte)-128);
+ WriteCommand.ParseValue("127", FocasDataType.Byte).ShouldBe((sbyte)127);
+ }
+
+ [Fact]
+ public void ParseValue_Int16_signed_range()
+ {
+ WriteCommand.ParseValue("-32768", FocasDataType.Int16).ShouldBe(short.MinValue);
+ WriteCommand.ParseValue("32767", FocasDataType.Int16).ShouldBe(short.MaxValue);
+ }
+
+ [Fact]
+ public void ParseValue_Int32_parses_negative()
+ {
+ WriteCommand.ParseValue("-2147483648", FocasDataType.Int32).ShouldBe(int.MinValue);
+ }
+
+ [Fact]
+ public void ParseValue_Float32_invariant_culture()
+ {
+ WriteCommand.ParseValue("3.14", FocasDataType.Float32).ShouldBe(3.14f);
+ }
+
+ [Fact]
+ public void ParseValue_Float64_higher_precision()
+ {
+ WriteCommand.ParseValue("2.718281828", FocasDataType.Float64).ShouldBeOfType();
+ }
+
+ [Fact]
+ public void ParseValue_String_passthrough()
+ {
+ WriteCommand.ParseValue("hello fanuc", FocasDataType.String).ShouldBe("hello fanuc");
+ }
+
+ // Driver.FOCAS.Cli-001: malformed input must produce a CommandException (a clean
+ // one-line CliFx error), NOT a raw FormatException stack trace. Previously the raw
+ // BCL parser exceptions leaked, contradicting how the Bit path already handled bad
+ // boolean input.
+ [Theory]
+ [InlineData("xyz", FocasDataType.Byte)]
+ [InlineData("xyz", FocasDataType.Int16)]
+ [InlineData("xyz", FocasDataType.Int32)]
+ [InlineData("not-a-number", FocasDataType.Float32)]
+ [InlineData("also-bad", FocasDataType.Float64)]
+ public void ParseValue_non_numeric_for_numeric_types_throws_CommandException(
+ string raw, FocasDataType type)
+ {
+ Should.Throw(
+ () => WriteCommand.ParseValue(raw, type));
+ }
+
+ // OverflowException from out-of-range input must also surface as CommandException.
+ [Theory]
+ [InlineData("128", FocasDataType.Byte)] // sbyte max + 1
+ [InlineData("-129", FocasDataType.Byte)] // sbyte min - 1
+ [InlineData("32768", FocasDataType.Int16)] // short max + 1
+ [InlineData("9999999999", FocasDataType.Int32)] // > int max
+ public void ParseValue_overflow_for_numeric_types_throws_CommandException(
+ string raw, FocasDataType type)
+ {
+ Should.Throw(
+ () => WriteCommand.ParseValue(raw, type));
+ }
+
+ [Fact]
+ public void ParseValue_CommandException_message_names_the_type_and_value()
+ {
+ var ex = Should.Throw(
+ () => WriteCommand.ParseValue("xyz", FocasDataType.Int16));
+ ex.Message.ShouldContain("xyz");
+ ex.Message.ShouldContain("Int16");
+ }
+
+ [Theory]
+ [InlineData("R100", FocasDataType.Int16, "R100:Int16")]
+ [InlineData("X0.0", FocasDataType.Bit, "X0.0:Bit")]
+ [InlineData("PARAM:1815/0", FocasDataType.Int32, "PARAM:1815/0:Int32")]
+ [InlineData("MACRO:500", FocasDataType.Float64, "MACRO:500:Float64")]
+ public void SynthesiseTagName_preserves_FOCAS_address_verbatim(
+ string address, FocasDataType type, string expected)
+ {
+ ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
+ }
+}
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests.csproj b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests.csproj
new file mode 100644
index 0000000..923e2f7
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+