# `otopcua-modbus-cli` — Modbus-TCP test client

Ad-hoc probe / read / write / subscribe tool for talking to Modbus-TCP devices
through the **same** `ModbusDriver` the OtOpcUa server uses. Mirrors the v1
OPC UA `otopcua-cli` shape so the muscle memory carries over: drop to a shell,
point at a PLC, watch registers move.

First of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
TwinCAT). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library
so each downstream CLI inherits verbose/log wiring + snapshot formatting
without copy-paste.

## Build + run

```powershell
dotnet build src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli
dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -- --help
```

Or publish a self-contained binary:

```powershell
dotnet publish src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -c Release -o publish/modbus-cli
publish/modbus-cli/otopcua-modbus-cli.exe --help
```

## Common flags

Every command accepts:

| Flag | Default | Purpose |
|---|---|---|
| `-h` / `--host` | **required** | Modbus-TCP server hostname or IP |
| `-p` / `--port` | `502` | TCP port |
| `-U` / `--unit-id` | `1` | Modbus unit / slave ID |
| `--timeout-ms` | `2000` | Per-PDU timeout |
| `--disable-reconnect` | off | Turn off mid-transaction reconnect-and-retry |
| `--verbose` | off | Serilog debug output |

## Commands

### `probe` — is the PLC up?

Connects, reads one holding register, prints driver health. Fastest sanity
check after swapping a network cable or deploying a new device.

```powershell
otopcua-modbus-cli probe -h 192.168.1.10
otopcua-modbus-cli probe -h 192.168.1.10 --probe-address 100    # device locks HR[0]
```

### `read` — single register / coil / string

Synthesises a one-tag driver config on the fly from `--region` + `--address`
+ `--type` flags.

```powershell
# Holding register as UInt16
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16

# Float32 with word-swap (CDAB) — common on Siemens / some AB families
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 --byte-order WordSwap

# Single bit out of a packed holding register
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 10 -t BitInRegister --bit-index 3

# 40-char ASCII string — DirectLOGIC packs the first char in the low byte
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 300 -t String --string-length 40 --string-byte-order LowByteFirst

# Discrete input / coil
otopcua-modbus-cli read -h 192.168.1.10 -r DiscreteInputs -a 5 -t Bool
```

### `write` — single value

Same flag shape as `read` plus `-v` / `--value`. Values parse per `--type`
using invariant culture (period as decimal separator). Booleans accept
`true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.

```powershell
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16 -v 42
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 -v 3.14
otopcua-modbus-cli write -h 192.168.1.10 -r Coils -a 5 -t Bool -v on
```

**Writes are non-idempotent by default** — a timeout after the device
already applied the write will NOT auto-retry. This matches the driver's
production contract (plan decisions #44 + #45).

### `subscribe` — watch a register until Ctrl+C

Uses the driver's `ISubscribable` surface (polling under the hood via
`PollGroupEngine`). Prints every data-change event with a timestamp.

```powershell
otopcua-modbus-cli subscribe -h 192.168.1.10 -r HoldingRegisters -a 100 -t Int16 -i 500
```

## Output format

- `probe` / `read` emit a multi-line per-tag block: `Tag / Value / Status /
  Source Time / Server Time`.
- `write` emits one line: `Write <tag>: 0x... (Good | BadCommunicationError | …)`.
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <tag> = <value> (<status>)`.

Status codes are rendered as `0xXXXXXXXX (Name)` for the OPC UA shortlist
(`Good`, `BadCommunicationError`, `BadTimeout`, `BadNodeIdUnknown`,
`BadTypeMismatch`, `Uncertain`, …). Unknown codes fall back to bare hex.

## Typical workflows

**"Is the PLC alive?"** → `probe`.

**"Does my recipe write land?"** → `write` + `read` back against the same
address.

**"Why is tag X flipping?"** → `subscribe` + wait for the operator scenario.

**"What's the right byte order for this family?"** → `read` with
`--byte-order BigEndian`, then with `--byte-order WordSwap`. The one that
gives plausible values is the correct one for that device.

## 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
paste tag spreadsheets from Wonderware / Kepware / Ignition without
per-row manual translation. Full reference + grammar rules:
[`docs/v2/modbus-addressing.md`](v2/modbus-addressing.md).

Quick examples:

```
40001                  HoldingRegisters[0], Int16
400001                 same, 6-digit form
40001:F                Float32
40001:F:CDAB           Float32 word-swapped
40001:STR20            20-char ASCII string
40001:S:5              Int16[5] array (3-field shorthand)
40001:F:CDAB:10        Float32[10] with explicit word-swap (4-field strict)
40001.5                bit 5 of HR[0]
HR1:I                  Int32 via mnemonic region prefix (matches Wonderware)
C100                   Coil 100 (mnemonic, 1-based)
V2000:F:CDAB           DL205 V-memory at PDU 1024 + Float32 + word-swap (Family=DL205)
D100:I                 MELSEC D-register 100, Int32 (Family=MELSEC)
```

**Type-code reminder** (post-#146): `:I` is **Int32** (matches Wonderware
DASMBTCP + Ignition `HRI`). The explicit Int16 code is `:S`. Bare HR/IR
with no type still defaults to Int16. Pre-#146 codes `:DI` / `:L` /
`:UDI` / `:UL` / `:LI` / `:ULI` / `:LBCD` are removed; configs that use
them get a clear "Unknown type code" diagnostic at parse time.

In `DriverConfig` JSON, set the per-tag `addressString` field instead of
the structured `region` + `address` + `dataType` fields. Both styles can
coexist within one driver instance.
