fix(historian): read-only historian bindings + ProvisionTags policy; provision OPC UA type names (#899, #898)
v2-ci / unit-tests (push) Has been cancelled

#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via
historianTagname re-typed/re-described it on every add and host start.
- TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly):
  HistoryRead from an existing historian tag that OtOpcUa never provisions and never
  records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags
  hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the
  RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid.
  AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the
  runtime parse.
- ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly
  (Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not
  sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start.
- Default for an explicit historianTagname stays OWNED: overrides pin owned history
  across renames, a read-only default would silently stop provisioning + recording.

#898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized
Float/Double/Byte/SByte tags were never provisioned. New shared
HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16
(lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips
Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject).

UPGRADE: the first host start after this provisions (upserts) every existing historized
Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set
ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 01:16:23 -05:00
parent eec87d3f74
commit 6c4d29902e
36 changed files with 1015 additions and 60 deletions
+11
View File
@@ -73,6 +73,7 @@ authorization uses the standard `AccessLevels.HistoryRead` bit set at materializ
| `MaxConcurrentHistoryReadBatches` | `16` | Process-wide concurrent HistoryRead batch cap (03/S3); flood degrades to `BadTooManyOperations`. Total in-flight reads capped at 16 × 4 = 64 | | `MaxConcurrentHistoryReadBatches` | `16` | Process-wide concurrent HistoryRead batch cap (03/S3); flood degrades to `BadTooManyOperations`. Total in-flight reads capped at 16 × 4 = 64 |
| `MaxTimeWeightedRawRows` | `1000000` | `SqlServer`/`QuestDb` (#671): raw in-window rows one time-weighted aggregate read (incl. `Total` and, since #680, PercentGood/PercentBad) may stream into the C# evaluator — and, since #681, every `Gateway` per-sample read (raw-evaluated); window query capped at this + 1, past it ⇒ `BadTooManyOperations` for that node. ≤ 0 = unbounded. Gateway (#688): the `cap + 18` budget is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is ≤ it, so the default no longer trips a strict (`AllowStreamingBeyondCeiling=false`) gateway | | `MaxTimeWeightedRawRows` | `1000000` | `SqlServer`/`QuestDb` (#671): raw in-window rows one time-weighted aggregate read (incl. `Total` and, since #680, PercentGood/PercentBad) may stream into the C# evaluator — and, since #681, every `Gateway` per-sample read (raw-evaluated); window query capped at this + 1, past it ⇒ `BadTooManyOperations` for that node. ≤ 0 = unbounded. Gateway (#688): the `cap + 18` budget is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is ≤ it, so the default no longer trips a strict (`AllowStreamingBeyondCeiling=false`) gateway |
| `HistoryReadDeadline` | `00:01:00` | Per-request HistoryRead wall-clock deadline (03/S3); a node still in flight surfaces `BadTimeout`. Non-positive = unbounded | | `HistoryReadDeadline` | `00:01:00` | Per-request HistoryRead wall-clock deadline (03/S3); a node still in flight surfaces `BadTimeout`. Non-positive = unbounded |
| `ProvisionTags` | `CreateOrUpdate` | #899: `CreateOrUpdate` (the `EnsureTags`/`MERGE` upsert, as before) \| `CreateOnly` (**Gateway only**, else host-start error: `TagExistsAsync` first, existing tags left alone + counted `skipped`, a failed lookup counted `failed` and NOT sent — fail closed) \| `Off` (Null provisioner; reads + recording unaffected). Unknown value fails host start |
### Alarm-history path (`AlarmHistorian` section) ### Alarm-history path (`AlarmHistorian` section)
@@ -136,6 +137,16 @@ historian's `Int1` analog-tag creation path is **server-degenerate** — `Ensure
stub (live-probed; gateway-side resolution in HistorianGateway #11 / PR #16). Both Float and Int2 are stub (live-probed; gateway-side resolution in HistorianGateway #11 / PR #16). Both Float and Int2 are
supported analog types, so a pre-existing Float Boolean tag retypes cleanly to Int2 on its next deploy — see supported analog types, so a pre-existing Float Boolean tag retypes cleanly to Int2 on its next deploy — see
the "Boolean historization maps to Int2" section in `docs/Historian.md`. the "Boolean historization maps to Int2" section in `docs/Historian.md`.
**Type names (#898):** `Tag.DataType` is an OPC UA name — the hook resolves it with `HistorianDataTypeNames.TryResolve`
(`Float`→Float32, `Double`→Float64, `Byte`/`SByte`→Int16 ⇒ `Int2`; DriverDataType spellings still accepted; `Guid`/`ByteString`
not provisioned, Information log). Before #898 Float/Double/Byte/SByte were silently never provisioned ⇒ ⚠️ upgrade: the first host
start provisions (upserts) every existing historized Float/Double/Byte/SByte tag. `Int64`/`UInt64` are skipped on the Gateway
(HistorianGateway cannot create Int8/UInt8 — lossless-cast-or-reject). **Read-only bindings (#899):** `TagConfig.historianReadOnly: true`
(`TagConfigIntent.HistorianReadOnly`, `RawTagPlan.HistorianReadOnly`, CSV `HistorianReadOnly`) = HistoryRead from an existing
historian tag OtOpcUa never provisions (`AddressSpaceApplier.IsOwnedHistorizedValue`) and never records into (not fed to the
recorder); node-inert ⇒ surgical; not mirrored on UNS refs; a non-bool value is the deploy error `HistorianReadOnlyInvalid`. The
default for an explicit `historianTagname` is still OWNED (overrides pin owned history across renames). `docs/Historian.md`
§"Read-only bindings to existing historian tags (#899)".
### Gateway-side prerequisites ### Gateway-side prerequisites
+58 -2
View File
@@ -69,6 +69,7 @@ fields directly in the textarea.
|---|---|---|---| |---|---|---|---|
| `isHistorized` | bool | yes | Marks the tag historized. Materialises the OPC UA node with `Historizing=true` and the `HistoryRead` AccessLevel bit. | | `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`). | | `historianTagname` | string | no | Explicit historian tagname to query. When absent or empty, defaults to the tag's driver `FullName` (the `TagConfig.FullName`). |
| `historianReadOnly` | bool | no | **Read-only binding (#899).** `true` = the history is read from an **existing** historian tag that OtOpcUa does not own: the node still serves HistoryRead, but OtOpcUa **never provisions** it (no `EnsureTags`, so it is never created, re-typed or re-described) and **never records** captured values into it (no continuous-historization `WriteLiveValues`). Absent/`false` = an owned tag (provisioned and recorded, the behaviour before #899). Must be a JSON boolean — any other value is the deploy error `HistorianReadOnlyInvalid`. CSV column `HistorianReadOnly`. See [Read-only bindings](#read-only-bindings-to-existing-historian-tags-899). |
### Examples ### Examples
@@ -84,6 +85,12 @@ Explicit historian tagname override:
{"FullName":"TestMachine_002.TestFloat","isHistorized":true,"historianTagname":"Plant.Line1.Flow"} {"FullName":"TestMachine_002.TestFloat","isHistorized":true,"historianTagname":"Plant.Line1.Flow"}
``` ```
Read-only binding to an existing AVEVA tag (never provisioned, never written; #899):
```json
{"scriptId":"SC-…","isHistorized":true,"historianTagname":"SysTimeSec","historianReadOnly":true}
```
A tag that is both historized and a native alarm: A tag that is both historized and a native alarm:
```json ```json
@@ -194,6 +201,7 @@ Processed read is `BadAggregateNotSupported` and no history aggregate functions
| `MaxConcurrentHistoryReadBatches` | int | `16` | Process-wide limit on HistoryRead (and HistoryUpdate) batches served **concurrently** (arch-review 03/S3). A flood of history clients degrades to `BadTooManyOperations` rather than exhausting the SDK request-thread pool. With `HistoryReadBatchParallelism` this caps total in-flight gateway reads at `MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism` (default 16 × 4 = 64). A batch that cannot get a slot within `min(HistoryReadDeadline, 5 s)` fails every handle `BadTooManyOperations`. | | `MaxConcurrentHistoryReadBatches` | int | `16` | Process-wide limit on HistoryRead (and HistoryUpdate) batches served **concurrently** (arch-review 03/S3). A flood of history clients degrades to `BadTooManyOperations` rather than exhausting the SDK request-thread pool. With `HistoryReadBatchParallelism` this caps total in-flight gateway reads at `MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism` (default 16 × 4 = 64). A batch that cannot get a slot within `min(HistoryReadDeadline, 5 s)` fails every handle `BadTooManyOperations`. |
| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**, since #820 **DurationGood/DurationBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). | | `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**, since #820 **DurationGood/DurationBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). |
| `HistoryReadDeadline` | TimeSpan | `00:01:00` | Per-request wall-clock deadline for a HistoryRead (and HistoryUpdate) batch (arch-review 03/S3). A node still in flight at the deadline surfaces `BadTimeout`, so one request can never hold an SDK request thread longer than this regardless of node count. Non-positive = unbounded (a `Validate()` warning). | | `HistoryReadDeadline` | TimeSpan | `00:01:00` | Per-request wall-clock deadline for a HistoryRead (and HistoryUpdate) batch (arch-review 03/S3). A node still in flight at the deadline surfaces `BadTimeout`, so one request can never hold an SDK request thread longer than this regardless of node count. Non-positive = unbounded (a `Validate()` warning). |
| `ProvisionTags` | string | `CreateOrUpdate` | What [tag auto-provisioning](#tag-auto-provisioning-ensuretags) may do (#899). `CreateOrUpdate` = the `EnsureTags` / `MERGE` upsert: create a missing tag, re-type and re-describe an existing one (the behaviour before #899). `CreateOnly` (**Gateway backend only** — any other backend fails host start) = look each tag up first (`GetTagMetadata`, a read) and send only the ones the historian reports **absent**; an existing tag is left exactly as it is (counted `skipped`), and a failed lookup is counted `failed` and **not** sent. `Off` = no provisioning at all; HistoryRead and continuous historization are unaffected. Case-insensitive; an unknown value fails host start. A tag's own `historianReadOnly: true` opts it out under every policy. |
| `HistoryUpdateMaxFutureSkew` | TimeSpan | `1.00:00:00` | #674. How far past the server clock a HistoryUpdate (Insert / Replace / Update) item's `SourceTimestamp` may lie; an item beyond `now + skew` is refused **`BadOutOfRange`** by the node manager before any backend sees it (every backend). Guards against a far-future typo (a 2062 timestamp). The live recorder is not bounded. Non-positive disables the bound (no warning — a deliberate choice). See [HistoryUpdate](#historyupdate). | | `HistoryUpdateMaxFutureSkew` | TimeSpan | `1.00:00:00` | #674. How far past the server clock a HistoryUpdate (Insert / Replace / Update) item's `SourceTimestamp` may lie; an item beyond `now + skew` is refused **`BadOutOfRange`** by the node manager before any backend sees it (every backend). Guards against a far-future typo (a 2062 timestamp). The live recorder is not bounded. Non-positive disables the bound (no warning — a deliberate choice). See [HistoryUpdate](#historyupdate). |
**`ServerHistorian:Gateway`** (read only when `Backend=Gateway`): knobs that exist because of the HistorianGateway's wire contract. **`ServerHistorian:Gateway`** (read only when `Backend=Gateway`): knobs that exist because of the HistorianGateway's wire contract.
@@ -521,6 +529,50 @@ tag (with the resolved historian name — the `historianTagname` override when s
deploy. On the **Gateway backend**, non-numeric (`String`/`DateTime`/`Reference`) types are skipped, not provisioned; deploy. On the **Gateway backend**, non-numeric (`String`/`DateTime`/`Reference`) types are skipped, not provisioned;
the SqlServer backend provisions every type (below). the SqlServer backend provisions every type (below).
> ⚠️ **Upgrade warning (#898, 2026-11-13).** Before this release, historized tags whose `DataType` is
> **`Float`, `Double`, `Byte` or `SByte`** were **never provisioned**: the hook parsed the OPC UA type name the AdminUI
> stores as a `DriverDataType` and silently skipped those four. They now resolve (`Float`→Float, `Double`→Double,
> `Byte`/`SByte`→`Int2`), so **on the first host start after the upgrade every existing historized Float / Double /
> Byte / SByte tag is provisioned** — on the Gateway backend that is an `EnsureTags` **upsert**, which creates the
> AVEVA tag if it is missing and **re-types / re-describes it if it exists** (description = the OtOpcUa tag name,
> storage rate 1000 ms, integral divisor 1). That is the intended fix for tags OtOpcUa owns, but a tag whose
> `historianTagname` names **someone else's** AVEVA tag (a System Platform attribute, `SysTimeSec`, …) will be
> rewritten. **Before upgrading**, either mark each such tag `"historianReadOnly": true`, or set
> `ServerHistorian:ProvisionTags=CreateOnly` (existing tags are left alone, missing ones still created) or `Off`.
> The SqlServer / QuestDB backends `MERGE`/upsert a row in OtOpcUa's own tag table for those tags — harmless. Also
> new: the Gateway skips `Int64`/`UInt64` up front (counted `skipped`, previously sent and `failed`), because
> HistorianGateway cannot create `Int8`/`UInt8` tags (lossless-cast-or-reject).
**Data type names (#898).** `Tag.DataType` holds OPC UA built-in names. The hook resolves them with
`HistorianDataTypeNames.TryResolve` (Core.Abstractions): `Float`→`Float32`, `Double`→`Float64`, `Byte`/`SByte`→`Int16`
(a lossless widening — provisioned as `Int2`, the type Boolean uses; the one-byte `Int1` creation is server-degenerate,
#441), the names shared with `DriverDataType` map to that member, and a `DriverDataType` spelling (`Float32`,
`Float64`, `Reference`) is still accepted. A name with no historian mapping (`Guid`, `ByteString`) is not provisioned and
is logged at **Information** (`not provisioning historized tag … has no historian mapping`) — it used to be Debug only.
### Read-only bindings to existing historian tags (#899)
`EnsureTags` is an upsert, so before #899 there was **no way** to expose an existing AVEVA tag's history on an OtOpcUa
node without OtOpcUa rewriting that tag — on every add and on every host start (the first plan adds every tag). Two
opt-outs now exist:
- **Per tag — `"historianReadOnly": true`** in `TagConfig` (CSV column `HistorianReadOnly`). The node keeps
`Historizing=true` + the HistoryRead bit and reads the named historian tag; the applier never puts it in an
`EnsureTags` request and never feeds it to the continuous-historization recorder, so OtOpcUa issues **no write of
any kind** for it. Clearing the flag makes the tag owned: the next deploy provisions it (like a historization flip)
and starts recording it. The flag is node-inert (a surgical, in-place deploy) and is not mirrored on UNS reference
nodes. On the SqlServer / QuestDB backends HistoryUpdate on the node is unaffected (those stores are OtOpcUa's own
tables; HistoryUpdate is an explicit, ACL-governed client operation).
- **Server-wide — `ServerHistorian:ProvisionTags`** = `CreateOrUpdate` (default) | `CreateOnly` (Gateway) | `Off` —
see the [options table](#serverhistorian-configuration). `CreateOnly` still records into existing tags; only
`historianReadOnly` stops recording.
**Why the default did not change.** An explicit `historianTagname` does **not** imply "someone else's tag": the AdminUI's
raw-rename warning tells authors to set one to **pin** an owned tag's history across RawPath renames, so overrides on
owned tags are common. Making an override read-only by default would silently stop provisioning **and** recording those
tags. Ordinary owned tags (with or without an override) therefore behave exactly as before; binding to a foreign tag is an
explicit opt-in.
**Wiring + gating.** The concrete provisioner comes from the selected backend's provider (`CreateProvisioning`): **Wiring + gating.** The concrete provisioner comes from the selected backend's provider (`CreateProvisioning`):
the gateway-backed `GatewayTagProvisioner`, or — for `SqlServer` — `SqlServerTagProvisioner` (one `MERGE … WITH (HOLDLOCK)` the gateway-backed `GatewayTagProvisioner`, or — for `SqlServer` — `SqlServerTagProvisioner` (one `MERGE … WITH (HOLDLOCK)`
upsert per chunk of 400 tags; every data type is provisionable so `skipped` is always 0; a blank or over-400-character upsert per chunk of 400 tags; every data type is provisionable so `skipped` is always 0; a blank or over-400-character
@@ -544,8 +596,12 @@ for this node), which is the signal to check that `ServerHistorian:Enabled=true`
> **Gateway backend only.** The `SqlServer` backend provisions and records every type (`String` and `DateTime` included). > **Gateway backend only.** The `SqlServer` backend provisions and records every type (`String` and `DateTime` included).
Historization on the **Gateway** backend is **numeric-analog only**. `HistorianTypeMapper` classifies exactly nine types as Historization on the **Gateway** backend is **numeric-analog only**. `HistorianTypeMapper` classifies exactly seven types as
historizable (`Boolean`, `Int16/32/64`, `UInt16/32/64`, `Float32`, `Float64`): historizable (`Boolean`, `Int16/32`, `UInt16/32`, `Float32`, `Float64`; the OPC UA `Byte`/`SByte` arrive as `Int16`):
- **Not creatable: `Int64`, `UInt64` (#898).** HistorianGateway's type policy is lossless-cast-or-reject and its client
cannot create `Int8`/`UInt8` tags; no creatable type holds a 64-bit integer losslessly. Provisioning skips them
(`skipped=`) instead of sending a request the gateway fails with `FailedPrecondition`.
- **Deferred: `String`, `DateTime`, `Reference`.** The historian's SQL analog write path cannot carry them. - **Deferred: `String`, `DateTime`, `Reference`.** The historian's SQL analog write path cannot carry them.
Provisioning skips them (counted in the `skipped=` tally, via `IsHistorizable`), the mapper throws Provisioning skips them (counted in the `skipped=` tally, via `IsHistorizable`), the mapper throws
+3 -2
View File
@@ -63,7 +63,8 @@ removed later.
- **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row - **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row
verdicts → commit) over an RFC-4180 parser. Columns: the common set verdicts → commit) over an RFC-4180 parser. Columns: the common set
(`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized, (`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized,
HistorianTagname, IsArray, ArrayLength, Alarm.*, TagConfigJson`) plus the per-driver typed HistorianTagname, HistorianReadOnly, IsArray, ArrayLength, Alarm.*, TagConfigJson`;
`HistorianReadOnly` = the #899 read-only historian binding, see [Historian.md](Historian.md)) plus the per-driver typed
columns (e.g. Modbus `Region, Address, ModbusDataType, ByteOrder, …`). `TagGroupPath` columns (e.g. Modbus `Region, Address, ModbusDataType, ByteOrder, …`). `TagGroupPath`
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit. the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
@@ -90,7 +91,7 @@ removed later.
Editing a raw **tag's** attributes is no longer a fleet-wide event. A **non-alarm** Editing a raw **tag's** attributes is no longer a fleet-wide event. A **non-alarm**
raw-tag delta whose only differences are the in-place-updatable attributes — raw-tag delta whose only differences are the in-place-updatable attributes —
`Writable`, `IsHistorized`, `HistorianTagname`, `DataType`, `IsArray`, `ArrayLength`, `Writable`, `IsHistorized`, `HistorianTagname`, `HistorianReadOnly` (#899, node-inert), `DataType`, `IsArray`, `ArrayLength`,
`ArrayWritable` (the driver's array-write capability, #610 — see `ArrayWritable` (the driver's array-write capability, #610 — see
[Uns.md §Array writes](Uns.md#array-writes-610)) — and/or its set of referencing equipment is applied **in place** on the existing Raw-realm [Uns.md §Array writes](Uns.md#array-writes-610)) — and/or its set of referencing equipment is applied **in place** on the existing Raw-realm
node (`ISurgicalAddressSpaceSink.UpdateTagAttributes`), so every client subscription on node (`ISurgicalAddressSpaceSink.UpdateTagAttributes`), so every client subscription on
+1
View File
@@ -252,6 +252,7 @@ drivers (Galaxy) alike.
|---|---|---|---| |---|---|---|---|
| **Historize this tag** checkbox | `isHistorized` | bool | When checked, the OPC UA node materialises with `Historizing=true` and the `HistoryRead` AccessLevel bit set. | | **Historize this tag** checkbox | `isHistorized` | bool | When checked, the OPC UA node materialises with `Historizing=true` and the `HistoryRead` AccessLevel bit set. |
| **Historian tagname (override)** textbox | `historianTagname` | string (optional) | Explicit tagname the historian backend will query. When left blank, the server defaults to the tag's driver `FullName`. | | **Historian tagname (override)** textbox | `historianTagname` | string (optional) | Explicit tagname the historian backend will query. When left blank, the server defaults to the tag's driver `FullName`. |
| (CSV column `HistorianReadOnly` / raw JSON) | `historianReadOnly` | bool (optional) | #899: the history is read from an **existing** historian tag OtOpcUa does not own — never provisioned (`EnsureTags`) and never recorded into. See [Historian.md](Historian.md#read-only-bindings-to-existing-historian-tags-899). |
These fields are merged into the `TagConfig` JSON blob via the pure These fields are merged into the `TagConfig` JSON blob via the pure
`TagHistorizeConfig` helper, which preserves all other keys byte-stable. `TagHistorizeConfig` helper, which preserves all other keys byte-stable.
@@ -18,13 +18,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <param name="HistorianTagname">Non-whitespace <c>historianTagname</c> string override, else <see langword="null"/> (not trimmed).</param> /// <param name="HistorianTagname">Non-whitespace <c>historianTagname</c> string override, else <see langword="null"/> (not trimmed).</param>
/// <param name="IsArray"><c>isArray</c> bool.</param> /// <param name="IsArray"><c>isArray</c> bool.</param>
/// <param name="ArrayLength"><c>arrayLength</c> honoured ONLY when <see cref="IsArray"/> and a JSON number fitting <see cref="uint"/>; else <see langword="null"/>.</param> /// <param name="ArrayLength"><c>arrayLength</c> honoured ONLY when <see cref="IsArray"/> and a JSON number fitting <see cref="uint"/>; else <see langword="null"/>.</param>
/// <param name="HistorianReadOnly"><c>historianReadOnly</c> — true only when the value is a bool <c>true</c> (lmxopcua#899):
/// the tag's history is READ from an existing historian tag that OtOpcUa does not own, so OtOpcUa never provisions
/// (<c>EnsureTags</c>) it and never records captured values into it. Absent / <c>false</c> ⇒ an owned tag (the
/// behaviour before #899). Any other JSON value is a deploy error (<c>DraftValidator</c>, see
/// <see cref="HasMalformedHistorianReadOnly"/>), never silently "owned".</param>
public sealed record TagConfigIntent( public sealed record TagConfigIntent(
TagAlarmIntent? Alarm, TagAlarmIntent? Alarm,
bool IsHistorized, bool IsHistorized,
string? HistorianTagname, string? HistorianTagname,
bool IsArray, bool IsArray,
uint? ArrayLength) uint? ArrayLength,
bool HistorianReadOnly = false)
{ {
/// <summary>The <c>TagConfig</c> root key of the read-only historian binding (lmxopcua#899).</summary>
public const string HistorianReadOnlyKey = "historianReadOnly";
/// <summary> /// <summary>
/// Parse a Tag's schemaless <c>TagConfig</c> JSON into the cross-driver platform intents. /// Parse a Tag's schemaless <c>TagConfig</c> JSON into the cross-driver platform intents.
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default. /// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default.
@@ -47,9 +56,11 @@ public sealed record TagConfigIntent(
var (isHistorized, historianTagname) = ParseHistorize(root); var (isHistorized, historianTagname) = ParseHistorize(root);
var (isArray, arrayLength) = ParseArray(root); var (isArray, arrayLength) = ParseArray(root);
var historianReadOnly = root.TryGetProperty(HistorianReadOnlyKey, out var roEl)
&& roEl.ValueKind == JsonValueKind.True;
return new TagConfigIntent( return new TagConfigIntent(
ParseAlarm(root), isHistorized, historianTagname, isArray, arrayLength); ParseAlarm(root), isHistorized, historianTagname, isArray, arrayLength, historianReadOnly);
} }
catch (JsonException) catch (JsonException)
{ {
@@ -57,6 +68,30 @@ public sealed record TagConfigIntent(
} }
} }
/// <summary>
/// True when <paramref name="tagConfig"/> is a JSON object whose <c>historianReadOnly</c> key is present with a
/// value that is not a JSON boolean (e.g. the string <c>"true"</c>). <see cref="Parse"/> reads such a value as
/// <see langword="false"/> — an OWNED tag that gets provisioned — so the deploy gate refuses it instead
/// (lmxopcua#899). Never throws; malformed JSON ⇒ <see langword="false"/> (other rules report that).
/// </summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON.</param>
/// <returns><see langword="true"/> when the key is present and not a boolean.</returns>
public static bool HasMalformedHistorianReadOnly(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty(HistorianReadOnlyKey, out var el)
&& el.ValueKind is not (JsonValueKind.True or JsonValueKind.False);
}
catch (JsonException)
{
return false;
}
}
private static TagAlarmIntent? ParseAlarm(JsonElement root) private static TagAlarmIntent? ParseAlarm(JsonElement root)
{ {
if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null; if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
@@ -37,6 +37,7 @@ public static class DraftValidator
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by: // and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
ValidateRawNameCharset(draft, errors); ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors); ValidateHistorizedTagnameLength(draft, errors);
ValidateHistorianReadOnly(draft, errors);
ValidateUnsEffectiveLeafUniqueness(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors); ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors); ValidateCalculationTags(draft, errors);
@@ -720,6 +721,21 @@ public static class DraftValidator
} }
} }
/// <summary>lmxopcua#899: <c>historianReadOnly</c> must be a JSON boolean. The runtime parse
/// (<see cref="TagConfigIntent.Parse"/>) honours only a bool <c>true</c>, so a string <c>"true"</c> would deploy as an
/// OWNED tag — provisioned (<c>EnsureTags</c>, an upsert) and recorded into — exactly what the author asked not to
/// happen to a historian tag OtOpcUa does not own. A deploy error, never a silent "owned".</summary>
private static void ValidateHistorianReadOnly(DraftSnapshot draft, List<ValidationError> errors)
{
foreach (var t in draft.Tags)
{
if (!TagConfigIntent.HasMalformedHistorianReadOnly(t.TagConfig)) continue;
errors.Add(new("HistorianReadOnlyInvalid",
$"tag '{t.TagId}' TagConfig '{TagConfigIntent.HistorianReadOnlyKey}' must be a JSON boolean (true or false)",
t.TagId));
}
}
/// <summary>v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences /// <summary>v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences
/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms /// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms
/// (Name) — the three share the equipment's UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>). /// (Name) — the three share the equipment's UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>).
@@ -0,0 +1,58 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Resolves a raw tag's authored <c>DataType</c> string to the <see cref="DriverDataType"/> the historian
/// provisioning contract (<see cref="HistorianTagProvisionRequest"/>) carries (lmxopcua#898).
/// </summary>
/// <remarks>
/// <para>
/// <c>Tag.DataType</c> holds <b>OPC UA built-in type names</b> — what the AdminUI writes (<c>RawTagModal</c>,
/// manual entry, CSV import, <c>RawBrowseCommitMapper</c>) and what <c>OtOpcUaNodeManager.ResolveBuiltInDataType</c>
/// understands: <c>Float</c>, <c>Double</c>, <c>Byte</c>, <c>SByte</c>, … The provisioning hook used to
/// <c>Enum.TryParse&lt;DriverDataType&gt;</c> that string, which knows <c>Float32</c>/<c>Float64</c> but not
/// <c>Float</c>/<c>Double</c>, and has no <c>Byte</c>/<c>SByte</c> member, so historized Float, Double, Byte and
/// SByte tags were silently never provisioned.
/// </para>
/// <para>
/// Mapping (case-insensitive, trimmed):
/// <list type="bullet">
/// <item><c>Float</c> ⇒ <see cref="DriverDataType.Float32"/>, <c>Double</c> ⇒ <see cref="DriverDataType.Float64"/>.</item>
/// <item><c>Byte</c> (0..255) and <c>SByte</c> (−128..127) ⇒ <see cref="DriverDataType.Int16"/>: a lossless
/// widening (HistorianGateway's lossless-cast-or-reject rule). Int16 provisions as the historian's
/// <c>Int2</c>, the type Boolean already uses; the historian's one-byte <c>Int1</c> creation is
/// server-degenerate (#441) and its <c>UInt2</c> write path is the one OtOpcUa still widens away from.</item>
/// <item>The other OPC UA names that share a <see cref="DriverDataType"/> member name (<c>Boolean</c>,
/// <c>Int16</c>…<c>UInt64</c>, <c>String</c>, <c>DateTime</c>) map to that member.</item>
/// <item>A <see cref="DriverDataType"/> member name (<c>Float32</c>, <c>Float64</c>, <c>Reference</c>) is still
/// accepted, as before.</item>
/// <item>Anything else (<c>Guid</c>, <c>ByteString</c>, a number, blank) is unresolved: the caller skips the tag.</item>
/// </list>
/// Whether a resolved type is then historizable is the backend's decision (the Gateway skips
/// String/DateTime/Reference and the 64-bit integers; the SQL backends take every type).
/// </para>
/// </remarks>
public static class HistorianDataTypeNames
{
/// <summary>Resolves an authored tag data type name to the provisioning <see cref="DriverDataType"/>.</summary>
/// <param name="dataType">The tag's <c>DataType</c> string (an OPC UA built-in name, or a <see cref="DriverDataType"/> name).</param>
/// <param name="driverDataType">The resolved type when the method returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the name resolves; <see langword="false"/> for an unknown, numeric or blank name.</returns>
public static bool TryResolve(string? dataType, out DriverDataType driverDataType)
{
driverDataType = default;
var name = dataType?.Trim();
if (string.IsNullOrEmpty(name)) return false;
switch (name.ToUpperInvariant())
{
case "FLOAT": driverDataType = DriverDataType.Float32; return true;
case "DOUBLE": driverDataType = DriverDataType.Float64; return true;
case "BYTE":
case "SBYTE": driverDataType = DriverDataType.Int16; return true;
}
// Enum.TryParse also accepts a bare number ("3") or a comma list — neither is a type name.
if (!char.IsLetter(name[0]) || name.Contains(',')) return false;
return Enum.TryParse(name, ignoreCase: true, out driverDataType) && Enum.IsDefined(driverDataType);
}
}
@@ -47,8 +47,10 @@ public sealed record HistorianTagProvisionRequest(
/// </summary> /// </summary>
/// <param name="Requested">Total tags submitted.</param> /// <param name="Requested">Total tags submitted.</param>
/// <param name="Ensured">Tags the historian acknowledged as created or already present.</param> /// <param name="Ensured">Tags the historian acknowledged as created or already present.</param>
/// <param name="Skipped">Tags whose data type is not historizable on the backend (never sent).</param> /// <param name="Skipped">Tags never sent: a data type the backend cannot historize, or (Gateway
/// <param name="Failed">Tags that were sent but the backend did not acknowledge (incl. a swallowed transport error).</param> /// <c>ServerHistorian:ProvisionTags=CreateOnly</c>, lmxopcua#899) a tag that already exists and is left unchanged.</param>
/// <param name="Failed">Tags that were sent but the backend did not acknowledge (incl. a swallowed transport error), or —
/// under <c>CreateOnly</c> — whose existence lookup failed, so they were not sent.</param>
public sealed record HistorianProvisionResult( public sealed record HistorianProvisionResult(
int Requested, int Requested,
int Ensured, int Ensured,
@@ -126,6 +126,7 @@ public static class GatewayHistorian
return new GatewayTagProvisioner( return new GatewayTagProvisioner(
HistorianGatewayClientAdapter.Create(options, loggerFactory), HistorianGatewayClientAdapter.Create(options, loggerFactory),
logger); logger,
createOnly: options.ProvisionPolicy == HistorianProvisionPolicy.CreateOnly);
} }
} }
@@ -1,3 +1,4 @@
using Grpc.Core;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -10,6 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// Non-historizable driver types are skipped (never built into a definition); the historizable /// Non-historizable driver types are skipped (never built into a definition); the historizable
/// ones are mapped via <see cref="HistorianTypeMapper"/> and batched into a single /// ones are mapped via <see cref="HistorianTypeMapper"/> and batched into a single
/// <c>EnsureTags</c> call. /// <c>EnsureTags</c> call.
/// <para>
/// <b><c>EnsureTags</c> is an upsert</b>: it re-types and re-describes an EXISTING historian tag. Under
/// <c>ServerHistorian:ProvisionTags=CreateOnly</c> (lmxopcua#899) each historizable request is looked up first
/// (<see cref="IHistorianGatewayClient.TagExistsAsync"/> — a read) and only a tag the historian reports
/// ABSENT is sent; an existing tag is left as it is and counted in <see cref="HistorianProvisionResult.Skipped"/>.
/// A lookup that fails is counted <see cref="HistorianProvisionResult.Failed"/> and the tag is NOT sent — the
/// policy fails closed, never into an upsert.
/// </para>
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <b>Non-blocking.</b> A historian that is unreachable or errors must never fail an address-space /// <b>Non-blocking.</b> A historian that is unreachable or errors must never fail an address-space
@@ -37,18 +46,30 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
/// </summary> /// </summary>
private const double DefaultIntegralDivisor = 1.0; private const double DefaultIntegralDivisor = 1.0;
/// <summary>Concurrent existence lookups under <c>CreateOnly</c> (a deploy's first plan carries every tag).</summary>
private const int ExistenceLookupParallelism = 4;
private readonly IHistorianGatewayClient _client; private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayTagProvisioner> _logger; private readonly ILogger<GatewayTagProvisioner> _logger;
private readonly bool _createOnly;
/// <summary>Creates the provisioner over a gateway client seam.</summary> /// <summary>Creates the provisioner over a gateway client seam.</summary>
/// <param name="client">The gateway client used for the <c>EnsureTags</c> path.</param> /// <param name="client">The gateway client used for the <c>EnsureTags</c> path.</param>
/// <param name="logger">Logger for skip/failure diagnostics (never logs tag values).</param> /// <param name="logger">Logger for skip/failure diagnostics (never logs tag values).</param>
public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger<GatewayTagProvisioner> logger) /// <param name="createOnly">
/// <c>ServerHistorian:ProvisionTags=CreateOnly</c>: send only tags the historian reports absent, so an existing
/// tag is never modified. <see langword="false"/> (default) = the create-or-update upsert.
/// </param>
public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger<GatewayTagProvisioner> logger, bool createOnly = false)
{ {
_client = client ?? throw new ArgumentNullException(nameof(client)); _client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_createOnly = createOnly;
} }
/// <summary>Whether this provisioner runs under <c>ServerHistorian:ProvisionTags=CreateOnly</c> — test/diagnostic.</summary>
internal bool CreateOnly => _createOnly;
/// <inheritdoc /> /// <inheritdoc />
public async Task<HistorianProvisionResult> EnsureTagsAsync( public async Task<HistorianProvisionResult> EnsureTagsAsync(
IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct) IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)
@@ -57,7 +78,9 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
var definitions = new List<HistorianTagDefinition>(requests.Count); var definitions = new List<HistorianTagDefinition>(requests.Count);
var skipped = 0; var skipped = 0;
var lookupFailed = 0;
var candidates = new List<HistorianTagProvisionRequest>(requests.Count);
foreach (var request in requests) foreach (var request in requests)
{ {
if (!HistorianTypeMapper.IsHistorizable(request.DataType)) if (!HistorianTypeMapper.IsHistorizable(request.DataType))
@@ -69,6 +92,25 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
continue; continue;
} }
candidates.Add(request);
}
if (_createOnly && candidates.Count > 0)
{
var (absent, existing, failed) = await FilterToAbsentAsync(candidates, ct).ConfigureAwait(false);
skipped += existing;
lookupFailed = failed;
if (existing > 0 || failed > 0)
{
_logger.LogInformation(
"Historian provisioning (CreateOnly): {Existing} existing tag(s) left unchanged, {Failed} not sent because the lookup failed.",
existing, failed);
}
candidates = absent;
}
foreach (var request in candidates)
{
definitions.Add(new HistorianTagDefinition definitions.Add(new HistorianTagDefinition
{ {
TagName = request.TagName, TagName = request.TagName,
@@ -83,10 +125,11 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
}); });
} }
// Every request was non-historizable — nothing to send. Skip the empty gateway round-trip. // Nothing left to send (every request non-historizable, or kept/unresolved under CreateOnly). Skip the
// empty gateway round-trip.
if (definitions.Count == 0) if (definitions.Count == 0)
{ {
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: 0); return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: lookupFailed);
} }
try try
@@ -94,7 +137,7 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
var results = await _client.EnsureTagsAsync(definitions, ct).ConfigureAwait(false); var results = await _client.EnsureTagsAsync(definitions, ct).ConfigureAwait(false);
var ensured = results.Results.Count(r => r.Success); var ensured = results.Results.Count(r => r.Success);
var failed = Math.Max(0, definitions.Count - ensured); var failed = Math.Max(0, definitions.Count - ensured);
return new HistorianProvisionResult(requests.Count, ensured, skipped, failed); return new HistorianProvisionResult(requests.Count, ensured, skipped, failed + lookupFailed);
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ {
@@ -102,7 +145,7 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
// the unsent batch as Failed (it didn't land) but log quietly and never throw, keeping // the unsent batch as Failed (it didn't land) but log quietly and never throw, keeping
// the non-blocking contract. // the non-blocking contract.
_logger.LogDebug("Tag provisioning cancelled at shutdown; deferred."); _logger.LogDebug("Tag provisioning cancelled at shutdown; deferred.");
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count); return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count + lookupFailed);
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -111,10 +154,65 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
_logger.LogWarning( _logger.LogWarning(
"EnsureTags failed for {Count} historian tag(s) ({Exception}); provisioning deferred.", "EnsureTags failed for {Count} historian tag(s) ({Exception}); provisioning deferred.",
definitions.Count, exception.GetType().Name); definitions.Count, exception.GetType().Name);
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count); return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count + lookupFailed);
} }
} }
/// <summary>
/// <c>CreateOnly</c>: keeps only the requests whose tag the historian reports absent. An existing tag, or one whose
/// lookup failed (anything but a clean "absent" / gRPC <c>NotFound</c>), is dropped — fail closed.
/// </summary>
private async Task<(List<HistorianTagProvisionRequest> Absent, int Existing, int Failed)> FilterToAbsentAsync(
List<HistorianTagProvisionRequest> candidates, CancellationToken ct)
{
var verdicts = new bool?[candidates.Count]; // true = absent, false = exists, null = lookup failed
try
{
await Parallel.ForAsync(0, candidates.Count,
new ParallelOptions { MaxDegreeOfParallelism = ExistenceLookupParallelism, CancellationToken = ct },
async (i, token) =>
{
try
{
verdicts[i] = !await _client.TagExistsAsync(candidates[i].TagName, token).ConfigureAwait(false);
}
catch (Exception ex) when (IsNotFound(ex))
{
verdicts[i] = true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
verdicts[i] = null;
_logger.LogDebug("Historian tag lookup failed ({Exception}); the tag is not provisioned.", ex.GetType().Name);
}
}).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Shutdown: whatever was not looked up stays null (failed, not sent).
}
var absent = new List<HistorianTagProvisionRequest>(candidates.Count);
int existing = 0, failed = 0;
for (var i = 0; i < candidates.Count; i++)
{
switch (verdicts[i])
{
case true: absent.Add(candidates[i]); break;
case false: existing++; break;
default: failed++; break;
}
}
return (absent, existing, failed);
}
private static bool IsNotFound(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
if (e is RpcException { StatusCode: StatusCode.NotFound }) return true;
return false;
}
/// <summary> /// <summary>
/// Disposes the underlying gateway client (and its gRPC channel). The DI container owns this /// Disposes the underlying gateway client (and its gRPC channel). The DI container owns this
/// provisioner as a singleton (<c>AddHistorianProvisioning</c>), so this fires once at host /// provisioner as a singleton (<c>AddHistorianProvisioning</c>), so this fires once at host
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// <see cref="HistorianDataType"/> for tag provisioning + historical writes. /// <see cref="HistorianDataType"/> for tag provisioning + historical writes.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Only the nine numeric types are historizable on the gateway's analog write path. Two of them /// Seven numeric types are historizable on the gateway's analog write path. Two of them
/// fall back to a wider/adjacent historian type because the exact one's write path is not usable /// fall back to a wider/adjacent historian type because the exact one's write path is not usable
/// upstream: <see cref="DriverDataType.UInt16"/> maps to <see cref="HistorianDataType.Uint4"/> (the /// upstream: <see cref="DriverDataType.UInt16"/> maps to <see cref="HistorianDataType.Uint4"/> (the
/// historian's <c>UInt2</c> write path is not proven), and <see cref="DriverDataType.Boolean"/> maps /// historian's <c>UInt2</c> write path is not proven), and <see cref="DriverDataType.Boolean"/> maps
@@ -17,6 +17,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// stub), so Boolean historizes as a small 0/1 integer instead. String / DateTime / Reference are not /// stub), so Boolean historizes as a small 0/1 integer instead. String / DateTime / Reference are not
/// historized in v1 and throw <see cref="NotSupportedException"/>; callers that want to skip them /// historized in v1 and throw <see cref="NotSupportedException"/>; callers that want to skip them
/// without catching an exception should consult <see cref="IsHistorizable(DriverDataType)"/> first. /// without catching an exception should consult <see cref="IsHistorizable(DriverDataType)"/> first.
/// <para>
/// <see cref="DriverDataType.Int64"/> / <see cref="DriverDataType.UInt64"/> are not historizable either
/// (lmxopcua#898): HistorianGateway's type policy is lossless-cast-or-reject, its client cannot create
/// <c>Int8</c>/<c>UInt8</c> tags, and no creatable type holds a 64-bit integer losslessly (<c>Double</c> loses
/// precision above 2^53), so <c>EnsureTags(Int8/Uint8)</c> fails with <c>FailedPrecondition</c>. They used to be
/// sent and counted <c>failed</c>; they are now skipped up front and counted <c>skipped</c>. The OPC UA
/// <c>Byte</c>/<c>SByte</c> names reach this mapper already widened to <see cref="DriverDataType.Int16"/>
/// (<see cref="HistorianDataTypeNames"/>), so they provision as <c>Int2</c>.
/// </para>
/// </remarks> /// </remarks>
internal static class HistorianTypeMapper internal static class HistorianTypeMapper
{ {
@@ -32,12 +41,14 @@ internal static class HistorianTypeMapper
DriverDataType.Int16 => HistorianDataType.Int2, DriverDataType.Int16 => HistorianDataType.Int2,
DriverDataType.Int32 => HistorianDataType.Int4, DriverDataType.Int32 => HistorianDataType.Int4,
DriverDataType.Int64 => HistorianDataType.Int8,
DriverDataType.UInt16 => HistorianDataType.Uint4, // UInt2 write path deferred upstream → widen DriverDataType.UInt16 => HistorianDataType.Uint4, // UInt2 write path deferred upstream → widen
DriverDataType.UInt32 => HistorianDataType.Uint4, DriverDataType.UInt32 => HistorianDataType.Uint4,
DriverDataType.UInt64 => HistorianDataType.Uint8,
DriverDataType.Float32 => HistorianDataType.Float, DriverDataType.Float32 => HistorianDataType.Float,
DriverDataType.Float64 => HistorianDataType.Double, DriverDataType.Float64 => HistorianDataType.Double,
DriverDataType.Int64 or DriverDataType.UInt64 =>
throw new NotSupportedException(
$"DriverDataType.{dataType} is not historized on the Gateway backend " +
"(the historian cannot create Int8/UInt8 tags and no creatable type holds a 64-bit integer losslessly)."),
DriverDataType.String or DriverDataType.DateTime or DriverDataType.Reference => DriverDataType.String or DriverDataType.DateTime or DriverDataType.Reference =>
throw new NotSupportedException( throw new NotSupportedException(
$"DriverDataType.{dataType} is not historized in v1 " + $"DriverDataType.{dataType} is not historized in v1 " +
@@ -47,20 +58,18 @@ internal static class HistorianTypeMapper
}; };
/// <summary> /// <summary>
/// True when <paramref name="dataType"/> is one of the nine historizable numeric types — lets the /// True when <paramref name="dataType"/> is one of the seven historizable numeric types — lets the
/// provisioning hook skip deferred types without catching <see cref="NotSupportedException"/>. /// provisioning hook skip deferred types without catching <see cref="NotSupportedException"/>.
/// </summary> /// </summary>
/// <param name="dataType">The driver-agnostic data type.</param> /// <param name="dataType">The driver-agnostic data type.</param>
/// <returns>True if the type is one of the nine historizable numeric types; otherwise false.</returns> /// <returns>True if the type is one of the seven historizable numeric types; otherwise false.</returns>
public static bool IsHistorizable(DriverDataType dataType) => dataType switch public static bool IsHistorizable(DriverDataType dataType) => dataType switch
{ {
DriverDataType.Boolean DriverDataType.Boolean
or DriverDataType.Int16 or DriverDataType.Int16
or DriverDataType.Int32 or DriverDataType.Int32
or DriverDataType.Int64
or DriverDataType.UInt16 or DriverDataType.UInt16
or DriverDataType.UInt32 or DriverDataType.UInt32
or DriverDataType.UInt64
or DriverDataType.Float32 or DriverDataType.Float32
or DriverDataType.Float64 => true, or DriverDataType.Float64 => true,
_ => false, _ => false,
@@ -42,7 +42,7 @@ public static class RawTagCsvMapper
// Root TagConfig keys owned by the flag columns — stripped from the export residual so the // Root TagConfig keys owned by the flag columns — stripped from the export residual so the
// TagConfigJson fallback never duplicates a value already carried by a dedicated column. // TagConfigJson fallback never duplicates a value already carried by a dedicated column.
private static readonly string[] FlagKeys = private static readonly string[] FlagKeys =
["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"]; ["isHistorized", "historianTagname", "historianReadOnly", "isArray", "arrayLength", "alarm"];
// ---------------------------------------------------------------------------------------- Import // ---------------------------------------------------------------------------------------- Import
@@ -213,7 +213,8 @@ public static class RawTagCsvMapper
// Historize flags. // Historize flags.
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized")); var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname")); json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"),
ParseFlag("HistorianReadOnly", Cell("HistorianReadOnly")));
// Array flags. // Array flags.
var isArray = ParseFlag("IsArray", Cell("IsArray")); var isArray = ParseFlag("IsArray", Cell("IsArray"));
@@ -406,6 +407,7 @@ public static class RawTagCsvMapper
tag.PollGroupId ?? "", tag.PollGroupId ?? "",
hist.IsHistorized ? "true" : "", hist.IsHistorized ? "true" : "",
hist.HistorianTagname, hist.HistorianTagname,
hist.HistorianReadOnly ? "true" : "",
array.IsArray ? "true" : "", array.IsArray ? "true" : "",
array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "", array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "",
alarm.IsAlarm ? alarm.AlarmType : "", alarm.IsAlarm ? alarm.AlarmType : "",
@@ -433,6 +433,7 @@ public static class CsvColumnMap
"PollGroup", "PollGroup",
"IsHistorized", "IsHistorized",
"HistorianTagname", "HistorianTagname",
"HistorianReadOnly",
"IsArray", "IsArray",
"ArrayLength", "ArrayLength",
"Alarm.AlarmType", "Alarm.AlarmType",
@@ -1,10 +1,15 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary> /// <summary>
/// Pure, driver-agnostic merge helper for the two server-side HistoryRead intent keys at the ROOT of a /// Pure, driver-agnostic merge helper for the server-side HistoryRead intent keys at the ROOT of a
/// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default) and /// tag's <c>TagConfig</c> JSON: <c>isHistorized</c> (camelCase bool — omit/false default),
/// <c>historianTagname</c> (camelCase string override — omit when blank). These map to what the server's /// <c>historianTagname</c> (camelCase string override — omit when blank) and <c>historianReadOnly</c>
/// <c>AddressSpaceComposer.ExtractTagHistorize</c> reads (see <c>docs/Historian.md</c>). /// (lmxopcua#899 — camelCase bool, omit/false default: the history is read from an existing historian tag
/// OtOpcUa never provisions or records into). These map to what the server's <see cref="TagConfigIntent"/>
/// reads (see <c>docs/Historian.md</c>); <see cref="Read"/> uses that same parse, so what the AdminUI shows is
/// what the runtime does.
/// ///
/// <para> /// <para>
/// This is the TagModal-merge seam: the TagModal owns the canonical TagConfig JSON; the driver's typed /// This is the TagModal-merge seam: the TagModal owns the canonical TagConfig JSON; the driver's typed
@@ -17,10 +22,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// </summary> /// </summary>
public static class TagHistorizeConfig public static class TagHistorizeConfig
{ {
/// <summary>The two history-intent values parsed from a TagConfig JSON's root.</summary> /// <summary>The history-intent values parsed from a TagConfig JSON's root.</summary>
/// <param name="IsHistorized">Whether the server exposes OPC UA HistoryRead over this tag's node.</param> /// <param name="IsHistorized">Whether the server exposes OPC UA HistoryRead over this tag's node.</param>
/// <param name="HistorianTagname">Optional historian tagname override; <c>""</c> means default to the tag's FullName.</param> /// <param name="HistorianTagname">Optional historian tagname override; <c>""</c> means default to the tag's FullName.</param>
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname); /// <param name="HistorianReadOnly">Whether the historian tag is a read-only binding OtOpcUa never provisions or records into.</param>
public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname, bool HistorianReadOnly = false);
/// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key /// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
/// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary> /// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary>
@@ -31,7 +37,9 @@ public static class TagHistorizeConfig
var o = TagConfigJson.ParseOrNew(json); var o = TagConfigJson.ParseOrNew(json);
return new HistorizeState( return new HistorizeState(
TagConfigJson.GetBool(o, "isHistorized"), TagConfigJson.GetBool(o, "isHistorized"),
TagConfigJson.GetString(o, "historianTagname") ?? ""); TagConfigJson.GetString(o, "historianTagname") ?? "",
// The runtime's own parse (only a JSON true counts), not a lenient re-read.
TagConfigIntent.Parse(json).HistorianReadOnly);
} }
/// <summary>Merges the two history-intent keys into <paramref name="json"/>, preserving every other /// <summary>Merges the two history-intent keys into <paramref name="json"/>, preserving every other
@@ -41,13 +49,17 @@ public static class TagHistorizeConfig
/// <param name="json">The tag's existing TagConfig JSON, or null/empty to start from a new object.</param> /// <param name="json">The tag's existing TagConfig JSON, or null/empty to start from a new object.</param>
/// <param name="isHistorized">Whether the server should expose OPC UA HistoryRead over this tag's node.</param> /// <param name="isHistorized">Whether the server should expose OPC UA HistoryRead over this tag's node.</param>
/// <param name="historianTagname">Optional historian tagname override; null/blank omits the key.</param> /// <param name="historianTagname">Optional historian tagname override; null/blank omits the key.</param>
/// <returns>The merged TagConfig JSON with the two history-intent keys applied.</returns> /// <param name="historianReadOnly"><see langword="true"/> writes <c>historianReadOnly: true</c>, <see langword="false"/>
public static string Set(string? json, bool isHistorized, string? historianTagname) /// drops the key (absent ⇒ owned), <see langword="null"/> leaves whatever the JSON already carries untouched.</param>
/// <returns>The merged TagConfig JSON with the history-intent keys applied.</returns>
public static string Set(string? json, bool isHistorized, string? historianTagname, bool? historianReadOnly = null)
{ {
var o = TagConfigJson.ParseOrNew(json); var o = TagConfigJson.ParseOrNew(json);
TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null); TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null);
TagConfigJson.Set(o, "historianTagname", TagConfigJson.Set(o, "historianTagname",
string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim()); string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim());
if (historianReadOnly is { } readOnly)
TagConfigJson.Set(o, TagConfigIntent.HistorianReadOnlyKey, readOnly ? true : null);
return TagConfigJson.Serialize(o); return TagConfigJson.Serialize(o);
} }
} }
@@ -63,6 +63,18 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<Serve
$"ServerHistorian:Backend '{options.Backend}' is not a known historian backend. " $"ServerHistorian:Backend '{options.Backend}' is not a known historian backend. "
+ $"Allowed values: {string.Join(", ", HistorianBackendRegistry.KnownBackends)}."); + $"Allowed values: {string.Join(", ", HistorianBackendRegistry.KnownBackends)}.");
builder.RequireThat(
ServerHistorianOptions.TryParseProvisionPolicy(options.ProvisionTags, out var provisionPolicy),
$"ServerHistorian:ProvisionTags '{options.ProvisionTags}' is not a known provisioning policy. "
+ $"Allowed values: {string.Join(", ", Enum.GetNames<HistorianProvisionPolicy>())}.");
// CreateOnly needs an existence lookup only the Gateway provisioner makes; the SQL backends' stores are
// OtOpcUa's own tables (lmxopcua#899).
builder.RequireThat(
!options.Enabled || provisionPolicy != HistorianProvisionPolicy.CreateOnly || options.IsGatewayBackend,
$"ServerHistorian:ProvisionTags=CreateOnly is supported only by the Gateway backend (Backend is '{options.Backend}'). "
+ "Use CreateOrUpdate or Off, or opt individual tags out with historianReadOnly.");
var alarmHistorianEnabled = _configuration.GetValue<bool>("AlarmHistorian:Enabled"); var alarmHistorianEnabled = _configuration.GetValue<bool>("AlarmHistorian:Enabled");
var consumed = options.Enabled || alarmHistorianEnabled; var consumed = options.Enabled || alarmHistorianEnabled;
@@ -199,7 +199,13 @@ public sealed class AddressSpaceApplier
// notifier wiring, and an alarm-bearing delta never reaches here — the classifier routes it // notifier wiring, and an alarm-bearing delta never reaches here — the classifier routes it
// to Rebuild). So a delta whose ONLY difference is the referencing set needs NOTHING said to // to Rebuild). So a delta whose ONLY difference is the referencing set needs NOTHING said to
// the sink: compare the two sides with the referencing set copied across, and skip on equal. // the sink: compare the two sides with the referencing set copied across, and skip on equal.
if ((d.Previous with { ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths }).Equals(d.Current)) // HistorianReadOnly (lmxopcua#899) is node-inert the same way: it only steers the provisioning
// hook and the recorder feed below, never the node's attributes.
if ((d.Previous with
{
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
HistorianReadOnly = d.Current.HistorianReadOnly,
}).Equals(d.Current))
{ {
continue; continue;
} }
@@ -621,6 +627,15 @@ public sealed class AddressSpaceApplier
private static string ResolveHistorianName(RawTagPlan tag) => private static string ResolveHistorianName(RawTagPlan tag) =>
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname; string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
/// <summary>
/// A historized value tag (not a native-alarm condition) whose historian tag OtOpcUa OWNS — i.e. not a
/// read-only binding (<see cref="RawTagPlan.HistorianReadOnly"/>, lmxopcua#899). Only these are provisioned
/// (<c>EnsureTags</c>) and fed to the continuous-historization recorder; a read-only binding still serves
/// HistoryRead from the existing historian tag, which OtOpcUa never creates, modifies or writes.
/// </summary>
private static bool IsOwnedHistorizedValue(RawTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null && !tag.HistorianReadOnly;
/// <summary> /// <summary>
/// Auto-provision the historian for the added (and newly-historized / renamed-historian) raw tags. Runs on the OPC UA /// Auto-provision the historian for the added (and newly-historized / renamed-historian) raw tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request /// publish actor's pinned thread, so the synchronous portion is kept to building the request
@@ -639,7 +654,7 @@ public sealed class AddressSpaceApplier
// and skip them. // and skip them.
foreach (var tag in plan.AddedRawTags) foreach (var tag in plan.AddedRawTags)
{ {
if (!tag.IsHistorized || tag.Alarm is not null) continue; if (!IsOwnedHistorizedValue(tag)) continue;
AddRequest(tag); AddRequest(tag);
} }
@@ -647,12 +662,14 @@ public sealed class AddressSpaceApplier
// EXISTING raw tag never appears in AddedRawTags, so EnsureTags never fired for it. EnsureTags is // EXISTING raw tag never appears in AddedRawTags, so EnsureTags never fired for it. EnsureTags is
// an upsert, so re-ensuring is safe; an unchanged pair is a no-op. Only raw deltas are used - a // an upsert, so re-ensuring is safe; an unchanged pair is a no-op. Only raw deltas are used - a
// UNS mirror delta must not double-provision the same historian tag. // UNS mirror delta must not double-provision the same historian tag.
// A read-only binding (lmxopcua#899) is never provisioned; flipping read-only OFF counts as becoming
// owned, so it provisions like a historization flip.
foreach (var d in plan.ChangedRawTags) foreach (var d in plan.ChangedRawTags)
{ {
var cur = d.Current; var cur = d.Current;
if (!cur.IsHistorized || cur.Alarm is not null) continue; if (!IsOwnedHistorizedValue(cur)) continue;
var prev = d.Previous; var prev = d.Previous;
var wasProvisioned = prev.IsHistorized && prev.Alarm is null; var wasProvisioned = IsOwnedHistorizedValue(prev);
if (wasProvisioned if (wasProvisioned
&& ResolveHistorianName(prev) == ResolveHistorianName(cur) && ResolveHistorianName(prev) == ResolveHistorianName(cur)
&& string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase)) && string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase))
@@ -662,12 +679,15 @@ public sealed class AddressSpaceApplier
void AddRequest(RawTagPlan tag) void AddRequest(RawTagPlan tag)
{ {
// An unparseable data type is skipped (logged at Debug) rather than faulting the hook. // Tag.DataType is an OPC UA built-in name (Float, Double, Byte, …); resolve it with the shared
if (!Enum.TryParse<DriverDataType>(tag.DataType, ignoreCase: true, out var dataType)) // map (lmxopcua#898 — a bare Enum.TryParse<DriverDataType> silently skipped Float/Double/Byte/SByte).
// A type with no historian mapping (Guid, ByteString, …) is skipped rather than faulting the hook,
// at Information so a historized tag that never provisions is visible without Debug logging.
if (!HistorianDataTypeNames.TryResolve(tag.DataType, out var dataType))
{ {
_logger.LogDebug( _logger.LogInformation(
"AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType", "AddressSpaceApplier: not provisioning historized tag {Node}: data type '{DataType}' has no historian mapping",
tag.DataType); tag.NodeId, tag.DataType);
return; return;
} }
@@ -786,12 +806,15 @@ public sealed class AddressSpaceApplier
/// <c>HistorianName</c> = the value the EnsureTags hook / materialiser write under (a non-alarm /// <c>HistorianName</c> = the value the EnsureTags hook / materialiser write under (a non-alarm
/// historized value variable's <c>HistorianTagname</c> override, else its <c>FullName</c>). The /// historized value variable's <c>HistorianTagname</c> override, else its <c>FullName</c>). The
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized /// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
/// value variable (not historized, or a native-alarm condition node). /// value variable OtOpcUa records (not historized, a native-alarm condition node, or a read-only binding,
/// lmxopcua#899).
/// </summary> /// </summary>
/// <param name="tag">The raw tag to resolve a historized ref for.</param> /// <param name="tag">The raw tag to resolve a historized ref for.</param>
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns> /// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) => private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null // lmxopcua#899: a read-only binding is never recorded into — the recorder's WriteLiveValues would write
// into a historian tag OtOpcUa does not own. A flip of the flag is a ref appearing/disappearing below.
IsOwnedHistorizedValue(tag)
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the // v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
// default historian tagname; an override diverges only the historian name. // default historian tagname; an override diverges only the historian name.
? new HistorizedTagRef( ? new HistorizedTagRef(
@@ -143,7 +143,7 @@ public static class AddressSpaceChangeClassifier
}).Equals(d.Current); }).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable // v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable) // attributes (Writable / IsHistorized / HistorianTagname / HistorianReadOnly / DataType / IsArray / ArrayLength / ArrayWritable)
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the // and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
// same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or // same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or
// every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm // every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
@@ -160,6 +160,9 @@ public static class AddressSpaceChangeClassifier
Writable = d.Current.Writable, Writable = d.Current.Writable,
IsHistorized = d.Current.IsHistorized, IsHistorized = d.Current.IsHistorized,
HistorianTagname = d.Current.HistorianTagname, HistorianTagname = d.Current.HistorianTagname,
// lmxopcua#899: node-inert — the node serves HistoryRead the same either way; only the
// provisioning hook and the recorder feed read it.
HistorianReadOnly = d.Current.HistorianReadOnly,
DataType = d.Current.DataType, DataType = d.Current.DataType,
IsArray = d.Current.IsArray, IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength, ArrayLength = d.Current.ArrayLength,
@@ -155,6 +155,11 @@ public sealed record RawContainerNode(
/// every other tag. AVEVA stores the alarm's history under that reference's object, so the node manager adds the /// every other tag. AVEVA stores the alarm's history under that reference's object, so the node manager adds the
/// object to the event-history sources of every folder the condition fires on (Gitea #886). It is not in the /// object to the event-history sources of every folder the condition fires on (Gitea #886). It is not in the
/// surgical whitelist either: an alarm-bearing tag's delta rebuilds anyway. /// surgical whitelist either: an alarm-bearing tag's delta rebuilds anyway.
/// <see cref="HistorianReadOnly"/> (lmxopcua#899, <c>TagConfig.historianReadOnly</c>) marks a historized tag whose
/// history is read from an existing historian tag OtOpcUa does not own: the node still serves HistoryRead, but the
/// applier neither provisions the historian tag (<c>EnsureTags</c>) nor feeds it to the continuous-historization
/// recorder. It is node-inert, so it is in the surgical whitelist, and a UNS reference node does not mirror it
/// (UNS nodes neither provision nor record).
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close /// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom /// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added /// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
@@ -184,7 +189,8 @@ public sealed record RawTagPlan(
AddressSpaceRealm Realm = AddressSpaceRealm.Raw, AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
bool ArrayWritable = false, bool ArrayWritable = false,
bool ScalarRefusesArrayValue = false, bool ScalarRefusesArrayValue = false,
string? NativeAlarmReference = null) string? NativeAlarmReference = null,
bool HistorianReadOnly = false)
{ {
/// <inheritdoc /> /// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
@@ -209,6 +215,7 @@ public sealed record RawTagPlan(
ArrayWritable == other.ArrayWritable && ArrayWritable == other.ArrayWritable &&
ScalarRefusesArrayValue == other.ScalarRefusesArrayValue && ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
NativeAlarmReference == other.NativeAlarmReference && NativeAlarmReference == other.NativeAlarmReference &&
HistorianReadOnly == other.HistorianReadOnly &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc /> /// <inheritdoc />
@@ -219,6 +226,7 @@ public sealed record RawTagPlan(
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm); hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength); hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue); hash.Add(NativeAlarmReference); hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue); hash.Add(NativeAlarmReference);
hash.Add(HistorianReadOnly);
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal); foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode(); return hash.ToHashCode();
} }
@@ -847,7 +855,8 @@ public static class AddressSpaceComposer
ArrayLength: intent.ArrayLength, ArrayLength: intent.ArrayLength,
ArrayWritable: arrayWritable, ArrayWritable: arrayWritable,
ScalarRefusesArrayValue: scalarRefusesArray, ScalarRefusesArrayValue: scalarRefusesArray,
NativeAlarmReference: nativeAlarmReference)); NativeAlarmReference: nativeAlarmReference,
HistorianReadOnly: intent.HistorianReadOnly));
} }
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -106,6 +106,41 @@ public sealed class ServerHistorianOptions
/// </summary> /// </summary>
public bool GatewayAtTimeFromRaw { get; init; } = true; public bool GatewayAtTimeFromRaw { get; init; } = true;
/// <summary>
/// What the tag provisioning hook may do to the historian's tags (lmxopcua#899): <c>CreateOrUpdate</c> (default —
/// the behaviour before #899: <c>EnsureTags</c> is an upsert that creates a missing tag and re-types /
/// re-describes an existing one), <c>CreateOnly</c> (<c>Gateway</c> backend only: each tag is looked up first and
/// an EXISTING one is left exactly as it is; only missing tags are created) or <c>Off</c> (no provisioning at all;
/// HistoryRead and continuous historization are unaffected). Case-insensitive; an unknown value fails host start.
/// A tag's own <c>historianReadOnly: true</c> opts it out of provisioning (and recording) under every policy.
/// </summary>
public string ProvisionTags { get; init; } = nameof(HistorianProvisionPolicy.CreateOrUpdate);
/// <summary>
/// <see cref="ProvisionTags"/> parsed; an unknown value reads as <see cref="HistorianProvisionPolicy.CreateOrUpdate"/>
/// here (the options validator fails host start on it first).
/// </summary>
public HistorianProvisionPolicy ProvisionPolicy =>
TryParseProvisionPolicy(ProvisionTags, out var policy) ? policy : HistorianProvisionPolicy.CreateOrUpdate;
/// <summary>Parses a <see cref="ProvisionTags"/> value (case-insensitive, trimmed; blank ⇒ the default).</summary>
/// <param name="value">The configured value.</param>
/// <param name="policy">The parsed policy.</param>
/// <returns><see langword="true"/> when the value names a policy (or is blank).</returns>
public static bool TryParseProvisionPolicy(string? value, out HistorianProvisionPolicy policy)
{
policy = HistorianProvisionPolicy.CreateOrUpdate;
var v = value?.Trim();
if (string.IsNullOrEmpty(v)) return true;
foreach (var candidate in Enum.GetValues<HistorianProvisionPolicy>())
{
if (!string.Equals(v, candidate.ToString(), StringComparison.OrdinalIgnoreCase)) continue;
policy = candidate;
return true;
}
return false;
}
/// <summary> /// <summary>
/// <c>Gateway</c> backend only: the <c>ServerHistorian:Gateway</c> sub-section — knobs that exist because of the /// <c>Gateway</c> backend only: the <c>ServerHistorian:Gateway</c> sub-section — knobs that exist because of the
/// HistorianGateway's wire contract (see <see cref="GatewayHistorianSettings"/>). Ignored by the other backends. /// HistorianGateway's wire contract (see <see cref="GatewayHistorianSettings"/>). Ignored by the other backends.
@@ -303,3 +338,16 @@ public sealed class GatewayHistorianSettings
/// </summary> /// </summary>
public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(30);
} }
/// <summary>What the historian tag provisioning hook may do (<see cref="ServerHistorianOptions.ProvisionTags"/>, lmxopcua#899).</summary>
public enum HistorianProvisionPolicy
{
/// <summary>Create a missing tag and update (re-type, re-describe) an existing one — the upsert, and the default.</summary>
CreateOrUpdate,
/// <summary>Create a missing tag; never touch an existing one (<c>Gateway</c> backend only).</summary>
CreateOnly,
/// <summary>No provisioning.</summary>
Off,
}
@@ -283,6 +283,8 @@ public static class ServiceCollectionExtensions
{ {
var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>(); var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
// ServerHistorian:ProvisionTags=Off (lmxopcua#899): keep the Null default — reads and recording are unaffected.
if (opts.ProvisionPolicy == HistorianProvisionPolicy.Off) return services;
// Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime. // Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
services.AddSingleton<IHistorianProvisioning>(sp => provisioningFactory(opts, sp)); services.AddSingleton<IHistorianProvisioning>(sp => provisioningFactory(opts, sp));
@@ -124,4 +124,28 @@ public sealed class TagConfigIntentTests
{ {
Should.NotThrow(() => TagConfigIntent.Parse(cfg)); Should.NotThrow(() => TagConfigIntent.Parse(cfg));
} }
// ---- lmxopcua#899: historianReadOnly ----
[Theory]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":true}", true)]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":false}", false)]
[InlineData("{\"isHistorized\":true}", false)]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":\"true\"}", false)]
[InlineData("{\"isHistorized\":true,\"historianreadonly\":true}", false)]
[InlineData(null, false)]
public void HistorianReadOnly_is_true_only_for_a_json_true(string? cfg, bool expected)
=> TagConfigIntent.Parse(cfg).HistorianReadOnly.ShouldBe(expected);
[Theory]
[InlineData("{\"historianReadOnly\":\"true\"}", true)]
[InlineData("{\"historianReadOnly\":1}", true)]
[InlineData("{\"historianReadOnly\":null}", true)]
[InlineData("{\"historianReadOnly\":true}", false)]
[InlineData("{\"historianReadOnly\":false}", false)]
[InlineData("{}", false)]
[InlineData("not json {", false)]
[InlineData(null, false)]
public void HasMalformedHistorianReadOnly_flags_only_a_present_non_boolean(string? cfg, bool expected)
=> TagConfigIntent.HasMalformedHistorianReadOnly(cfg).ShouldBe(expected);
} }
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// lmxopcua#899: <c>historianReadOnly</c> must be a JSON boolean. The runtime honours only a bool <c>true</c>, so a
/// string <c>"true"</c> would deploy as an OWNED tag whose foreign historian tag then gets provisioned (upserted)
/// and recorded into — the deploy gate refuses it instead.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorHistorianReadOnlyTests
{
private static DraftSnapshot Draft(params Tag[] tags) => new()
{
GenerationId = 1, ClusterId = "c",
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "di", ClusterId = "c", Name = "mb", DriverType = "Modbus", DriverConfig = "{}",
},
],
Devices = [new Device { DeviceId = "dev", DriverInstanceId = "di", Name = "plc", DeviceConfig = "{}" }],
Tags = tags,
};
private static Tag TagRow(string id, string tagConfig) => new()
{
TagId = id, DeviceId = "dev", Name = id, DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
};
[Theory]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":\"true\"}")]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":1}")]
[InlineData("{\"historianReadOnly\":null}")]
public void A_non_boolean_historianReadOnly_is_a_deploy_error(string tagConfig)
{
var errors = DraftValidator.Validate(Draft(TagRow("t-bad", tagConfig)));
errors.ShouldContain(e => e.Code == "HistorianReadOnlyInvalid" && e.Context == "t-bad");
}
[Theory]
[InlineData("{\"isHistorized\":true,\"historianTagname\":\"SysTimeSec\",\"historianReadOnly\":true}")]
[InlineData("{\"isHistorized\":true,\"historianReadOnly\":false}")]
[InlineData("{\"isHistorized\":true}")]
public void A_boolean_or_absent_historianReadOnly_passes(string tagConfig)
{
var errors = DraftValidator.Validate(Draft(TagRow("t-ok", tagConfig)));
errors.ShouldNotContain(e => e.Code == "HistorianReadOnlyInvalid");
}
}
@@ -0,0 +1,48 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
/// <summary>
/// lmxopcua#898: a raw tag's <c>DataType</c> is an OPC UA built-in name. Every name the AdminUI offers
/// (<c>RawTagModal.DataTypes</c>) must resolve the way the historian provisioning hook expects, or be explicitly
/// unresolved — <c>Float</c>/<c>Double</c>/<c>Byte</c>/<c>SByte</c> used to be silently dropped.
/// </summary>
public sealed class HistorianDataTypeNamesTests
{
[Theory]
[InlineData("Boolean", DriverDataType.Boolean)]
[InlineData("SByte", DriverDataType.Int16)] // lossless widening; Int1 creation is server-degenerate
[InlineData("Byte", DriverDataType.Int16)] // lossless widening
[InlineData("Int16", DriverDataType.Int16)]
[InlineData("UInt16", DriverDataType.UInt16)]
[InlineData("Int32", DriverDataType.Int32)]
[InlineData("UInt32", DriverDataType.UInt32)]
[InlineData("Int64", DriverDataType.Int64)]
[InlineData("UInt64", DriverDataType.UInt64)]
[InlineData("Float", DriverDataType.Float32)]
[InlineData("Double", DriverDataType.Float64)]
[InlineData("String", DriverDataType.String)]
[InlineData("DateTime", DriverDataType.DateTime)]
// DriverDataType spellings are still accepted, case-insensitively and trimmed.
[InlineData("Float32", DriverDataType.Float32)]
[InlineData("float64", DriverDataType.Float64)]
[InlineData(" double ", DriverDataType.Float64)]
[InlineData("Reference", DriverDataType.Reference)]
public void Resolves(string name, DriverDataType expected)
{
HistorianDataTypeNames.TryResolve(name, out var resolved).ShouldBeTrue();
resolved.ShouldBe(expected);
}
[Theory]
[InlineData("Guid")]
[InlineData("ByteString")]
[InlineData("3")] // Enum.TryParse would take a bare number
[InlineData("Int16,Int32")] // ...or a flags-style list
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Does_not_resolve(string? name) => HistorianDataTypeNames.TryResolve(name, out _).ShouldBeFalse();
}
@@ -35,4 +35,21 @@ public sealed class GatewayHistorianProviderTests
Assert.IsType<GatewayTagProvisioner>(p.CreateProvisioning(sp)); Assert.IsType<GatewayTagProvisioner>(p.CreateProvisioning(sp));
await writer.DisposeAsync(); await writer.DisposeAsync();
} }
/// <summary>lmxopcua#899: ServerHistorian:ProvisionTags reaches the provisioner (CreateOnly on, the default off).</summary>
[Theory]
[InlineData("CreateOnly", true)]
[InlineData("createonly", true)]
[InlineData("CreateOrUpdate", false)]
[InlineData("", false)]
public async Task ProvisionTags_policy_reaches_the_provisioner(string policy, bool createOnly)
{
var p = new GatewayHistorianProvider(new ServerHistorianOptions
{
Endpoint = "http://localhost:5222", UseTls = false, ApiKey = "histgw_test_secret", ProvisionTags = policy,
});
var provisioner = Assert.IsType<GatewayTagProvisioner>(p.CreateProvisioning(Sp()));
Assert.Equal(createOnly, provisioner.CreateOnly);
await provisioner.DisposeAsync();
}
} }
@@ -1,3 +1,4 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Xunit; using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
@@ -220,4 +221,120 @@ public sealed class GatewayTagProvisionerTests
Assert.Equal(0, result.Skipped); Assert.Equal(0, result.Skipped);
Assert.Equal(1, result.Failed); Assert.Equal(1, result.Failed);
} }
// ---- lmxopcua#899: ServerHistorian:ProvisionTags=CreateOnly ------------------------------------------
private static TagOperationResults AllSucceeded(int count)
{
var results = new TagOperationResults();
for (var i = 0; i < count; i++) results.Results.Add(new TagOperationResult { Success = true });
return results;
}
/// <summary>The default (CreateOrUpdate) never looks a tag up — it is the upsert, unchanged.</summary>
[Fact]
public async Task Default_policy_sends_without_an_existence_lookup()
{
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = AllSucceeded(1), TagExistsResult = true };
await Provisioner(fake).EnsureTagsAsync(
new[] { new HistorianTagProvisionRequest("SysTimeSec", DriverDataType.Int32, null, "t") },
TestContext.Current.CancellationToken);
Assert.Equal(0, fake.TagExistsCallCount);
Assert.Equal(1, fake.EnsureTagsCallCount);
Assert.Equal("SysTimeSec", Assert.Single(fake.LastEnsureDefinitions!).TagName);
}
/// <summary>CreateOnly: an EXISTING historian tag never reaches EnsureTags (it would be re-typed), a missing one
/// does (positive control), and the tally counts the kept tag as skipped.</summary>
[Fact]
public async Task CreateOnly_sends_only_tags_the_historian_reports_absent()
{
var fake = new FakeHistorianGatewayClient
{
EnsureTagsResult = AllSucceeded(1),
TagExistsSource = (tag, _) => Task.FromResult(tag == "SysTimeSec"),
};
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance, createOnly: true);
var result = await p.EnsureTagsAsync(new[]
{
new HistorianTagProvisionRequest("SysTimeSec", DriverDataType.Int32, null, "existing"),
new HistorianTagProvisionRequest("OtOpcUa.New", DriverDataType.Float32, null, "new"),
}, TestContext.Current.CancellationToken);
Assert.Equal(2, fake.TagExistsCallCount);
Assert.Equal("OtOpcUa.New", Assert.Single(fake.LastEnsureDefinitions!).TagName);
Assert.Equal(new HistorianProvisionResult(2, Ensured: 1, Skipped: 1, Failed: 0), result);
}
/// <summary>CreateOnly when every tag exists: no EnsureTags call at all.</summary>
[Fact]
public async Task CreateOnly_with_only_existing_tags_makes_no_EnsureTags_call()
{
var fake = new FakeHistorianGatewayClient { TagExistsResult = true };
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance, createOnly: true);
var result = await p.EnsureTagsAsync(
new[] { new HistorianTagProvisionRequest("SysPulse", DriverDataType.Boolean, null, null) },
TestContext.Current.CancellationToken);
Assert.Equal(0, fake.EnsureTagsCallCount);
Assert.Equal(new HistorianProvisionResult(1, Ensured: 0, Skipped: 1, Failed: 0), result);
}
/// <summary>CreateOnly fails closed: a lookup that errors is NOT followed by an upsert; a gRPC NotFound is a clean
/// "absent" and the tag is created.</summary>
[Fact]
public async Task CreateOnly_fails_closed_on_a_lookup_error_and_treats_NotFound_as_absent()
{
var fake = new FakeHistorianGatewayClient
{
EnsureTagsResult = AllSucceeded(1),
TagExistsSource = (tag, _) => tag == "Broken"
? Task.FromException<bool>(new RpcException(new Status(StatusCode.Unavailable, "down")))
: Task.FromException<bool>(new RpcException(new Status(StatusCode.NotFound, "no such tag"))),
};
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance, createOnly: true);
var result = await p.EnsureTagsAsync(new[]
{
new HistorianTagProvisionRequest("Broken", DriverDataType.Float64, null, null),
new HistorianTagProvisionRequest("Missing", DriverDataType.Float64, null, null),
}, TestContext.Current.CancellationToken);
Assert.Equal("Missing", Assert.Single(fake.LastEnsureDefinitions!).TagName);
Assert.Equal(new HistorianProvisionResult(2, Ensured: 1, Skipped: 0, Failed: 1), result);
}
/// <summary>CreateOnly looks up only historizable requests — a deferred type is skipped before any lookup.</summary>
[Fact]
public async Task CreateOnly_does_not_look_up_non_historizable_types()
{
var fake = new FakeHistorianGatewayClient { TagExistsResult = false };
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance, createOnly: true);
var result = await p.EnsureTagsAsync(
new[] { new HistorianTagProvisionRequest("Text", DriverDataType.String, null, null) },
TestContext.Current.CancellationToken);
Assert.Equal(0, fake.TagExistsCallCount);
Assert.Equal(0, fake.EnsureTagsCallCount);
Assert.Equal(1, result.Skipped);
}
/// <summary>lmxopcua#898: a 64-bit integer is skipped up front (the gateway cannot create Int8/UInt8).</summary>
[Fact]
public async Task Sixty_four_bit_integers_are_skipped_not_sent()
{
var fake = new FakeHistorianGatewayClient();
var result = await Provisioner(fake).EnsureTagsAsync(new[]
{
new HistorianTagProvisionRequest("Big", DriverDataType.Int64, null, null),
new HistorianTagProvisionRequest("BigU", DriverDataType.UInt64, null, null),
}, TestContext.Current.CancellationToken);
Assert.Equal(0, fake.EnsureTagsCallCount);
Assert.Equal(new HistorianProvisionResult(2, Ensured: 0, Skipped: 2, Failed: 0), result);
}
} }
@@ -11,10 +11,8 @@ public sealed class HistorianTypeMapperTests
[InlineData(DriverDataType.Boolean, HistorianDataType.Int2)] // Int1 analog-tag creation is server-degenerate (issue #441) → store 0/1 [InlineData(DriverDataType.Boolean, HistorianDataType.Int2)] // Int1 analog-tag creation is server-degenerate (issue #441) → store 0/1
[InlineData(DriverDataType.Int16, HistorianDataType.Int2)] [InlineData(DriverDataType.Int16, HistorianDataType.Int2)]
[InlineData(DriverDataType.Int32, HistorianDataType.Int4)] [InlineData(DriverDataType.Int32, HistorianDataType.Int4)]
[InlineData(DriverDataType.Int64, HistorianDataType.Int8)]
[InlineData(DriverDataType.UInt16, HistorianDataType.Uint4)] // fallback: UInt2 write deferred upstream [InlineData(DriverDataType.UInt16, HistorianDataType.Uint4)] // fallback: UInt2 write deferred upstream
[InlineData(DriverDataType.UInt32, HistorianDataType.Uint4)] [InlineData(DriverDataType.UInt32, HistorianDataType.Uint4)]
[InlineData(DriverDataType.UInt64, HistorianDataType.Uint8)]
[InlineData(DriverDataType.Float32, HistorianDataType.Float)] [InlineData(DriverDataType.Float32, HistorianDataType.Float)]
[InlineData(DriverDataType.Float64, HistorianDataType.Double)] [InlineData(DriverDataType.Float64, HistorianDataType.Double)]
public void Maps_writable_numeric_types(DriverDataType d, HistorianDataType expected) public void Maps_writable_numeric_types(DriverDataType d, HistorianDataType expected)
@@ -30,6 +28,18 @@ public sealed class HistorianTypeMapperTests
Assert.Contains("not historized in v1", ex.Message); // human-actionable, no tag value leaked Assert.Contains("not historized in v1", ex.Message); // human-actionable, no tag value leaked
} }
/// <summary>lmxopcua#898: HistorianGateway is lossless-cast-or-reject and cannot create Int8/UInt8, so the 64-bit
/// integers are refused here (skipped by the provisioner) instead of being sent to fail.</summary>
[Theory]
[InlineData(DriverDataType.Int64)]
[InlineData(DriverDataType.UInt64)]
public void Sixty_four_bit_integers_are_not_historizable(DriverDataType d)
{
Assert.False(HistorianTypeMapper.IsHistorizable(d));
var ex = Assert.Throws<NotSupportedException>(() => HistorianTypeMapper.ToHistorianDataType(d));
Assert.Contains("losslessly", ex.Message);
}
[Fact] // matrix guard: a new DriverDataType member must be classified (mapped or explicitly deferred) [Fact] // matrix guard: a new DriverDataType member must be classified (mapped or explicitly deferred)
public void Every_DriverDataType_member_is_classified() public void Every_DriverDataType_member_is_classified()
{ {
@@ -68,6 +68,43 @@ public sealed class RawTagCsvRoundTripTests
AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy }); AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy });
} }
/// <summary>lmxopcua#899: the HistorianReadOnly column round-trips a read-only binding, and an exported row carries
/// it in its own column, never in the TagConfigJson residual.</summary>
[Fact]
public void Historian_read_only_binding_round_trips_through_its_own_column()
{
var ro = new RawCsvExportTag(
null, "systime", "Int32", TagAccessLevel.Read, false, null,
"""{"FullName":"Reactor_001.Temp","isHistorized":true,"historianTagname":"SysTimeSec","historianReadOnly":true}""");
AssertRoundTrip(DriverTypeNames.Galaxy, new[] { ro });
var csv = RawTagCsvMapper.Export(DriverTypeNames.Galaxy, new[] { ro });
var rows = CsvParser.Parse(csv);
var header = rows[0].ToList();
var row = rows[1];
row[header.IndexOf("HistorianReadOnly")].ShouldBe("true");
row[header.IndexOf(CsvColumnMap.TagConfigJsonColumn)].ShouldNotContain("historianReadOnly");
}
/// <summary>lmxopcua#899: the column imports strictly — "true" sets the key, blank leaves an owned tag, and a
/// non-boolean cell is a row error rather than a silently owned tag.</summary>
[Theory]
[InlineData("true", true, true)]
[InlineData("", true, false)]
[InlineData("maybe", false, false)]
public void HistorianReadOnly_column_imports_strictly(string cell, bool valid, bool readOnly)
{
var csv = "Name,DataType,AccessLevel,IsHistorized,HistorianTagname,HistorianReadOnly,TagConfigJson\n"
+ $"t1,Int32,Read,true,SysTimeSec,{cell},\"{{\"\"FullName\"\":\"\"A.B\"\"}}\"\n";
var result = RawTagCsvMapper.Parse(DriverTypeNames.Galaxy, csv);
var reviewRow = result.Rows.ShouldHaveSingleItem();
reviewRow.Ok.ShouldBe(valid);
if (valid) TagHistorizeConfig.Read(reviewRow.ImportRow!.Tag.TagConfig).HistorianReadOnly.ShouldBe(readOnly);
}
[Fact] [Fact]
public void Variable_length_array_round_trips_lmxopcua_770() public void Variable_length_array_round_trips_lmxopcua_770()
{ {
@@ -143,4 +143,30 @@ public sealed class TagHistorizeConfigTests
h.HistorianTagname.ShouldBe("TN"); h.HistorianTagname.ShouldBe("TN");
OpcUaClientTagConfigModel.FromJson(afterEditor).NodeId.ShouldBe("ns=2;s=X"); OpcUaClientTagConfigModel.FromJson(afterEditor).NodeId.ShouldBe("ns=2;s=X");
} }
// ---- lmxopcua#899: historianReadOnly ----
[Fact]
public void Set_writes_and_clears_historianReadOnly_and_Read_uses_the_runtime_parse()
{
var on = TagHistorizeConfig.Set("""{"a":1}""", isHistorized: true, historianTagname: "SysTimeSec", historianReadOnly: true);
on.ShouldContain("\"historianReadOnly\":true");
TagHistorizeConfig.Read(on).ShouldBe(new TagHistorizeConfig.HistorizeState(true, "SysTimeSec", true));
var off = TagHistorizeConfig.Set(on, isHistorized: true, historianTagname: "SysTimeSec", historianReadOnly: false);
off.ShouldNotContain("historianReadOnly");
off.ShouldContain("\"a\":1");
}
[Fact]
public void Set_without_a_read_only_argument_preserves_the_existing_key()
{
var json = TagHistorizeConfig.Set("""{"historianReadOnly":true}""", isHistorized: true, historianTagname: null);
TagHistorizeConfig.Read(json).HistorianReadOnly.ShouldBeTrue();
}
/// <summary>A string "true" is what the runtime treats as NOT read-only; the AdminUI must say the same.</summary>
[Fact]
public void Read_reports_a_string_true_as_not_read_only_like_the_runtime()
=> TagHistorizeConfig.Read("""{"isHistorized":true,"historianReadOnly":"true"}""").HistorianReadOnly.ShouldBeFalse();
} }
@@ -460,4 +460,41 @@ public sealed class ServerHistorianOptionsValidatorTests
result.Failed.ShouldBeTrue(); result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:TablePrefix")); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:TablePrefix"));
} }
// ---- lmxopcua#899: ServerHistorian:ProvisionTags ----
/// <summary>An unknown policy fails, even when nothing consumes the section (like an unknown backend).</summary>
[Fact]
public void Unknown_ProvisionTags_policy_fails()
{
var result = Sut().Validate(null, new ServerHistorianOptions { ProvisionTags = "UpdateOnly" });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ProvisionTags") && f.Contains("CreateOnly"));
}
/// <summary>CreateOnly needs the Gateway's existence lookup; on an enabled SQL backend it fails host start.</summary>
[Theory]
[InlineData("SqlServer", true)]
[InlineData("QuestDb", true)]
[InlineData("Gateway", false)]
public void CreateOnly_is_gateway_only(string backend, bool fails)
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Backend = backend, Enabled = true, ProvisionTags = "createonly",
Endpoint = "https://host:5222", ApiKey = "histgw_x_y", UseTls = true,
});
result.Failures?.Any(f => f.Contains("ProvisionTags=CreateOnly")).ShouldBe(fails);
}
/// <summary>The policies parse case-insensitively; blank is the default.</summary>
[Theory]
[InlineData("CreateOrUpdate", HistorianProvisionPolicy.CreateOrUpdate)]
[InlineData(" off ", HistorianProvisionPolicy.Off)]
[InlineData("CREATEONLY", HistorianProvisionPolicy.CreateOnly)]
[InlineData("", HistorianProvisionPolicy.CreateOrUpdate)]
public void ProvisionTags_parses(string value, HistorianProvisionPolicy expected)
=> new ServerHistorianOptions { ProvisionTags = value }.ProvisionPolicy.ShouldBe(expected);
} }
@@ -126,24 +126,88 @@ public sealed class AddressSpaceApplierProvisioningTests
outcome.AddedNodes.ShouldBeGreaterThan(0); outcome.AddedNodes.ShouldBeGreaterThan(0);
} }
/// <summary>An added historized tag whose DataType string is not a <see cref="DriverDataType"/> is /// <summary>lmxopcua#898: Tag.DataType holds OPC UA built-in names (what the AdminUI writes). Float, Double, Byte and
/// skipped (no request) — the hook never throws on an unparseable type.</summary> /// SByte used to fail <c>Enum.TryParse&lt;DriverDataType&gt;</c> and were silently never provisioned; they now resolve
/// (Float→Float32, Double→Float64, Byte/SByte→Int16, a lossless widening). The DriverDataType spelling still works,
/// and a name with no historian mapping (Guid) is skipped without faulting the hook.</summary>
[Fact] [Fact]
public async Task Apply_skips_added_tag_with_unparseable_datatype() public async Task Apply_provisions_opc_ua_type_names_and_skips_unmapped_ones()
{ {
var prov = new CapturingProvisioner(); var prov = new CapturingProvisioner();
var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger<AddressSpaceApplier>.Instance, prov); var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger<AddressSpaceApplier>.Instance, prov);
// "Float" is NOT a DriverDataType member (the members are Float32/Float64); it must be skipped.
var plan = PlanWithAddedTags( var plan = PlanWithAddedTags(
HistorizedTag(displayName: "Bad", historianName: "Pump1.Bad", dataType: "Float"), HistorizedTag(displayName: "F", historianName: "P.F", dataType: "Float"),
HistorizedTag(displayName: "Good", historianName: "Pump1.Good", dataType: "Float32")); HistorizedTag(displayName: "D", historianName: "P.D", dataType: "Double"),
HistorizedTag(displayName: "B", historianName: "P.B", dataType: "Byte"),
HistorizedTag(displayName: "S", historianName: "P.S", dataType: "SByte"),
HistorizedTag(displayName: "F32", historianName: "P.F32", dataType: "Float32"),
HistorizedTag(displayName: "G", historianName: "P.G", dataType: "Guid"));
applier.Apply(plan); applier.Apply(plan);
await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
prov.Seen.Count.ShouldBe(1); prov.Seen.Select(r => (r.TagName, r.DataType)).ShouldBe(new[]
prov.Seen[0].TagName.ShouldBe("Pump1.Good"); {
("P.F", DriverDataType.Float32),
("P.D", DriverDataType.Float64),
("P.B", DriverDataType.Int16),
("P.S", DriverDataType.Int16),
("P.F32", DriverDataType.Float32),
}, ignoreOrder: true);
}
/// <summary>lmxopcua#899: a read-only binding (<c>historianReadOnly: true</c>) never reaches EnsureTags — neither
/// as an add nor as a change. The owned "Control" tag is the positive control, so the absence is not a vacuous
/// no-dispatch; the override-without-the-flag tag shows the chosen default is unchanged (an override alone still
/// provisions).</summary>
[Fact]
public async Task Apply_never_provisions_a_read_only_binding()
{
var prov = new CapturingProvisioner();
var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger<AddressSpaceApplier>.Instance, prov);
var readOnlyAdded = HistorizedTag("SysTime", "SysTimeSec", "Int32", fullName: "Plant/SysTime") with { HistorianReadOnly = true };
var flipToReadOnlyOld = HistorizedTag("Pulse", "SysPulse", "Boolean", fullName: "Plant/Pulse");
var flipToReadOnlyNew = flipToReadOnlyOld with { HistorianReadOnly = true, DataType = "Int32" };
var readOnlyRenamedOld = HistorizedTag("Ro", "Other.A", "Float", fullName: "Plant/Ro") with { HistorianReadOnly = true };
var readOnlyRenamedNew = readOnlyRenamedOld with { HistorianTagname = "Other.B" };
var plan = PlanWithAddedTags(
HistorizedTag("Control", "Control.Name", "Float"),
HistorizedTag("Override", "Someones.Tag", "Double", fullName: "Plant/Override"),
readOnlyAdded) with
{
ChangedRawTags = new[]
{
new AddressSpacePlan.RawTagDelta(flipToReadOnlyOld, flipToReadOnlyNew),
new AddressSpacePlan.RawTagDelta(readOnlyRenamedOld, readOnlyRenamedNew),
},
};
applier.Apply(plan);
await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
prov.Seen.Select(r => r.TagName).ShouldBe(new[] { "Control.Name", "Someones.Tag" }, ignoreOrder: true);
}
/// <summary>lmxopcua#899: turning read-only OFF makes the tag owned, so it provisions like a historization flip.</summary>
[Fact]
public async Task Apply_provisions_a_tag_whose_read_only_flag_is_cleared()
{
var prov = new CapturingProvisioner();
var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger<AddressSpaceApplier>.Instance, prov);
var before = HistorizedTag("Own", "Mine.Tag", "Float", fullName: "Plant/Own") with { HistorianReadOnly = true };
var plan = PlanWithAddedTags() with
{
ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(before, before with { HistorianReadOnly = false }) },
};
applier.Apply(plan);
await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
prov.Seen.Select(r => r.TagName).ShouldBe(new[] { "Mine.Tag" });
} }
/// <summary>#600.1: a CHANGED raw tag that flips to historized, or whose historian name / data type /// <summary>#600.1: a CHANGED raw tag that flips to historized, or whose historian name / data type
@@ -348,6 +412,42 @@ public sealed class AddressSpaceApplierProvisioningTests
}, ignoreOrder: true); }, ignoreOrder: true);
} }
/// <summary>lmxopcua#899: a read-only binding is never fed to the recorder (its WriteLiveValues would write into a
/// historian tag OtOpcUa does not own); flipping the flag on removes the ref, flipping it off adds it.</summary>
[Fact]
public void Apply_never_feeds_a_read_only_binding_to_the_recorder()
{
var sink = new CapturingSubscriptionSink();
var applier = new AddressSpaceApplier(
NullOpcUaAddressSpaceSink.Instance, NullLogger<AddressSpaceApplier>.Instance,
historizedSubscriptions: sink);
var owned = HistorizedTag("Own", "Mine.Tag", "Float", fullName: "Plant/Own");
var readOnly = HistorizedTag("Ro", "SysTimeSec", "Int32", fullName: "Plant/Ro") with { HistorianReadOnly = true };
var goingRo = HistorizedTag("ToRo", "X.Tag", "Float", fullName: "Plant/ToRo");
var goingOwned = HistorizedTag("ToOwn", "Y.Tag", "Float", fullName: "Plant/ToOwn") with { HistorianReadOnly = true };
var removedRo = HistorizedTag("Gone", "Z.Tag", "Float", fullName: "Plant/Gone") with { HistorianReadOnly = true };
var plan = PlanWithAddedTags(owned, readOnly) with
{
RemovedRawTags = new[] { removedRo },
ChangedRawTags = new[]
{
new AddressSpacePlan.RawTagDelta(goingRo, goingRo with { HistorianReadOnly = true }),
new AddressSpacePlan.RawTagDelta(goingOwned, goingOwned with { HistorianReadOnly = false }),
},
};
applier.Apply(plan);
sink.Added.ShouldBe(new[]
{
new HistorizedTagRef("Plant/Own", "Mine.Tag"),
new HistorizedTagRef("Plant/ToOwn", "Y.Tag"),
}, ignoreOrder: true);
sink.Removed.ShouldBe(new[] { new HistorizedTagRef("Plant/ToRo", "X.Tag") });
}
/// <summary>A synchronously-throwing subscription sink must NOT block or break the publish — the /// <summary>A synchronously-throwing subscription sink must NOT block or break the publish — the
/// address-space work still completes and <see cref="AddressSpaceApplier.Apply"/> returns its outcome.</summary> /// address-space work still completes and <see cref="AddressSpaceApplier.Apply"/> returns its outcome.</summary>
[Fact] [Fact]
@@ -102,6 +102,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
[InlineData("writable")] [InlineData("writable")]
[InlineData("historized")] [InlineData("historized")]
[InlineData("historianTagname")] [InlineData("historianTagname")]
[InlineData("historianReadOnly")]
[InlineData("dataType")] [InlineData("dataType")]
[InlineData("array")] [InlineData("array")]
[InlineData("arrayLength")] [InlineData("arrayLength")]
@@ -118,6 +119,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
"writable" => prev with { Writable = true }, "writable" => prev with { Writable = true },
"historized" => prev with { IsHistorized = true }, "historized" => prev with { IsHistorized = true },
"historianTagname" => prev with { IsHistorized = true, HistorianTagname = "Plant.PLC_A.Speed" }, "historianTagname" => prev with { IsHistorized = true, HistorianTagname = "Plant.PLC_A.Speed" },
"historianReadOnly" => prev with { HistorianReadOnly = true }, // lmxopcua#899: node-inert
"dataType" => prev with { DataType = "Double" }, "dataType" => prev with { DataType = "Double" },
"array" => prev with { IsArray = true, ArrayLength = 8 }, "array" => prev with { IsArray = true, ArrayLength = 8 },
"arrayLength" => prev with { ArrayLength = 16 }, "arrayLength" => prev with { ArrayLength = 16 },
@@ -219,6 +219,42 @@ public sealed class AddressSpacePlannerDualNamespaceTests
_ = prev; _ = prev;
} }
/// <summary>lmxopcua#899: a redeploy whose ONLY change is <c>historianReadOnly</c> must reach the applier as a
/// ChangedRawTags delta — otherwise (the field missing from RawTagPlan's hand-written equality) the flip would diff
/// empty and a tag marked read-only would keep being provisioned and recorded until something else about it changed.</summary>
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
public void Historian_read_only_flip_alone_routes_to_ChangedRawTags(bool before, bool after)
{
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
var driver = new DriverInstance
{
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
Name = DriverName, DriverType = "Modbus", DriverConfig = "{}",
};
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" };
AddressSpaceComposition ComposeWith(bool readOnly) => AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
new[] { driver }, Array.Empty<ScriptedAlarm>(),
tags: new[]
{
new Tag
{
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Int32", AccessLevel = TagAccessLevel.Read,
TagConfig = $"{{\"isHistorized\":true,\"historianTagname\":\"SysTimeSec\",\"historianReadOnly\":{(readOnly ? "true" : "false")}}}",
},
},
rawFolders: new[] { folder }, devices: new[] { device });
var plan = AddressSpacePlanner.Compute(ComposeWith(before), ComposeWith(after));
var changed = plan.ChangedRawTags.ShouldHaveSingleItem();
changed.Previous.HistorianReadOnly.ShouldBe(before);
changed.Current.HistorianReadOnly.ShouldBe(after);
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); // in place, no rebuild
}
/// <summary> /// <summary>
/// <b>THE PIN (2026-08-15 surgical-deploys review):</b> a RAW-side edit to the backing tag's historian /// <b>THE PIN (2026-08-15 surgical-deploys review):</b> a RAW-side edit to the backing tag's historian
/// intent or array shape must produce a <c>ChangedRawTags</c> delta AND a <b>coincident</b> /// intent or array shape must produce a <c>ChangedRawTags</c> delta AND a <b>coincident</b>
@@ -36,6 +36,9 @@ public sealed class UnsReferenceBackingParityTests
// Node-inert for a value tag: it only drives native-alarm notifier wiring, and an alarm-bearing delta // Node-inert for a value tag: it only drives native-alarm notifier wiring, and an alarm-bearing delta
// never takes the surgical path. The mirror is the referencing side, so it has nothing to inherit. // never takes the surgical path. The mirror is the referencing side, so it has nothing to inherit.
[nameof(RawTagPlan.ReferencingEquipmentPaths)] = "notifier wiring only; the mirror is the referencing side", [nameof(RawTagPlan.ReferencingEquipmentPaths)] = "notifier wiring only; the mirror is the referencing side",
// lmxopcua#899: node-inert — it only steers the raw-tag provisioning hook and recorder feed; a UNS mirror
// serves the same HistoryRead either way and neither provisions nor records.
[nameof(RawTagPlan.HistorianReadOnly)] = "provisioning/recorder only; node-inert and never applied to a UNS mirror",
}; };
private static RawTagPlan Baseline() => new( private static RawTagPlan Baseline() => new(
@@ -86,7 +89,7 @@ public sealed class UnsReferenceBackingParityTests
{ {
nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths), nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths),
nameof(RawTagPlan.IsHistorized), nameof(RawTagPlan.HistorianTagname), nameof(RawTagPlan.IsArray), nameof(RawTagPlan.IsHistorized), nameof(RawTagPlan.HistorianTagname), nameof(RawTagPlan.IsArray),
nameof(RawTagPlan.ArrayLength), nameof(RawTagPlan.ArrayWritable), nameof(RawTagPlan.ArrayLength), nameof(RawTagPlan.ArrayWritable), nameof(RawTagPlan.HistorianReadOnly),
}, },
ignoreOrder: true); ignoreOrder: true);
@@ -20,6 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// </summary> /// </summary>
public sealed class DeploymentArtifactRawUnsParityTests public sealed class DeploymentArtifactRawUnsParityTests
{ {
private const string RunTagConfig = "{\"isHistorized\":true,\"historianTagname\":\"SysPulse\",\"historianReadOnly\":true}";
[Fact] [Fact]
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode() public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
{ {
@@ -44,7 +46,8 @@ public sealed class DeploymentArtifactRawUnsParityTests
var run = new Tag var run = new Tag
{ {
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean", TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}", // lmxopcua#899: a read-only historian binding — the flag must survive the artifact round-trip.
AccessLevel = TagAccessLevel.Read, TagConfig = RunTagConfig,
}; };
var profile = new Tag var profile = new Tag
{ {
@@ -81,7 +84,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
Tags = new object[] Tags = new object[]
{ {
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" }, new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = RunTagConfig },
new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"Float32\",\"isArray\":true,\"arrayLength\":16}" }, new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"Float32\",\"isArray\":true,\"arrayLength\":16}" },
new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" }, new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" },
}, },
@@ -111,6 +114,10 @@ public sealed class DeploymentArtifactRawUnsParityTests
composedAlarm.Alarm.ShouldNotBeNull(); composedAlarm.Alarm.ShouldNotBeNull();
composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm"); composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
composedAlarm.Writable.ShouldBeTrue(); composedAlarm.Writable.ShouldBeTrue();
var composedRun = composed.RawTags.Single(t => t.Name == "Run");
composedRun.IsHistorized.ShouldBeTrue();
composedRun.HistorianReadOnly.ShouldBeTrue();
composed.RawTags.Single(t => t.Name == "Speed").HistorianReadOnly.ShouldBeFalse();
composed.UnsReferenceVariables.Count.ShouldBe(2); composed.UnsReferenceVariables.Count.ShouldBe(2);
var composedSpeedRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-1"); var composedSpeedRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-1");
composedSpeedRef.NodeId.ShouldBe("filling/line1/station1/MotorSpeed"); composedSpeedRef.NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
@@ -81,5 +81,9 @@ public static class TagConfigGoldenCorpus
"{\"address\":5,\"region\":\"Coils\",\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}", "{\"address\":5,\"region\":\"Coils\",\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}",
// 29 all intents combined // 29 all intents combined
"{\"address\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}", "{\"address\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}",
// 30 lmxopcua#899 read-only historian binding
"{\"address\":\"A\",\"isHistorized\":true,\"historianTagname\":\"SysTimeSec\",\"historianReadOnly\":true}",
// 31 historianReadOnly non-bool → false (the deploy gate refuses it: HistorianReadOnlyInvalid)
"{\"address\":\"A\",\"isHistorized\":true,\"historianReadOnly\":\"true\"}",
}; };
} }
@@ -113,6 +113,38 @@ public sealed class AddHistorianProvisioningTests
resolved.ShouldNotBeSameAs(NullHistorianProvisioning.Instance); resolved.ShouldNotBeSameAs(NullHistorianProvisioning.Instance);
} }
/// <summary>lmxopcua#899: ServerHistorian:ProvisionTags=Off keeps the no-op even with the section enabled; the
/// other policies register the backend's provisioner (control).</summary>
[Theory]
[InlineData("Off", false)]
[InlineData("off", false)]
[InlineData("CreateOnly", true)]
[InlineData("CreateOrUpdate", true)]
public void ProvisionTags_Off_keeps_null_provisioning(string policy, bool registered)
{
var factoryInvoked = false;
var services = new ServiceCollection();
services.AddOtOpcUaRuntime();
var config = ConfigFrom(new Dictionary<string, string?>
{
["ServerHistorian:Enabled"] = "true",
["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
["ServerHistorian:ApiKey"] = "histgw_x_y",
["ServerHistorian:ProvisionTags"] = policy,
});
services.AddHistorianProvisioning(config, (_, _) =>
{
factoryInvoked = true;
return new CapturingProvisioner();
});
using var provider = services.BuildServiceProvider();
var resolved = provider.GetRequiredService<IHistorianProvisioning>();
(resolved is NullHistorianProvisioning).ShouldBe(!registered);
factoryInvoked.ShouldBe(registered);
}
[Fact] [Fact]
public void Section_enabled_passes_bound_options_to_factory() public void Section_enabled_passes_bound_options_to_factory()
{ {