Files
lmxopcua/docs/Client.CLI.md
T
Joseph Doherty fe919f8b08 fix(client): endpoint port rewrite, Part 13 time-weighted aggregates, clean history-refusal error (#678.5, #677.5, #674.6, #678.6)
- EndpointSelector rewrites host AND port to the user-supplied URL (same scheme only)
- AggregateType/mapper/CLI/UI gain TimeAverage, TimeAverage2, Total, Total2, Interpolative
- HistoryReadCommand turns ServiceResultException into a one-line CommandException
- NodeManagerHistoryReadTests non-historized case sets IsReadModified=false
2026-10-11 10:45:18 -04:00

476 lines
22 KiB
Markdown

# Client CLI
## Overview
`ZB.MOM.WW.OtOpcUa.Client.CLI` is a cross-platform command-line client for the OtOpcUa OPC UA server. It targets .NET 10 and uses the shared `IOpcUaClientService` from `Client.Shared` for all OPC UA operations. Commands are routed and parsed by [CliFx](https://github.com/Tyrrrz/CliFx).
The CLI is the primary tool for operators and developers to test and interact with the server from a terminal. It supports all core operations: connectivity testing, browsing, reading, writing, subscriptions, alarm monitoring, alarm operator actions (acknowledge / confirm / comment / shelve / enable / disable), history reads, and redundancy queries. Any driver surface exposed by the server (Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, OPC UA Client) is reachable through these commands — the CLI is driver-agnostic because everything below the OPC UA endpoint is.
## Build and Run
```bash
cd src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI
dotnet build
dotnet run -- <command> [options]
```
The executable name is `otopcua-cli`. Dev boxes carrying a pre-task-#208 install may still have the legacy `{LocalAppData}/LmxOpcUaClient/` folder on disk; on first launch of any post-#208 CLI or UI build, `ClientStoragePaths` (`src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs`) migrates it to `{LocalAppData}/OtOpcUaClient/` automatically so trusted certificates + saved settings survive the rename.
## Architecture
All commands inherit from `CommandBase`, which provides common connection options and helper methods. Every command follows the same lifecycle:
1. Build `ConnectionSettings` from common options
2. Create an `IOpcUaClientService` through the factory
3. Call `ConnectAsync` to establish a session
4. Perform the command-specific operation
5. Call `DisconnectAsync` in a `finally` block
No command accesses the OPC UA `Session` directly; all operations go through the shared service abstraction. This ensures consistent behavior with the desktop UI client.
## Common Options
All commands accept these options:
| Flag | Description |
|------|-------------|
| `-u` / `--url` | OPC UA server endpoint URL (required) |
| `-U` / `--username` | Username for `UserName` token authentication |
| `-P` / `--password` | Password for `UserName` token authentication |
| `-S` / `--security` | Transport security mode: `none`, `sign`, `encrypt`, `signandencrypt` (default: `none`) |
| `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy |
| `--verbose` | Enable debug-level Serilog console logging (default: warning) |
**`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and
fails with an `InvalidOperationException` naming the endpoints the server does offer when it has none. The
older stack helper silently fell back to the first endpoint (often a secured one), so a command that
defaulted to `none` against a secured-only server used to connect and now errors. Pass `-S sign`,
`-S encrypt` or `-S signandencrypt` to talk to such a server.
### Authentication
When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used.
```bash
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
```
### Failover
**Mapped ports and internal hostnames.** Endpoint discovery rewrites the host *and port* of the endpoint the server
advertises to the URL you passed (when the scheme matches), so connecting through a port mapping works even though the
server advertises its internal address (for example the docker-dev rig: `-u opc.tcp://localhost:4841` reaches the
node mapped to 4841, although every container advertises `:4840`).
When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure.
```bash
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa
```
### Transport Security
When `sign` or `encrypt` is specified, the shared service:
1. Ensures a client application certificate exists under `{LocalAppData}/OtOpcUaClient/pki/` (auto-created if missing; pre-rename `LmxOpcUaClient/` is migrated in place on first launch)
2. Discovers server endpoints and selects one matching the requested security mode
3. Prefers `Basic256Sha256` when multiple matching endpoints exist
4. Fails with a clear error if no matching endpoint is found
```bash
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2
```
### Verbose Logging
The `--verbose` flag switches Serilog output from `Warning` to `Debug` level, showing internal connection lifecycle, endpoint discovery, and OPC UA SDK diagnostics on the console.
## Commands
### connect
Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects.
```bash
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
```
Output:
```text
Connected to: opc.tcp://localhost:4840/OtOpcUa
Server: OtOpcUa Server
Security Mode: None
Security Policy: http://opcfoundation.org/UA/SecurityPolicy#None
Connection successful.
```
### read
Reads the current value of a single node and prints the value, status code, and timestamps.
```bash
otopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to read (required) |
Output:
```text
Node: ns=3;s=DEV.ScanState
Value: True
Status: 0x00000000
Source Time: 2026-03-30T19:58:38.0961252Z
Server Time: 2026-03-30T19:58:38.0971257Z
```
A Bad node status (unknown node, access denied, ...) is reported, not thrown: the command prints `Node:`, an empty `Value:` and `Status: 0x80340000 (BadNodeIdUnknown)`. Session-level failures (closed connection / channel) still fail the command.
### write
Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`.
```bash
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to write to (required) |
| `-v` / `--value` | Value to write (required) |
### browse
Browses the OPC UA address space starting from the Objects folder or a specified node. Supports recursive traversal with a configurable depth limit. Output uses tree-style indentation with `[Object]`, `[Variable]`, and `[Method]` markers.
```bash
# Browse top-level Objects folder
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
# Browse a specific node recursively to depth 3
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB"
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to browse (default: Objects folder) |
| `-d` / `--depth` | Maximum browse depth (default: 1) |
| `-r` / `--recursive` | Browse recursively using `-d` as max depth |
### subscribe
Monitors a node (or every Variable in its subtree) for value changes using OPC UA subscriptions.
Prints each data-change notification with timestamp, value, and status code, then prints a
summary on exit. Exits on Ctrl+C, or automatically after `--duration` seconds.
```bash
# Subscribe to a single node
otopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
# Browse a subtree and subscribe to every Variable, run for 60 seconds, write the summary to disk
otopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=3;s=ZB" -r --max-depth 4 \
--duration 60 --quiet --summary-file C:\Temp\subscribe-summary.txt
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to monitor (required). When `--recursive` is set, this is the browse root. |
| `-i` / `--interval` | Sampling interval in milliseconds (default: 1000) |
| `-r` / `--recursive` | Browse recursively from `--node` and subscribe to every Variable found |
| `--max-depth` | Maximum recursion depth when `--recursive` is set (default: 10) |
| `-q` / `--quiet` | Suppress per-update output; only print the final summary |
| `--duration` | Auto-exit after N seconds and print the summary (0 = run until Ctrl+C, default: 0) |
| `--summary-file` | Also write the summary to this file path on exit |
#### Summary buckets
The summary prints per-node counts across these buckets:
- **Ever went BAD during window** — node received at least one notification whose status was not Good.
- **NEVER went bad (suspect)** — node received at least one notification and every one was Good.
- **Last status GOOD / NOT-GOOD** — final observed status across nodes that received any update.
- **No update received at all** — node was subscribed but no notification arrived during the window.
### historyread
Reads historical data from a node. Supports raw history reads and aggregate (processed) history reads.
`--start` and `--end` are parsed with `CultureInfo.InvariantCulture` and treated as UTC; supply
them in ISO 8601 UTC form (`YYYY-MM-DDTHH:MM:SSZ`) for unambiguous behaviour across hosts.
A Bad per-node status from the server (for example `BadHistoryOperationUnsupported` for a node that is not
historized, or `BadUserAccessDenied`) fails the command with that status named. It is never printed as
`0 values returned`; only `BadNoData` and Good/GoodNoData read as an empty result.
```bash
# Raw history
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z"
# Aggregate: 1-hour average
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z" \
--aggregate Average --interval 3600000
# Modified-value history (what HistoryUpdate changed): prior value, update type, user, modification time
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa -U apikey -P otua_<keyId>_<secret> \
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-01-15T00:00:00Z" --end "2026-01-16T00:00:00Z" --modified
```
`--modified` sends `ReadRawModifiedDetails` with `IsReadModified = true` (OPC UA Part 11, Gitea #668) and prints one line per
recorded modification: the source timestamp, the **prior** value (`(none)` for an insert, which had none), its status, the update type
(`Insert` / `Replace` / `Update` / `Delete`), the user (the API key's principal, or `anonymous`) and the modification time. It follows
continuation points up to `--max`. A server on the `Gateway` backend (no modification trail) answers `BadHistoryOperationUnsupported`.
`--modified` cannot be combined with `--aggregate`. See [Historian.md §Modified-value history](Historian.md#modified-value-history-part-11).
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to read history for (required) |
| `--start` | Start time in ISO 8601 UTC format, e.g. `2026-01-15T08:00:00Z` (default: 24 hours ago) |
| `--end` | End time in ISO 8601 UTC format, e.g. `2026-01-15T09:00:00Z` (default: now) |
| `--max` | Maximum number of values (default: 1000) |
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative |
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
| `--modified` | Read modified-value history (Part 11 `IsReadModified`) instead of raw samples |
### historyupdate
Inserts, replaces, updates or deletes stored history on a historized node via the OPC UA `HistoryUpdate`
service (requires a server whose historian backend supports updates and a session holding the history-write
permission). Prints one line per item (`index time StatusCodeName`; a code the SDK has no name for prints as
`0xXXXXXXXX`) followed by the overall service result. It exits non-zero unless **every** result is Good: a Bad
**or Uncertain** item, a non-Good node or service result, or a Good service result that is missing per-item
results (fewer than the items sent, printed as `(no result returned)`) all fail the command.
```bash
# Insert two values
otopcua-cli historyupdate -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=Plant/Modbus/dev1/Speed" \
--mode insert --value 1.5 --time 2026-01-15T08:00:00Z --value 2.5 --time 2026-01-15T09:00:00Z
# Replace an int64 value
otopcua-cli historyupdate -u ... -n "..." --mode replace --type int64 --value 42 --time 2026-01-15T08:00:00Z
# Delete a raw range / specific timestamps
otopcua-cli historyupdate -u ... -n "..." --mode delete-raw --start 2026-01-15T08:00:00Z --end 2026-01-15T10:00:00Z
otopcua-cli historyupdate -u ... -n "..." --mode delete-at-time --time 2026-01-15T08:00:00Z
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to modify (required) |
| `--mode` | `insert`, `replace`, `update`, `delete-raw` or `delete-at-time` (required) |
| `--value` | Value to write; repeatable, paired positionally with `--time` (insert/replace/update) |
| `--time` | ISO 8601 UTC timestamp; repeatable (insert/replace/update/delete-at-time) |
| `--type` | `double` (default), `int64`, `bool`, `string`, `datetime` - how `--value` is parsed |
| `--start` / `--end` | Range for `delete-raw` (both required; `--end` later than `--start`) |
Expected per-item codes on the SQL Server historian backend: `insert` ⇒ `GoodEntryInserted` (repeat ⇒ `BadEntryExists`), `replace` ⇒ `GoodEntryReplaced`
(`BadNoEntryExists` if nothing is stored at that time), `update` ⇒ either Good code, `delete-at-time` ⇒ `Good` / `BadNoEntryExists`; `delete-raw` reports one
node-level code (`Good`, or `BadNoData` if the range held nothing). A node that is not historized, or a server on the Gateway historian backend (no
updater, so no `HistoryWrite` access bit), answers `BadHistoryOperationUnsupported`; a `NaN`/`Infinity` double is `BadInvalidArgument`. The session's API key
(`-U apikey -P otua_<keyId>_<secret>`) needs the `HistoryUpdate` permission — via a `Cluster`-scoped grant when `Security:Acl:Enabled` is off, or a scoped
grant on the node when it is on; a key with only `WriteOperate` gets `BadUserAccessDenied`. See [Historian.md §HistoryUpdate](Historian.md#historyupdate) and
[security.md](security.md#historyupdate-historian-writes). `historyread --aggregate` only knows the twelve names in the table below; the SQL backend's other
aggregates (Range, Delta, PercentGood, …) need a different client. A refused history read (for example
`BadHistoryOperationUnsupported`) prints one error line and exits non-zero; it does not dump a stack trace.
`delete-raw` is sent with `IsDeleteModified=false`. Invalid or inconsistent options (unknown mode, unpaired
value/time, unparsable value, options that do not apply to the mode) fail before any connection is made.
#### Aggregate mapping
| Name | Aliases | OPC UA Node ID |
|------|---------|---------------|
| `Average` | `avg` | `AggregateFunction_Average` |
| `Minimum` | `min` | `AggregateFunction_Minimum` |
| `Maximum` | `max` | `AggregateFunction_Maximum` |
| `Count` | | `AggregateFunction_Count` |
| `Start` | `first` | `AggregateFunction_Start` |
| `End` | `last` | `AggregateFunction_End` |
| `StandardDeviation` | `stddev`, `stdev` | `AggregateFunction_StandardDeviationPopulation` |
| `TimeAverage` | `timeavg` | `AggregateFunction_TimeAverage` |
| `TimeAverage2` | `timeavg2` | `AggregateFunction_TimeAverage2` |
| `Total` | | `AggregateFunction_Total` |
| `Total2` | | `AggregateFunction_Total2` |
| `Interpolative` | `interp` | `AggregateFunction_Interpolative` |
### alarms
Subscribes to alarm events on a node. Prints structured alarm output including source, condition, severity, active/acknowledged state, and message. Runs until Ctrl+C, then unsubscribes and disconnects cleanly.
```bash
# Subscribe to alarm events on the Server node
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa
# Subscribe to a specific source node with condition refresh
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001" --refresh
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Node ID to monitor for events (default: Server node) |
| `-i` / `--interval` | Publishing interval in milliseconds (default: 1000) |
| `--refresh` | Request a `ConditionRefresh` after subscribing to get current retained alarm states |
The active/acknowledged columns come from the standard `ActiveState/Id`, `AckedState/Id` and
`EnabledState/Id` event fields. Before the 2026-10 fix the client's filter selected them with a single
QualifiedName named `ActiveState/Id` (a `/`-joined string passed to `EventFilter.AddSelectClause` is **not**
split into a browse path), which matched nothing — so every event printed inactive and unacknowledged
whatever the condition's real state. A direct Read of the condition was always correct.
### ack
Acknowledges an active alarm condition via the OPC UA Part 9 `Acknowledge` method. The server gates this on the **`AlarmAck`** role (fail-closed — a session whose API-key principal holds no `AlarmAck`-conferring `NodeAcl` grant gets `BadUserAccessDenied`; the data plane does not use LDAP; see [ScriptedAlarms.md](ScriptedAlarms.md) §"AlarmAck gate"). The `--event-id` is the hex-encoded `EventId` taken from a prior `alarms` subscription notification for the same condition.
```bash
otopcua-cli ack -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001" -e 0A1B2C -c "Investigating"
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Condition node ID of the alarm to acknowledge (**required**) |
| `-e` / `--event-id` | `EventId` from the alarm notification, hex-encoded (e.g. `0A1B2C`) (**required**) |
| `-c` / `--comment` | Operator comment for the acknowledgment (optional) |
Example output:
```text
Acknowledge successful: ns=1;s=TestMachine_001
```
### confirm
Confirms an already-acknowledged alarm condition via the OPC UA Part 9 `Confirm` method — the second stage of two-stage acknowledgment. Same `AlarmAck` role gate and same `--event-id` source (a prior `alarms` notification) as `ack`.
```bash
otopcua-cli confirm -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001" -e 0A1B2C -c "Root cause cleared"
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Condition node ID of the alarm to confirm (**required**) |
| `-e` / `--event-id` | `EventId` from the alarm notification, hex-encoded (**required**) |
| `-c` / `--comment` | Operator comment for the confirmation (optional) |
Example output:
```text
Confirm successful: ns=1;s=TestMachine_001
```
### comment
Attaches an operator comment to an alarm condition (OPC UA Part 9 `AddComment`) without changing its acknowledge/confirm state. Works on scripted and native conditions alike. The `--node` and `--event-id` are the same values `ack`/`confirm` take.
```bash
otopcua-cli comment -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=Plant/Line1/Pump1/HighTemp" -e 0A1B2C -c "Checked on site"
```
| Flag | Description |
|---|---|
| `-n` / `--node` | Condition node ID of the alarm to comment on (**required**) |
| `-e` / `--event-id` | Hex-encoded EventId from the alarm notification (**required**) |
| `-c` / `--comment` | Operator comment to attach (**required**) |
### shelve
Shelves or unshelves an active alarm condition via the OPC UA Part 9 `ShelvedStateMachine` (`OneShotShelve` / `TimedShelve` / `Unshelve`). Gated on the `AlarmAck` role. `--duration` is given in **seconds** (converted to milliseconds for the OPC UA call) and is required only for `--kind Timed`. The `--node` is the condition node itself (the `SourceNode`/condition id an `alarms` notification reports): `ack`, `confirm`, `shelve`, `enable` and `disable` call the Part 9 method ids directly on that node — no `.Condition` / `.ShelvingState` suffix is appended (those child nodes do not exist on the OtOpcUa server).
```bash
# One-shot shelve until the next clear
otopcua-cli shelve -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001" -k OneShot
# Timed shelve for 300 seconds
otopcua-cli shelve -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001" -k Timed -d 300
# Unshelve
otopcua-cli shelve -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=1;s=TestMachine_001" -k Unshelve
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Condition node ID of the alarm to shelve/unshelve (**required**) |
| `-k` / `--kind` | Shelve operation: `OneShot` \| `Timed` \| `Unshelve` (**required**) |
| `-d` / `--duration` | Shelving duration in **seconds** (must be > 0; **required for `--kind Timed`**) |
Example output:
```text
Timed successful: ns=1;s=TestMachine_001
```
### enable
Enables an alarm condition via the OPC UA Part 9 `ConditionType.Enable` method (resumes evaluation/reporting for a previously disabled condition). Gated on the `AlarmAck` role.
```bash
otopcua-cli enable -u opc.tcp://localhost:4840/OtOpcUa -n "ns=1;s=TestMachine_001"
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Condition node ID of the alarm to enable (**required**) |
Example output:
```text
Enable successful: ns=1;s=TestMachine_001
```
### disable
Disables an alarm condition via the OPC UA Part 9 `ConditionType.Disable` method (stops evaluation/reporting). Gated on the `AlarmAck` role.
```bash
otopcua-cli disable -u opc.tcp://localhost:4840/OtOpcUa -n "ns=1;s=TestMachine_001"
```
| Flag | Description |
|------|-------------|
| `-n` / `--node` | Condition node ID of the alarm to disable (**required**) |
Example output:
```text
Disable successful: ns=1;s=TestMachine_001
```
### redundancy
Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI.
```bash
otopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
```
Example output:
```text
Redundancy Mode: Warm
Service Level: 200
Server URIs:
- urn:localhost:OtOpcUa:instance1
- urn:localhost:OtOpcUa:instance2
Application URI: urn:localhost:OtOpcUa:instance1
```
## Testing
The Client CLI has 104 unit tests covering option parsing, service invocation, output formatting, and cleanup behavior:
```bash
dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests
```