# Code Review — Driver.Modbus.Cli

| Field | Value |
|---|---|
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli` |
| Reviewer | Claude Code |
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
| Open findings | 0 |

## Checklist coverage

A comprehensive review completes every category, recording "No issues found" where
a category produced nothing rather than leaving it blank.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.Modbus.Cli-001, Driver.Modbus.Cli-002, Driver.Modbus.Cli-003 |
| 2 | OtOpcUa conventions | No issues found |
| 3 | Concurrency & thread safety | Driver.Modbus.Cli-004 |
| 4 | Error handling & resilience | Driver.Modbus.Cli-005, Driver.Modbus.Cli-006 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | Driver.Modbus.Cli-007 |
| 8 | Code organization & conventions | No issues found |
| 9 | Testing coverage | Driver.Modbus.Cli-008 |
| 10 | Documentation & comments | No issues found |

## Findings

### Driver.Modbus.Cli-001

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:43-51` |
| Status | Resolved |

**Description:** `SubscribeCommand` synthesises its `ModbusTagDefinition` with only
`Name`, `Region`, `Address`, `DataType`, `Writable`, and `ByteOrder` — it never
exposes or passes `--bit-index`, `--string-length`, or `--string-byte-order`.
A user running `subscribe -t BitInRegister` always watches bit 0 regardless of
intent, and `subscribe -t String` runs with `StringLength = 0`. The doc
(`docs/Driver.Modbus.Cli.md`) lists `BitInRegister`, `String`, `Bcd16`, `Bcd32`
in the `subscribe` `--type` help text, so these types are advertised as supported
but cannot be used correctly. `read` and `write` both expose all three flags;
`subscribe` is the odd one out.

**Recommendation:** Add `--bit-index`, `--string-length`, and `--string-byte-order`
options to `SubscribeCommand` (mirroring `ReadCommand`) and pass them into the
`ModbusTagDefinition`, or trim the `--type` help text to the types `subscribe`
actually supports and reject `BitInRegister` / `String` at command entry with a
clear message.

**Resolution:** Resolved 2026-05-22 — added `--bit-index`, `--string-length`, and `--string-byte-order` options to `SubscribeCommand`, mirroring `ReadCommand`, and passed them through to `ModbusTagDefinition` so `BitInRegister` and `String` types subscribe correctly.

### Driver.Modbus.Cli-002

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs:54-89` |
| Status | Resolved |

**Description:** `WriteCommand` rejects read-only regions (`DiscreteInputs` /
`InputRegisters`) but does not validate that `--type` is meaningful for the
`Coils` region. `write -r Coils -a 5 -t UInt16 -v 42` builds a `Coils` tag with
`DataType = UInt16`; the value parses to a boxed `ushort`, and the driver's
`WriteOneAsync` coil branch calls `Convert.ToBoolean(value)` which succeeds for
any non-zero `ushort` (yields `true`). The write silently lands as a coil ON with
no diagnostic, even though the operator asked for a 16-bit register write. A coil
region only supports `Bool`-style boolean values.

**Recommendation:** After the read-only-region check, reject `Region == Coils`
combined with any non-boolean `--type` (anything other than `Bool`), with a
message explaining coils carry a single bit.

**Resolution:** Resolved 2026-05-22 — added a `Region == Coils && DataType != Bool` check immediately after the read-only-region guard, throwing `CommandException` with a message explaining that coils carry a single bit and only `--type Bool` is valid.

### Driver.Modbus.Cli-003

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs:14-24` |
| Status | Resolved |

**Description:** `Port` (`int`) and `TimeoutMs` (`int`) accept any 32-bit value,
including negatives and ports above 65535. `UnitId` is a `byte`, so it accepts
0-255 even though the option description and `docs/Driver.Modbus.Cli.md` both say
the valid range is 1-247 (0 is the Modbus broadcast address; 248-255 are
reserved). A negative `--timeout-ms` becomes a negative `TimeSpan` passed straight
into the driver; an out-of-range `--port` fails later with an opaque socket
error. None of these are validated at parse time.

**Recommendation:** Validate `Port` (1-65535), `TimeoutMs` (greater than 0), and
`UnitId` (1-247) at the top of each command's `ExecuteAsync` (or in
`ModbusCommandBase`), throwing `CliFx.Exceptions.CommandException` with a clear
message — consistent with how `WriteCommand` already rejects bad regions and
boolean strings.

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

| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:61-67` |
| Status | Resolved |

**Description:** The `OnDataChange` handler is invoked from the driver's
`PollGroupEngine` background thread and calls `console.Output.WriteLine`
synchronously. An exception thrown inside this handler (e.g. an `IOException` on a
redirected or closed stdout) propagates on the poll-engine thread and is not
caught — it could fault the background loop. For a long-running `subscribe` this
is a real, if low-probability, crash path. Output lines are also written without
any synchronization, so overlapping poll ticks could interleave partial lines.

**Recommendation:** Wrap the handler body in a `try/catch` that swallows or logs
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.

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

| Field | Value |
|---|---|
| Severity | Low |
| 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` |
| Status | Resolved |

**Description:** All three commands call `ConfigureLogging()` then
`console.RegisterCancellationHandler()`, but if the operator presses Ctrl+C
before `InitializeAsync` completes, the resulting `OperationCancelledException`
propagates out of `ExecuteAsync` unhandled. CliFx renders unhandled non-
`CommandException` exceptions as a full stack trace, which is noisy for what is
just a user-cancelled run. `SubscribeCommand` correctly catches
`OperationCancelledException` around its `Task.Delay`, but the connect/read/write
commands do not catch it around their driver calls.

**Recommendation:** Either let cancellation surface a clean message (catch
`OperationCancelledException` in each command and exit quietly) or document that
the noisy trace on Ctrl+C-during-connect is acceptable. Consistency with
`SubscribeCommand`'s handling is the cleaner choice.

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

| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:35-53` |
| Status | Resolved |

**Description:** `probe` reports `Health: {health.State}` from `GetHealth()`.
After a successful `InitializeAsync` the driver sets state to `Healthy`
regardless of whether the subsequent probe register read returns Good or a Bad
status code. `ReadAsync` does not throw on a Modbus exception response — it
returns a `DataValueSnapshot` with a Bad `StatusCode`. So `probe` against a host
that accepts the TCP connection but rejects FC03 at the probe address prints
`Health: Healthy` while the snapshot line below shows a Bad status. The two lines
disagree, and the headline `Health` value (the thing an operator scans first)
overstates success. The doc bills `probe` as the "is the PLC up + talking Modbus"
check, which the bare `Healthy` does not actually confirm.

**Recommendation:** Have `probe` derive its headline verdict from the probe
snapshot's `StatusCode` (Good vs Bad) rather than — or in addition to — the driver
`State`, or print a single combined verdict line so the two cannot contradict each
other.

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

| Field | Value |
|---|---|
| Severity | Low |
| 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` |
| Status | Resolved |

**Description:** `docs/Driver.Modbus.Cli.md` devotes a whole "v2 addressing
grammar" section to the industry-standard tag-address strings (`40001:F:CDAB`,
`HR1:I`, `C100`, `V2000:F:CDAB`, etc.) and says "set the per-tag `addressString`
field instead of the structured `region` + `address` + `dataType` fields." None of
the CLI commands expose an `--address-string` (or equivalent) flag — `read`,
`write`, and `subscribe` only accept the structured `--region` + `--address` +
`--type` triple. The documented address-string grammar is reachable only through a
hand-written `DriverConfig` JSON, not through this CLI. The doc reads as if the CLI
supports it.

**Recommendation:** Either add an `--address-string` option that feeds the
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
applies to `DriverConfig` JSON and is not a CLI flag.

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

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/` |
| Status | Resolved |

**Description:** The test project covers only the two pure-function seams:
`ReadCommand.SynthesiseTagName` and `WriteCommand.ParseValue`. There is no coverage
for `WriteCommand`'s read-only-region rejection (`Region is not (Coils or
HoldingRegisters)`), no test for `ModbusCommandBase.BuildOptions` (e.g. that
`Probe.Enabled` is `false` and `AutoReconnect` tracks `--disable-reconnect`), and
no test asserting unsupported write types throw. The branch logic in
`WriteCommand.ExecuteAsync` and `ModbusCommandBase.BuildOptions` is the part most
likely to regress and is currently untested. The validation gaps in findings
002/003 are also untested precisely because no test exercises that path.

**Recommendation:** Add tests for `WriteCommand`'s region-validation branch and for
`ModbusCommandBase.BuildOptions` (construct a command instance via the `init`
setters and assert the produced `ModbusDriverOptions`). Once findings 002/003 are
fixed, add tests for the new validation paths.

**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.
