fix(driver-modbus-cli): resolve Low code-review findings (Driver.Modbus.Cli-003,004,005,006,007,008)

- Driver.Modbus.Cli-003: ModbusCommandBase.ValidateEndpoint rejects
  --port outside 1..65535, non-positive --timeout-ms, and --unit-id
  outside 1..247.
- Driver.Modbus.Cli-004: wrapped SubscribeCommand's OnDataChange handler
  body in a try/catch (warn-and-swallow) and serialised the console
  write through a lock.
- Driver.Modbus.Cli-005: Probe / Read / Write now catch the
  cancellation-during-init OperationCanceledException and print
  'Cancelled.' instead of dumping a stack trace.
- Driver.Modbus.Cli-006: ProbeCommand.ComputeVerdict derives the headline
  from BOTH the driver state and the probe snapshot's OPC UA quality
  class so the headline can't disagree with the wire result.
- Driver.Modbus.Cli-007: docs/Driver.Modbus.Cli.md carries an explicit
  'CLI scope' callout — the address-string grammar is a DriverConfig
  JSON feature; the CLI takes the structured triple only.
- Driver.Modbus.Cli-008: pinned BuildOptions, ValidateEndpoint, the
  region-validation guards, ComputeVerdict, and the cancellation-during-
  initialize paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:35:05 -04:00
parent f2ee027145
commit 80ef8806e0
11 changed files with 533 additions and 18 deletions

View File

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 | | Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` | | Commit reviewed | `76d35d1` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 6 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -87,7 +87,7 @@ message explaining coils carry a single bit.
| Severity | Low | | Severity | Low |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs:14-24` | | Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs:14-24` |
| Status | Open | | Status | Resolved |
**Description:** `Port` (`int`) and `TimeoutMs` (`int`) accept any 32-bit value, **Description:** `Port` (`int`) and `TimeoutMs` (`int`) accept any 32-bit value,
including negatives and ports above 65535. `UnitId` is a `byte`, so it accepts including negatives and ports above 65535. `UnitId` is a `byte`, so it accepts
@@ -103,7 +103,13 @@ error. None of these are validated at parse time.
message — consistent with how `WriteCommand` already rejects bad regions and message — consistent with how `WriteCommand` already rejects bad regions and
boolean strings. boolean strings.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — added `ModbusCommandBase.ValidateEndpoint()`
that throws `CliFx.Exceptions.CommandException` for `--port` outside 1..65535,
non-positive `--timeout-ms`, and `--unit-id` outside 1..247 (Modbus spec unicast
range — rejects the broadcast 0 and reserved 248-255). Each of `ProbeCommand`,
`ReadCommand`, `WriteCommand`, and `SubscribeCommand` now calls it at the top of
`ExecuteAsync` after `ConfigureLogging()`. Regression tests live in
`ModbusCommandBaseTests` (range + boundary cases for all three knobs).
### Driver.Modbus.Cli-004 ### Driver.Modbus.Cli-004
@@ -113,7 +119,7 @@ boolean strings.
| Severity | Low | | Severity | Low |
| Category | Concurrency & thread safety | | Category | Concurrency & thread safety |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:61-67` | | Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:61-67` |
| Status | Open | | Status | Resolved |
**Description:** The `OnDataChange` handler is invoked from the driver's **Description:** The `OnDataChange` handler is invoked from the driver's
`PollGroupEngine` background thread and calls `console.Output.WriteLine` `PollGroupEngine` background thread and calls `console.Output.WriteLine`
@@ -127,7 +133,11 @@ any synchronization, so overlapping poll ticks could interleave partial lines.
write failures so a transient console-write error cannot tear down the poll loop. write failures so a transient console-write error cannot tear down the poll loop.
A single `lock` around the write also removes the interleave risk. A single `lock` around the write also removes the interleave risk.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — wrapped the `OnDataChange` handler in
`try/catch (Exception)` that logs to Serilog at Warning and swallows the failure
so a transient console-write error cannot fault the `PollGroupEngine` background
loop. The console write is also serialized through a local `lock` object, removing
the partial-line interleave risk when multiple poll ticks overlap.
### Driver.Modbus.Cli-005 ### Driver.Modbus.Cli-005
@@ -136,7 +146,7 @@ A single `lock` around the write also removes the interleave risk.
| Severity | Low | | Severity | Low |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:21-54`; `Commands/ReadCommand.cs:46-75`; `Commands/WriteCommand.cs:54-89` | | Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:21-54`; `Commands/ReadCommand.cs:46-75`; `Commands/WriteCommand.cs:54-89` |
| Status | Open | | Status | Resolved |
**Description:** All three commands call `ConfigureLogging()` then **Description:** All three commands call `ConfigureLogging()` then
`console.RegisterCancellationHandler()`, but if the operator presses Ctrl+C `console.RegisterCancellationHandler()`, but if the operator presses Ctrl+C
@@ -152,7 +162,14 @@ commands do not catch it around their driver calls.
the noisy trace on Ctrl+C-during-connect is acceptable. Consistency with the noisy trace on Ctrl+C-during-connect is acceptable. Consistency with
`SubscribeCommand`'s handling is the cleaner choice. `SubscribeCommand`'s handling is the cleaner choice.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — added a
`catch (OperationCanceledException) when (ct.IsCancellationRequested)` clause
around the driver-call bodies in `ProbeCommand`, `ReadCommand`, and `WriteCommand`
that prints `Cancelled.` and falls through to the existing `finally`-block
shutdown. Matches `SubscribeCommand`'s existing handling so all four commands now
exit quietly on Ctrl+C. Regression tests in `CommandCancellationTests` pre-cancel
the CliFx `FakeInMemoryConsole` before calling `ExecuteAsync` and assert no
exception escapes.
### Driver.Modbus.Cli-006 ### Driver.Modbus.Cli-006
@@ -161,7 +178,7 @@ the noisy trace on Ctrl+C-during-connect is acceptable. Consistency with
| Severity | Low | | Severity | Low |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:35-53` | | Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:35-53` |
| Status | Open | | Status | Resolved |
**Description:** `probe` reports `Health: {health.State}` from `GetHealth()`. **Description:** `probe` reports `Health: {health.State}` from `GetHealth()`.
After a successful `InitializeAsync` the driver sets state to `Healthy` After a successful `InitializeAsync` the driver sets state to `Healthy`
@@ -179,7 +196,14 @@ snapshot's `StatusCode` (Good vs Bad) rather than — or in addition to — the
`State`, or print a single combined verdict line so the two cannot contradict each `State`, or print a single combined verdict line so the two cannot contradict each
other. other.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — `ProbeCommand` now prints a single combined
`Verdict:` line above the bare driver-state line; the headline is computed by a
new `ProbeCommand.ComputeVerdict(DriverState, statusCode)` helper that combines
the driver state with the probe snapshot's OPC UA quality class (top 2 bits of
the StatusCode). Verdict is `FAIL` whenever the driver is not Healthy OR the
snapshot is Bad, `DEGRADED` when the driver is Healthy but the snapshot is
Uncertain, and `OK` only when both are Good. Regression tests in
`ProbeCommandTests` pin the verdict-grid behaviour.
### Driver.Modbus.Cli-007 ### Driver.Modbus.Cli-007
@@ -188,7 +212,7 @@ other.
| Severity | Low | | Severity | Low |
| Category | Design-document adherence | | Category | Design-document adherence |
| Location | `docs/Driver.Modbus.Cli.md:124-156`; `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs` | | Location | `docs/Driver.Modbus.Cli.md:124-156`; `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs` |
| Status | Open | | Status | Resolved |
**Description:** `docs/Driver.Modbus.Cli.md` devotes a whole "v2 addressing **Description:** `docs/Driver.Modbus.Cli.md` devotes a whole "v2 addressing
grammar" section to the industry-standard tag-address strings (`40001:F:CDAB`, grammar" section to the industry-standard tag-address strings (`40001:F:CDAB`,
@@ -205,7 +229,14 @@ driver's address-string parser (and `--family` for the DL205/MELSEC native
syntax), or scope the "v2 addressing grammar" section of the doc to note it syntax), or scope the "v2 addressing grammar" section of the doc to note it
applies to `DriverConfig` JSON and is not a CLI flag. applies to `DriverConfig` JSON and is not a CLI flag.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — chose the doc-scoping fix (an
`--address-string` flag is a bigger feature that warrants its own design
discussion). Added a "CLI scope" callout to the top of the
`docs/Driver.Modbus.Cli.md` "v2 addressing grammar" section stating the CLI
accepts only the structured `--region` + `--address` + `--type` triple and that
the address-string grammar is a `DriverConfig` JSON feature. The pre-existing
closing paragraph already said the same thing; the new callout makes it visible
before the grammar examples instead of after them. Code surface left unchanged.
### Driver.Modbus.Cli-008 ### Driver.Modbus.Cli-008
@@ -214,7 +245,7 @@ applies to `DriverConfig` JSON and is not a CLI flag.
| Severity | Low | | Severity | Low |
| Category | Testing coverage | | Category | Testing coverage |
| Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/` | | Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/` |
| Status | Open | | Status | Resolved |
**Description:** The test project covers only the two pure-function seams: **Description:** The test project covers only the two pure-function seams:
`ReadCommand.SynthesiseTagName` and `WriteCommand.ParseValue`. There is no coverage `ReadCommand.SynthesiseTagName` and `WriteCommand.ParseValue`. There is no coverage
@@ -231,4 +262,19 @@ likely to regress and is currently untested. The validation gaps in findings
setters and assert the produced `ModbusDriverOptions`). Once findings 002/003 are setters and assert the produced `ModbusDriverOptions`). Once findings 002/003 are
fixed, add tests for the new validation paths. fixed, add tests for the new validation paths.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — added four new test classes covering the
previously untested branch logic:
- `ModbusCommandBaseTests` — six tests over `BuildOptions` (probe disabled,
`TimeoutMs``Timeout` mapping, `AutoReconnect` tracking
`--disable-reconnect` in both directions, host/port/unit flow-through, tag
pass-through) plus the new `ValidateEndpoint` range-check tests for finding
003 (port 1..65535, timeout-ms > 0, unit-id 1..247).
- `WriteCommandRegionValidationTests` — read-only region rejection
(DiscreteInputs / InputRegisters) and the Coils-non-Bool guard for finding
002.
- `ProbeCommandTests` — the new `ComputeVerdict` helper for finding 006
(OK / DEGRADED / FAIL grid).
- `CommandCancellationTests` — Ctrl+C-during-initialize for `ProbeCommand` /
`ReadCommand` / `WriteCommand` for finding 005.
Total test count grew from 18 to 64; all pass.

View File

@@ -122,6 +122,14 @@ gives plausible values is the correct one for that device.
## v2 addressing grammar ## v2 addressing grammar
> **CLI scope:** the `read` / `write` / `subscribe` commands accept only
> the structured `--region` + `--address` + `--type` triple. The
> address-string grammar below is a **`DriverConfig` JSON** feature
> consumed by the driver itself; it is not reachable from this CLI's
> flags. To experiment with it via the CLI, use the structured flags;
> to deploy spreadsheets as-is, hand-author a `DriverConfig` and run
> the server.
The driver accepts the industry-standard tag-address grammar so you can The driver accepts the industry-standard tag-address grammar so you can
paste tag spreadsheets from Wonderware / Kepware / Ignition without paste tag spreadsheets from Wonderware / Kepware / Ignition without
per-row manual translation. Full reference + grammar rules: per-row manual translation. Full reference + grammar rules:

View File

@@ -1,5 +1,6 @@
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
@@ -21,6 +22,7 @@ public sealed class ProbeCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
ConfigureLogging(); ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler(); var ct = console.RegisterCancellationHandler();
// Build with one probe tag + Probe.Enabled=false so InitializeAsync connects the // Build with one probe tag + Probe.Enabled=false so InitializeAsync connects the
@@ -39,17 +41,59 @@ public sealed class ProbeCommand : ModbusCommandBase
var snapshot = await driver.ReadAsync(["__probe"], ct); var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth(); var health = driver.GetHealth();
// Driver.Modbus.Cli-006: derive the headline verdict from BOTH the driver state
// AND the probe-read StatusCode so the operator never sees the previous
// contradictory pair (`Health: Healthy` over a Bad snapshot line). The bare
// driver state is still printed below for diagnostics, but the verdict is what
// the operator scans first.
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
await console.Output.WriteLineAsync($"Host: {Host}:{Port} (unit {UnitId})"); await console.Output.WriteLineAsync($"Host: {Host}:{Port} (unit {UnitId})");
await console.Output.WriteLineAsync($"Health: {health.State}"); await console.Output.WriteLineAsync($"Verdict: {verdict}");
await console.Output.WriteLineAsync($"Driver state: {health.State}");
if (health.LastError is { } err) if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}"); await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync(); await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync( await console.Output.WriteLineAsync(
SnapshotFormatter.Format($"HR[{ProbeAddress}]", snapshot[0])); SnapshotFormatter.Format($"HR[{ProbeAddress}]", snapshot[0]));
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.Modbus.Cli-005: Ctrl+C during InitializeAsync — exit quietly so CliFx
// does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
finally finally
{ {
await driver.ShutdownAsync(CancellationToken.None); await driver.ShutdownAsync(CancellationToken.None);
} }
} }
/// <summary>
/// Driver.Modbus.Cli-006: combine the driver-side <see cref="DriverState"/> with the
/// probe snapshot's OPC UA <c>StatusCode</c> into a single headline verdict. Order
/// of precedence:
/// <list type="number">
/// <item><c>FAIL</c> — driver did not reach <see cref="DriverState.Healthy"/>
/// (Faulted / Reconnecting / Unknown) OR the snapshot reports Bad quality.</item>
/// <item><c>DEGRADED</c> — driver Healthy but the snapshot quality is Uncertain.</item>
/// <item><c>OK</c> — driver Healthy and snapshot Good.</item>
/// </list>
/// </summary>
public static string ComputeVerdict(DriverState state, uint statusCode)
{
// OPC UA StatusCode top 2 bits encode the quality class:
// 0x00xxxxxx → Good, 0x40xxxxxx → Uncertain, 0x80xxxxxx / 0xC0xxxxxx → Bad.
var qualityClass = statusCode & 0xC0000000u;
var snapshotGood = qualityClass == 0x00000000u;
var snapshotUncertain = qualityClass == 0x40000000u;
if (state != DriverState.Healthy || !snapshotGood && !snapshotUncertain)
return $"FAIL (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
if (snapshotUncertain)
return $"DEGRADED (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
return $"OK (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
}
} }

View File

@@ -46,6 +46,7 @@ public sealed class ReadCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
ConfigureLogging(); ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler(); var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Region, Address, DataType); var tagName = SynthesiseTagName(Region, Address, DataType);
@@ -68,6 +69,12 @@ public sealed class ReadCommand : ModbusCommandBase
var snapshot = await driver.ReadAsync([tagName], ct); var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(tagName, snapshot[0])); await console.Output.WriteLineAsync(SnapshotFormatter.Format(tagName, snapshot[0]));
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.Modbus.Cli-005: Ctrl+C during driver connect/read — exit quietly so
// CliFx does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
finally finally
{ {
await driver.ShutdownAsync(CancellationToken.None); await driver.ShutdownAsync(CancellationToken.None);

View File

@@ -53,6 +53,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
ConfigureLogging(); ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler(); var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType); var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
@@ -69,6 +70,9 @@ public sealed class SubscribeCommand : ModbusCommandBase
var options = BuildOptions([tag]); var options = BuildOptions([tag]);
await using var driver = new ModbusDriver(options, DriverInstanceId); await using var driver = new ModbusDriver(options, DriverInstanceId);
// Driver.Modbus.Cli-004: serialize console writes from the PollGroupEngine background
// thread so overlapping poll ticks can't interleave partial lines.
var writeLock = new object();
ISubscriptionHandle? handle = null; ISubscriptionHandle? handle = null;
try try
{ {
@@ -77,11 +81,27 @@ public sealed class SubscribeCommand : ModbusCommandBase
// Route every data-change event to the CliFx console (not System.Console — the // Route every data-change event to the CliFx console (not System.Console — the
// analyzer flags it + IConsole is the testable abstraction). // analyzer flags it + IConsole is the testable abstraction).
driver.OnDataChange += (_, e) => driver.OnDataChange += (_, e) =>
{
// Driver.Modbus.Cli-004: 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}] " + var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " + $"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})"; $"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
lock (writeLock)
{
console.Output.WriteLine(line); 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); handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);

View File

@@ -54,6 +54,7 @@ public sealed class WriteCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
ConfigureLogging(); ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler(); var ct = console.RegisterCancellationHandler();
if (Region is not (ModbusRegion.Coils or ModbusRegion.HoldingRegisters)) if (Region is not (ModbusRegion.Coils or ModbusRegion.HoldingRegisters))
@@ -92,6 +93,12 @@ public sealed class WriteCommand : ModbusCommandBase
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct); var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(tagName, results[0])); await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(tagName, results[0]));
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.Modbus.Cli-005: Ctrl+C during driver connect/write — exit quietly so
// CliFx does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
finally finally
{ {
await driver.ShutdownAsync(CancellationToken.None); await driver.ShutdownAsync(CancellationToken.None);

View File

@@ -1,4 +1,5 @@
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
@@ -57,4 +58,33 @@ public abstract class ModbusCommandBase : DriverCommandBase
/// multiple endpoints in parallel can distinguish the logs. /// multiple endpoints in parallel can distinguish the logs.
/// </summary> /// </summary>
protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}"; protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}";
/// <summary>
/// Driver.Modbus.Cli-003: validate the endpoint flags at parse time so the operator
/// gets a clear CliFx error instead of an opaque socket / argument exception thrown
/// deep inside the driver. Ranges:
/// <list type="bullet">
/// <item><c>--port</c>: 1..65535 (IANA TCP port space, excludes the
/// "any" sentinel 0 and rejects negative / overflowed values).</item>
/// <item><c>--timeout-ms</c>: strictly positive — a non-positive
/// <see cref="TimeSpan"/> would propagate as an immediate-timeout into the
/// driver and surface as a confusing TimeoutException.</item>
/// <item><c>--unit-id</c>: 1..247 — the Modbus spec unicast unit-id range.
/// 0 is the broadcast address and not valid for read/write requests; 248-255
/// are reserved. (Documented in <c>docs/Driver.Modbus.Cli.md</c>.)</item>
/// </list>
/// </summary>
protected void ValidateEndpoint()
{
if (Port < 1 || Port > 65535)
throw new CommandException(
$"--port must be in 1..65535 (got {Port}).");
if (TimeoutMs <= 0)
throw new CommandException(
$"--timeout-ms must be strictly positive (got {TimeoutMs}).");
if (UnitId < 1 || UnitId > 247)
throw new CommandException(
$"--unit-id must be in 1..247 per the Modbus spec (got {UnitId}); " +
$"0 is the broadcast address, 248-255 are reserved.");
}
} }

View File

@@ -0,0 +1,66 @@
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers Driver.Modbus.Cli-005: <c>probe</c> / <c>read</c> / <c>write</c> must swallow
/// <see cref="OperationCanceledException"/> so a Ctrl+C during InitializeAsync exits
/// cleanly instead of dumping a full stack trace through CliFx. <c>SubscribeCommand</c>
/// already handles this around its <c>Task.Delay</c>; these tests pin the same behaviour
/// to the connect/read/write commands.
/// The test pre-cancels the CliFx <see cref="FakeInMemoryConsole"/>; the driver's
/// <c>ConnectAsync</c> observes the token via <c>Dns.GetHostAddressesAsync</c> and throws
/// OCE before any socket I/O happens, so the test is hermetic — no real PLC needed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CommandCancellationTests
{
[Fact]
public async Task ProbeCommand_swallows_cancellation_during_initialize()
{
using var console = new FakeInMemoryConsole();
console.RequestCancellation(); // simulate Ctrl+C before ExecuteAsync runs
var sut = new ProbeCommand { Host = "127.0.0.1" };
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
[Fact]
public async Task ReadCommand_swallows_cancellation_during_initialize()
{
using var console = new FakeInMemoryConsole();
console.RequestCancellation();
var sut = new ReadCommand
{
Host = "127.0.0.1",
Region = ModbusRegion.HoldingRegisters,
Address = 0,
DataType = ModbusDataType.UInt16,
};
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
[Fact]
public async Task WriteCommand_swallows_cancellation_during_initialize()
{
using var console = new FakeInMemoryConsole();
console.RequestCancellation();
var sut = new WriteCommand
{
Host = "127.0.0.1",
Region = ModbusRegion.HoldingRegisters,
Address = 0,
DataType = ModbusDataType.UInt16,
Value = "42",
};
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
}

View File

@@ -0,0 +1,164 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers <see cref="ModbusCommandBase.BuildOptions"/> — the pure, deterministic mapping
/// from the base's host / port / unit-id / timeout / disable-reconnect flags onto a
/// <c>ModbusDriverOptions</c>. The CLI is one-shot so the background connectivity probe
/// must be disabled; <c>AutoReconnect</c> is the inverse of <c>--disable-reconnect</c>.
/// Also covers the input-range validation introduced for Driver.Modbus.Cli-003.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCommandBaseTests
{
// Test-only ModbusCommandBase concrete subclass that exposes the protected BuildOptions
// helper + ValidateEndpoint. 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 ModbusCommandBase.BuildOptions.")]
private sealed class ProbeOnly : ModbusCommandBase
{
public override ValueTask ExecuteAsync(IConsole console) => default;
public ModbusDriverOptions Invoke(IReadOnlyList<ModbusTagDefinition> tags) => BuildOptions(tags);
public void InvokeValidate() => ValidateEndpoint();
}
[Fact]
public void BuildOptions_disables_probe_for_one_shot_cli_runs()
{
var sut = new ProbeOnly { Host = "10.0.0.5" };
var options = sut.Invoke([]);
options.Probe.ShouldNotBeNull();
options.Probe.Enabled.ShouldBeFalse();
}
[Fact]
public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
{
var sut = new ProbeOnly { Host = "h", TimeoutMs = 7500 };
var options = sut.Invoke([]);
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
}
[Fact]
public void BuildOptions_AutoReconnect_defaults_to_true_when_flag_unset()
{
var sut = new ProbeOnly { Host = "h" };
var options = sut.Invoke([]);
options.AutoReconnect.ShouldBeTrue();
}
[Fact]
public void BuildOptions_AutoReconnect_becomes_false_when_disable_reconnect_flag_set()
{
var sut = new ProbeOnly { Host = "h", DisableAutoReconnect = true };
var options = sut.Invoke([]);
options.AutoReconnect.ShouldBeFalse();
}
[Fact]
public void BuildOptions_flows_host_port_unit_through()
{
var sut = new ProbeOnly { Host = "plc.shop.local", Port = 5020, UnitId = 17, TimeoutMs = 3000 };
var options = sut.Invoke([]);
options.Host.ShouldBe("plc.shop.local");
options.Port.ShouldBe(5020);
options.UnitId.ShouldBe((byte)17);
}
[Fact]
public void BuildOptions_forwards_tag_list_verbatim()
{
var sut = new ProbeOnly { Host = "h" };
var tag = new ModbusTagDefinition(
Name: "T", Region: ModbusRegion.HoldingRegisters, Address: 0, DataType: ModbusDataType.UInt16);
var options = sut.Invoke([tag]);
options.Tags.Count.ShouldBe(1);
options.Tags[0].ShouldBeSameAs(tag);
}
// --- Driver.Modbus.Cli-003: parse-time endpoint validation -------------------------------
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(65536)]
[InlineData(int.MinValue)]
[InlineData(int.MaxValue)]
public void ValidateEndpoint_rejects_port_outside_1_to_65535(int port)
{
var sut = new ProbeOnly { Host = "h", Port = port };
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
[Theory]
[InlineData(1)]
[InlineData(502)]
[InlineData(65535)]
public void ValidateEndpoint_accepts_port_in_range(int port)
{
var sut = new ProbeOnly { Host = "h", Port = port };
Should.NotThrow(() => sut.InvokeValidate());
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-2000)]
public void ValidateEndpoint_rejects_non_positive_timeout(int timeoutMs)
{
var sut = new ProbeOnly { Host = "h", TimeoutMs = timeoutMs };
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
[Theory]
[InlineData(0)] // broadcast — disallowed for unicast read/write requests
[InlineData(248)]
[InlineData(255)]
public void ValidateEndpoint_rejects_unit_id_outside_1_to_247(byte unitId)
{
var sut = new ProbeOnly { Host = "h", UnitId = unitId };
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
[Theory]
[InlineData(1)]
[InlineData(247)]
[InlineData(50)]
public void ValidateEndpoint_accepts_unit_id_in_range(byte unitId)
{
var sut = new ProbeOnly { Host = "h", UnitId = unitId };
Should.NotThrow(() => sut.InvokeValidate());
}
[Fact]
public void ValidateEndpoint_accepts_default_options()
{
// Defaults: Port=502, UnitId=1, TimeoutMs=2000. All inside the valid ranges.
var sut = new ProbeOnly { Host = "h" };
Should.NotThrow(() => sut.InvokeValidate());
}
}

View File

@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers <see cref="ProbeCommand.ComputeVerdict"/> — the headline-string helper that
/// combines the driver-side <see cref="DriverState"/> with the probe snapshot's OPC UA
/// <see cref="DataValueSnapshot.StatusCode"/> so the operator never sees the previous
/// contradictory pair (`Health: Healthy` above a `Status: Bad...` snapshot line — see
/// Driver.Modbus.Cli-006).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ProbeCommandTests
{
[Fact]
public void ComputeVerdict_returns_OK_when_state_is_Healthy_and_status_is_Good()
{
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode: 0x00000000u);
verdict.ShouldContain("OK");
}
[Theory]
[InlineData(DriverState.Faulted)]
[InlineData(DriverState.Reconnecting)]
[InlineData(DriverState.Unknown)]
public void ComputeVerdict_returns_FAIL_when_driver_state_is_not_Healthy(DriverState state)
{
var verdict = ProbeCommand.ComputeVerdict(state, statusCode: 0x00000000u);
verdict.ShouldContain("FAIL");
}
[Theory]
[InlineData(0x80050000u)] // BadCommunicationError
[InlineData(0x800A0000u)] // BadTimeout
[InlineData(0x80740000u)] // BadTypeMismatch
[InlineData(0x80000000u)] // generic Bad
public void ComputeVerdict_returns_FAIL_when_snapshot_status_is_Bad_even_if_driver_state_Healthy(uint statusCode)
{
// Driver.Modbus.Cli-006: a successful InitializeAsync sets DriverState.Healthy, but
// the FC03 probe read may still fail (snapshot.StatusCode != Good). Previously the
// headline reported Healthy while the snapshot line below showed Bad. The verdict
// must reflect the actual probe-read outcome.
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode);
verdict.ShouldContain("FAIL");
}
[Fact]
public void ComputeVerdict_returns_DEGRADED_for_uncertain_status_with_healthy_driver()
{
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode: 0x40000000u);
verdict.ShouldContain("DEGRADED");
}
}

View File

@@ -0,0 +1,63 @@
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers the branch validation inside <see cref="WriteCommand.ExecuteAsync"/>:
/// 1. Driver.Modbus.Cli-002 — write to <see cref="ModbusRegion.Coils"/> must use
/// <c>--type Bool</c>.
/// 2. Read-only regions (DiscreteInputs / InputRegisters) reject any write.
/// The actual driver call is never reached for these guard cases — they throw a
/// <see cref="CliFx.Exceptions.CommandException"/> before the driver is constructed,
/// so we can exercise <c>ExecuteAsync</c> against an unreachable host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class WriteCommandRegionValidationTests
{
[Theory]
[InlineData(ModbusRegion.DiscreteInputs, ModbusDataType.Bool, "0")]
[InlineData(ModbusRegion.InputRegisters, ModbusDataType.UInt16, "1")]
public async Task ExecuteAsync_rejects_read_only_regions(
ModbusRegion region, ModbusDataType type, string value)
{
var sut = new WriteCommand
{
// Host is required, but the guard fires before any socket use.
Host = "127.0.0.1",
Region = region,
Address = 0,
DataType = type,
Value = value,
};
using var console = new FakeInMemoryConsole();
await Should.ThrowAsync<CliFx.Exceptions.CommandException>(
async () => await sut.ExecuteAsync(console));
}
[Theory]
[InlineData(ModbusDataType.UInt16)]
[InlineData(ModbusDataType.Int16)]
[InlineData(ModbusDataType.Float32)]
[InlineData(ModbusDataType.Int32)]
public async Task ExecuteAsync_rejects_non_Bool_type_for_Coils_region(ModbusDataType type)
{
var sut = new WriteCommand
{
Host = "127.0.0.1",
Region = ModbusRegion.Coils,
Address = 5,
DataType = type,
Value = "42",
};
using var console = new FakeInMemoryConsole();
var ex = await Should.ThrowAsync<CliFx.Exceptions.CommandException>(
async () => await sut.ExecuteAsync(console));
ex.Message.ShouldContain("Coils");
ex.Message.ShouldContain("Bool");
}
}