Web verification (2026-04-25) against current vendor docs surfaced concrete grammar conflicts in the v1 suffix grammar shipped in #137. Hard cutover before the Admin UI rolls out widely so users don't paste `:I` from a Wonderware spreadsheet and silently get wrong-typed reads. Sources: - Wonderware DASMBTCP user guide https://cdn.logic-control.com/media/DASMBTCP.pdf - Ignition Modbus addressing (8.1) https://www.docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/opc-ua-drivers/modbus/modbus-addressing Type-code changes: | Code | Pre-#146 | Post-#146 | Vendor reference | |--------|----------|------------|------------------------------| | `:S` | (n/a) | Int16 | Wonderware DASMBTCP `S` | | `:US` | (n/a) | UInt16 | Ignition `HRUS` | | `:I` | Int16 | **Int32** | Wonderware `I` + Ignition `HRI` | | `:UI` | UInt16 | **UInt32** | Ignition `HRUI` | | `:I_64` | (n/a) | Int64 | Ignition `HRI_64` | | `:UI_64` | (n/a) | UInt64 | Ignition `HRUI_64` | | `:BCD_32`| (n/a) | BCD32 | Ignition `HRBCD_32` | Codes REMOVED (no clear vendor precedent + conflict with the new mapping): `:DI`, `:L`, `:UDI`, `:UL`, `:LI`, `:ULI`, `:LBCD`. Pre-#146 configs that use them get an "Unknown type code" diagnostic at parse time so users get a fast surface-level error rather than silent wrong-typed reads. Codes UNCHANGED (already vendor-aligned): `:BOOL`, `:F`, `:D`, `:BCD`, `:STR<n>`. Modicon 5/6-digit + mnemonic regions (HR/IR/C/DI) + bit suffix `.N` are also unchanged. Defaults: - Coils / DiscreteInputs → `BOOL` (unchanged) - HoldingRegisters / InputRegisters with no explicit type → Int16 (matches Ignition's bare `HR` default) Byte-order mnemonics (`:ABCD` / `:CDAB` / `:BADC` / `:DCBA`) are kept but documented as OtOpcUa-specific — they aren't in any major vendor's per-tag address string. Ignition uses a `-R` suffix per prefix; Wonderware configures word-order at the topic level. Tests: - 12 Type_Codes_Parse rows updated to assert the new mappings. - New Removed_Aliases_Are_Rejected (×7) confirms each pre-#146 alias now fails fast with "Unknown type code". - Worked_Example_Int16_Array uses the new `:S` code. - New Worked_Example_Int32_Array_Via_I_Code documents the `:I = Int32` vendor-alignment intent so a future "fix" doesn't accidentally regress. - Unknown_Type_Code_Rejected_With_Catalog updated to match the new error message ("Valid: BOOL, S, US, I, ..."). Docs: - docs/v2/modbus-addressing.md — table replaced with the post-#146 codes, each row cites its Wonderware / Ignition reference. New "Codes removed in #146" subsection documents the cutover. - docs/Driver.Modbus.Cli.md — example grammar list updated; explicit type-code reminder appended. 114 addressing tests + 231 driver tests still green. Solution build clean.
5.9 KiB
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
dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -- --help
Or publish a self-contained binary:
dotnet publish src/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.
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
--typeflags.
# 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.
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.
otopcua-modbus-cli subscribe -h 192.168.1.10 -r HoldingRegisters -a 100 -t Int16 -i 500
Output format
probe/reademit a multi-line per-tag block:Tag / Value / Status / Source Time / Server Time.writeemits one line:Write <tag>: 0x... (Good | BadCommunicationError | …).subscribeemits 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
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.
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.