diff --git a/CLAUDE.md b/CLAUDE.md index 66aa8954..6f01f324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,3 +181,7 @@ See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` t ## Scripted Alarm Ack/Shelve Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` LDAP role via `RoleCarryingUserIdentity`; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`. + +## Historian / HistoryRead + +Server-side OPC UA HistoryRead for historized equipment tags is implemented driver-agnostically in Phase C. A tag is historized by adding `"isHistorized": true` to its `TagConfig` JSON blob (authored in the raw-JSON textarea on the `/uns` TagModal); an optional `"historianTagname"` field overrides the default historian tagname, which is the tag's driver `FullName`. The server dispatches all history reads to the registered `IHistorianDataSource` (Wonderware historian TCP client) via the `ServerHistorian` appsettings section (`Enabled` defaults to `false`; when disabled, a `NullHistorianDataSource` is used and historized nodes return `GoodNoData` rather than an error). Supported variants: Raw, Processed (Average/Minimum/Maximum/Total/Count aggregates), and AtTime over historized variable nodes; Events over alarm-owning equipment-folder event-notifier nodes. Reads are ungated (served from any redundancy node); authorization uses the standard `AccessLevels.HistoryRead` bit set at materialization. See `docs/Historian.md` for the full guide. diff --git a/docs/Historian.md b/docs/Historian.md new file mode 100644 index 00000000..a867cb84 --- /dev/null +++ b/docs/Historian.md @@ -0,0 +1,212 @@ +# Historian — server-side OPC UA HistoryRead (Phase C) + +Phase C wires server-side OPC UA **HistoryRead** for authored equipment tags flagged +historized. The feature is driver-agnostic: any equipment tag (Galaxy, Modbus, OpcUaClient, +or any other driver) can be marked historized; the server dispatches all history reads to the +registered `IHistorianDataSource` — today, the Wonderware sidecar client +(`WonderwareHistorianClient`). No EF migration is required; the historian flag rides in the +existing schemaless `TagConfig` JSON blob alongside the Phase B `alarm` object. + +Design reference: [docs/plans/2026-06-14-galaxy-phase-c-historian-design.md](plans/2026-06-14-galaxy-phase-c-historian-design.md). + +--- + +## Historized TagConfig schema + +A tag is historized by adding fields to its `TagConfig` blob on the `/uns` equipment page +Tags tab (raw-JSON textarea). No separate UI control exists — Galaxy (the primary use case) +already uses the raw-JSON editor. + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `isHistorized` | bool | yes | Marks the tag historized. Materialises the OPC UA node with `Historizing=true` and the `HistoryRead` AccessLevel bit. | +| `historianTagname` | string | no | Explicit historian tagname to query. When absent or empty, defaults to the tag's driver `FullName` (the `TagConfig.FullName`). | + +### Examples + +Default historian tagname (uses `FullName` = `TestMachine_002.TestFloat`): + +```json +{"FullName":"TestMachine_002.TestFloat","isHistorized":true} +``` + +Explicit historian tagname override: + +```json +{"FullName":"TestMachine_002.TestFloat","isHistorized":true,"historianTagname":"Plant.Line1.Flow"} +``` + +A tag that is both historized and a native alarm: + +```json +{"FullName":"TestMachine_002.HiAlarm","isHistorized":true,"alarm":{"alarmType":"OffNormalAlarm","severity":700}} +``` + +--- + +## ServerHistorian configuration + +The `ServerHistorian` section in `appsettings.json` controls the historian read path. +`Enabled` defaults to `false`; when disabled, the server registers `NullHistorianDataSource` +and all HistoryRead calls on historized nodes return `GoodNoData` (empty, not an error). + +```json +{ + "ServerHistorian": { + "Enabled": false, + "Host": "localhost", + "Port": 32569, + "UseTls": false, + "ServerCertThumbprint": "", + "SharedSecret": "" + } +} +``` + +| Key | Type | Default | Description | +|---|---|---|---| +| `Enabled` | bool | `false` | Enable the live `WonderwareHistorianClient`. `false` → `NullHistorianDataSource` (empty reads). | +| `Host` | string | `localhost` | DNS name or IP of the machine running the historian sidecar. | +| `Port` | int | `32569` | TCP port the sidecar listens on (`OTOPCUA_HISTORIAN_TCP_PORT`). | +| `UseTls` | bool | `false` | Wrap the TCP connection in TLS. | +| `ServerCertThumbprint` | string | — | Optional SHA-1 thumbprint to pin the sidecar's TLS certificate. Leave empty for CA-chain validation. | +| `SharedSecret` | string | — | Shared secret token the sidecar expects on every connection. Required when `Enabled`. | + +> **Do not commit `SharedSecret` to `appsettings.json`.** Set it via an environment variable, +> a secrets store, or a deployment-time overlay. The checked-in default is always empty. + +The `ServerHistorian` section is independent of the `AlarmHistorian` section (the alarm +write path). They share the same Wonderware sidecar process but hold separate client +instances and separate `SharedSecret` values. + +--- + +## HistoryRead behavior + +### Read variants + +The server supports all four OPC UA HistoryRead variants: + +| Variant | Node type | CLI `--aggregate` | +|---|---|---| +| **Raw** | Historized variable | (omit `--aggregate`) | +| **Processed** | Historized variable | `Average`, `Minimum`, `Maximum`, `Total`, `Count` | +| **AtTime** | Historized variable | n/a (client supplies exact timestamps) | +| **Events** | Equipment folder (event notifier) | n/a | + +**Variable nodes** (historized tags) serve Raw, Processed, and AtTime history. +`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant +OPC UA client can discover historized capability from the node's attributes. + +**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that +owns at least one alarm condition is already an event notifier; the server registers a +`sourceName` (the equipment id) for each such folder and maps event history reads to the +Wonderware historian using that source. Event-field projection supports the standard +`BaseEventType` select clauses — `EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`, +and `Severity`; an unsupported select operand returns a null field (spec-conformant). + +### Graceful degradation + +| Situation | HistoryRead status | +|---|---| +| Historized node, historian configured and reachable, results found | `Good` | +| Historized node, historian configured, time range empty | `GoodNoData` | +| Historized node, historian NOT configured (`Enabled=false` / Null source) | `GoodNoData` (empty) | +| Non-historized node | `BadHistoryOperationUnsupported` | +| Backend timeout or exception | `BadHistoryOperationUnsupported` per node; other nodes in the same batch are unaffected | + +A historized node with no historian configured never returns an error status — it returns +empty. This means a deployment can author and publish historized tags before the historian +sidecar is provisioned, without producing error spikes in connected clients. + +### Known limitations + +- **No server-managed continuation points.** Each HistoryRead call is single-shot. The server + honors the client's `NumValuesPerNode` limit but does not issue continuation points for + large result sets. Paging across multiple calls is a documented follow-up. +- **No modified-value history** (`HistoryReadModified`). Requests for modified values return + `BadHistoryOperationUnsupported`. This is a follow-up. + +### Redundancy and authorization + +History reads are served from any node — there is no Primary gate. Authorization is the +standard OPC UA `HistoryRead` permission enforced by the SDK through the `AccessLevels.HistoryRead` +bit set at materialization. A session without sufficient permissions receives +`BadUserAccessDenied` from the SDK before the dispatch reaches the historian. + +--- + +## Authoring workflow + +1. Open the equipment's **Tags** tab on `/uns/equipment/{id}`. +2. Create or edit the tag. Because Galaxy uses the raw-JSON editor, add `"isHistorized":true` + (and optionally `"historianTagname":"..."`) directly in the TagConfig textarea. +3. Save and publish. The server rebuilds its address space; the node materialises with + `Historizing=true` and the `HistoryRead` AccessLevel bit. +4. Confirm with Client.CLI `read` that the node's `Status` is `Good` and that the value + is updating. Then issue a `historyread` to verify the historian connection returns data. + +--- + +## Client.CLI historyread examples + +The `historyread` command reads historical data from any node. Supply start and end times in +ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference. + +```bash +# Raw history for a historized Galaxy tag (last 24 hours by default) +otopcua-cli historyread \ + -u opc.tcp://localhost:4840/OtOpcUa \ + -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" + +# Limit to 100 values +otopcua-cli historyread \ + -u opc.tcp://localhost:4840/OtOpcUa \ + -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ + --max 100 + +# 1-hour average aggregate +otopcua-cli historyread \ + -u opc.tcp://localhost:4840/OtOpcUa \ + -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ + --aggregate Average --interval 3600000 + +# Authenticated read (ReadOnly role or higher required) +otopcua-cli historyread \ + -u opc.tcp://localhost:4840/OtOpcUa \ + -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ + -U reader -P password +``` + +Supported `--aggregate` values: `Average`, `Minimum`, `Maximum`, `Count`, `Start`, `End`, +`StandardDeviation` (aliases: `avg`, `min`, `max`, `stddev`/`stdev`, `first`, `last`). +`--interval` is the processing interval in milliseconds (default 3600000 = 1 hour). + +--- + +## Live /run gate + +The live read gate requires the Wonderware historian sidecar running on the WW Historian VM +(`10.100.0.48`) and AVEVA Historian healthy. Set `ServerHistorian:Enabled=true` with the +correct `Host`, `Port`, and `SharedSecret` in `appsettings.json` (or via environment +variables), then deploy and publish at least one historized Galaxy tag. The gate is +operator-driven — it is not part of the local docker-dev rig. + +See [AlarmHistorian.md](AlarmHistorian.md) for the historian sidecar setup and +[ServiceHosting.md](ServiceHosting.md) for the sidecar service configuration. + +--- + +## See also + +- [docs/plans/2026-06-14-galaxy-phase-c-historian-design.md](plans/2026-06-14-galaxy-phase-c-historian-design.md) — full design and implementation notes +- [AlarmHistorian.md](AlarmHistorian.md) — alarm write path; shares the same Wonderware sidecar +- [AlarmTracking.md](AlarmTracking.md) — OPC UA Part 9 alarm surface (event history source) +- [Client.CLI.md](Client.CLI.md) — full `historyread` flag reference +- [ScriptedAlarms.md](ScriptedAlarms.md) §"Native driver alarms" — the Phase B `alarm` object in `TagConfig` (parallel carrier)