fix(historian): read-only historian bindings + ProvisionTags policy; provision OPC UA type names (#899, #898)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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<DriverDataType></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,
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
|
|||||||
+16
-7
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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();
|
||||||
|
}
|
||||||
+17
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-8
@@ -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<DriverDataType></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]
|
||||||
|
|||||||
+2
@@ -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 },
|
||||||
|
|||||||
+36
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -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\"}",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user