diff --git a/.claude/rules/historian.md b/.claude/rules/historian.md index 8f3956983..2de867160 100644 --- a/.claude/rules/historian.md +++ b/.claude/rules/historian.md @@ -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 | | `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 | +| `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) @@ -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 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`. +**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 diff --git a/docs/Historian.md b/docs/Historian.md index f994fb796..960e6659a 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -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. | | `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 @@ -84,6 +85,12 @@ Explicit historian tagname override: {"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: ```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`. | | `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). | +| `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). | **`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; 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`): 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 @@ -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). -Historization on the **Gateway** backend is **numeric-analog only**. `HistorianTypeMapper` classifies exactly nine types as -historizable (`Boolean`, `Int16/32/64`, `UInt16/32/64`, `Float32`, `Float64`): +Historization on the **Gateway** backend is **numeric-analog only**. `HistorianTypeMapper` classifies exactly seven types as +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. Provisioning skips them (counted in the `skipped=` tally, via `IsHistorizable`), the mapper throws diff --git a/docs/Raw.md b/docs/Raw.md index 098c1fc5c..3a5a8152d 100644 --- a/docs/Raw.md +++ b/docs/Raw.md @@ -63,7 +63,8 @@ removed later. - **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row verdicts → commit) over an RFC-4180 parser. Columns: the common set (`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` 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. @@ -90,7 +91,7 @@ removed later. 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 — -`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 [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 diff --git a/docs/Uns.md b/docs/Uns.md index f136bb1d0..16e36b428 100644 --- a/docs/Uns.md +++ b/docs/Uns.md @@ -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. | | **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 `TagHistorizeConfig` helper, which preserves all other keys byte-stable. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs index 97c5b5dc6..2288211e9 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs @@ -18,13 +18,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types; /// Non-whitespace historianTagname string override, else (not trimmed). /// isArray bool. /// arrayLength honoured ONLY when and a JSON number fitting ; else . +/// historianReadOnly — true only when the value is a bool true (lmxopcua#899): +/// the tag's history is READ from an existing historian tag that OtOpcUa does not own, so OtOpcUa never provisions +/// (EnsureTags) it and never records captured values into it. Absent / false ⇒ an owned tag (the +/// behaviour before #899). Any other JSON value is a deploy error (DraftValidator, see +/// ), never silently "owned". public sealed record TagConfigIntent( TagAlarmIntent? Alarm, bool IsHistorized, string? HistorianTagname, bool IsArray, - uint? ArrayLength) + uint? ArrayLength, + bool HistorianReadOnly = false) { + /// The TagConfig root key of the read-only historian binding (lmxopcua#899). + public const string HistorianReadOnlyKey = "historianReadOnly"; + /// /// Parse a Tag's schemaless TagConfig JSON into the cross-driver platform intents. /// 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 (isArray, arrayLength) = ParseArray(root); + var historianReadOnly = root.TryGetProperty(HistorianReadOnlyKey, out var roEl) + && roEl.ValueKind == JsonValueKind.True; return new TagConfigIntent( - ParseAlarm(root), isHistorized, historianTagname, isArray, arrayLength); + ParseAlarm(root), isHistorized, historianTagname, isArray, arrayLength, historianReadOnly); } catch (JsonException) { @@ -57,6 +68,30 @@ public sealed record TagConfigIntent( } } + /// + /// True when is a JSON object whose historianReadOnly key is present with a + /// value that is not a JSON boolean (e.g. the string "true"). reads such a value as + /// — an OWNED tag that gets provisioned — so the deploy gate refuses it instead + /// (lmxopcua#899). Never throws; malformed JSON ⇒ (other rules report that). + /// + /// The tag's raw TagConfig JSON. + /// when the key is present and not a boolean. + 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) { if (!root.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null; diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index c2880f739..a4e958e56 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -37,6 +37,7 @@ public static class DraftValidator // and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by: ValidateRawNameCharset(draft, errors); ValidateHistorizedTagnameLength(draft, errors); + ValidateHistorianReadOnly(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors); ValidateEquipReferenceResolution(draft, errors); ValidateCalculationTags(draft, errors); @@ -720,6 +721,21 @@ public static class DraftValidator } } + /// lmxopcua#899: historianReadOnly must be a JSON boolean. The runtime parse + /// () honours only a bool true, so a string "true" would deploy as an + /// OWNED tag — provisioned (EnsureTags, 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". + private static void ValidateHistorianReadOnly(DraftSnapshot draft, List 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)); + } + } + /// 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 /// (Name) — the three share the equipment's UNS NodeId space ({EquipmentId}/{EffectiveName}). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorianDataTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorianDataTypeNames.cs new file mode 100644 index 000000000..aba6b0b2c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistorianDataTypeNames.cs @@ -0,0 +1,58 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Resolves a raw tag's authored DataType string to the the historian +/// provisioning contract () carries (lmxopcua#898). +/// +/// +/// +/// Tag.DataType holds OPC UA built-in type names — what the AdminUI writes (RawTagModal, +/// manual entry, CSV import, RawBrowseCommitMapper) and what OtOpcUaNodeManager.ResolveBuiltInDataType +/// understands: Float, Double, Byte, SByte, … The provisioning hook used to +/// Enum.TryParse<DriverDataType> that string, which knows Float32/Float64 but not +/// Float/Double, and has no Byte/SByte member, so historized Float, Double, Byte and +/// SByte tags were silently never provisioned. +/// +/// +/// Mapping (case-insensitive, trimmed): +/// +/// Float ⇒ , Double ⇒ . +/// Byte (0..255) and SByte (−128..127) ⇒ : a lossless +/// widening (HistorianGateway's lossless-cast-or-reject rule). Int16 provisions as the historian's +/// Int2, the type Boolean already uses; the historian's one-byte Int1 creation is +/// server-degenerate (#441) and its UInt2 write path is the one OtOpcUa still widens away from. +/// The other OPC UA names that share a member name (Boolean, +/// Int16…UInt64, String, DateTime) map to that member. +/// A member name (Float32, Float64, Reference) is still +/// accepted, as before. +/// Anything else (Guid, ByteString, a number, blank) is unresolved: the caller skips the tag. +/// +/// 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). +/// +/// +public static class HistorianDataTypeNames +{ + /// Resolves an authored tag data type name to the provisioning . + /// The tag's DataType string (an OPC UA built-in name, or a name). + /// The resolved type when the method returns . + /// when the name resolves; for an unknown, numeric or blank name. + 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); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs index a767cb5d4..2aba3fdf0 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs @@ -47,8 +47,10 @@ public sealed record HistorianTagProvisionRequest( /// /// Total tags submitted. /// Tags the historian acknowledged as created or already present. -/// Tags whose data type is not historizable on the backend (never sent). -/// Tags that were sent but the backend did not acknowledge (incl. a swallowed transport error). +/// Tags never sent: a data type the backend cannot historize, or (Gateway +/// ServerHistorian:ProvisionTags=CreateOnly, lmxopcua#899) a tag that already exists and is left unchanged. +/// Tags that were sent but the backend did not acknowledge (incl. a swallowed transport error), or — +/// under CreateOnly — whose existence lookup failed, so they were not sent. public sealed record HistorianProvisionResult( int Requested, int Ensured, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs index cdd8b6371..d7093251a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs @@ -126,6 +126,7 @@ public static class GatewayHistorian return new GatewayTagProvisioner( HistorianGatewayClientAdapter.Create(options, loggerFactory), - logger); + logger, + createOnly: options.ProvisionPolicy == HistorianProvisionPolicy.CreateOnly); } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs index acb813ee7..f1ebd0e9e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs @@ -1,3 +1,4 @@ +using Grpc.Core; using Microsoft.Extensions.Logging; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; 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 /// ones are mapped via and batched into a single /// EnsureTags call. +/// +/// EnsureTags is an upsert: it re-types and re-describes an EXISTING historian tag. Under +/// ServerHistorian:ProvisionTags=CreateOnly (lmxopcua#899) each historizable request is looked up first +/// ( — a read) and only a tag the historian reports +/// ABSENT is sent; an existing tag is left as it is and counted in . +/// A lookup that fails is counted and the tag is NOT sent — the +/// policy fails closed, never into an upsert. +/// /// /// /// Non-blocking. A historian that is unreachable or errors must never fail an address-space @@ -37,18 +46,30 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos /// private const double DefaultIntegralDivisor = 1.0; + /// Concurrent existence lookups under CreateOnly (a deploy's first plan carries every tag). + private const int ExistenceLookupParallelism = 4; + private readonly IHistorianGatewayClient _client; private readonly ILogger _logger; + private readonly bool _createOnly; /// Creates the provisioner over a gateway client seam. /// The gateway client used for the EnsureTags path. /// Logger for skip/failure diagnostics (never logs tag values). - public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger logger) + /// + /// ServerHistorian:ProvisionTags=CreateOnly: send only tags the historian reports absent, so an existing + /// tag is never modified. (default) = the create-or-update upsert. + /// + public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger logger, bool createOnly = false) { _client = client ?? throw new ArgumentNullException(nameof(client)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _createOnly = createOnly; } + /// Whether this provisioner runs under ServerHistorian:ProvisionTags=CreateOnly — test/diagnostic. + internal bool CreateOnly => _createOnly; + /// public async Task EnsureTagsAsync( IReadOnlyList requests, CancellationToken ct) @@ -57,7 +78,9 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos var definitions = new List(requests.Count); var skipped = 0; + var lookupFailed = 0; + var candidates = new List(requests.Count); foreach (var request in requests) { if (!HistorianTypeMapper.IsHistorizable(request.DataType)) @@ -69,6 +92,25 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos 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 { 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) { - return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: 0); + return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: lookupFailed); } try @@ -94,7 +137,7 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos var results = await _client.EnsureTagsAsync(definitions, ct).ConfigureAwait(false); var ensured = results.Results.Count(r => r.Success); 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) { @@ -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 non-blocking contract. _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) { @@ -111,10 +154,65 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos _logger.LogWarning( "EnsureTags failed for {Count} historian tag(s) ({Exception}); provisioning deferred.", 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); } } + /// + /// CreateOnly: keeps only the requests whose tag the historian reports absent. An existing tag, or one whose + /// lookup failed (anything but a clean "absent" / gRPC NotFound), is dropped — fail closed. + /// + private async Task<(List Absent, int Existing, int Failed)> FilterToAbsentAsync( + List 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(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; + } + /// /// Disposes the underlying gateway client (and its gRPC channel). The DI container owns this /// provisioner as a singleton (AddHistorianProvisioning), so this fires once at host diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/HistorianTypeMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/HistorianTypeMapper.cs index 174b9985d..c95a12f75 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/HistorianTypeMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/HistorianTypeMapper.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; /// for tag provisioning + historical writes. /// /// -/// 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 /// upstream: maps to (the /// historian's UInt2 write path is not proven), and 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 /// historized in v1 and throw ; callers that want to skip them /// without catching an exception should consult first. +/// +/// / are not historizable either +/// (lmxopcua#898): HistorianGateway's type policy is lossless-cast-or-reject, its client cannot create +/// Int8/UInt8 tags, and no creatable type holds a 64-bit integer losslessly (Double loses +/// precision above 2^53), so EnsureTags(Int8/Uint8) fails with FailedPrecondition. They used to be +/// sent and counted failed; they are now skipped up front and counted skipped. The OPC UA +/// Byte/SByte names reach this mapper already widened to +/// (), so they provision as Int2. +/// /// internal static class HistorianTypeMapper { @@ -32,12 +41,14 @@ internal static class HistorianTypeMapper DriverDataType.Int16 => HistorianDataType.Int2, DriverDataType.Int32 => HistorianDataType.Int4, - DriverDataType.Int64 => HistorianDataType.Int8, DriverDataType.UInt16 => HistorianDataType.Uint4, // UInt2 write path deferred upstream → widen DriverDataType.UInt32 => HistorianDataType.Uint4, - DriverDataType.UInt64 => HistorianDataType.Uint8, DriverDataType.Float32 => HistorianDataType.Float, 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 => throw new NotSupportedException( $"DriverDataType.{dataType} is not historized in v1 " + @@ -47,20 +58,18 @@ internal static class HistorianTypeMapper }; /// - /// True when is one of the nine historizable numeric types — lets the + /// True when is one of the seven historizable numeric types — lets the /// provisioning hook skip deferred types without catching . /// /// The driver-agnostic data type. - /// True if the type is one of the nine historizable numeric types; otherwise false. + /// True if the type is one of the seven historizable numeric types; otherwise false. public static bool IsHistorizable(DriverDataType dataType) => dataType switch { DriverDataType.Boolean or DriverDataType.Int16 or DriverDataType.Int32 - or DriverDataType.Int64 or DriverDataType.UInt16 or DriverDataType.UInt32 - or DriverDataType.UInt64 or DriverDataType.Float32 or DriverDataType.Float64 => true, _ => false, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index a4dc9acf9..1f1427c01 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -42,7 +42,7 @@ public static class RawTagCsvMapper // 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. private static readonly string[] FlagKeys = - ["isHistorized", "historianTagname", "isArray", "arrayLength", "alarm"]; + ["isHistorized", "historianTagname", "historianReadOnly", "isArray", "arrayLength", "alarm"]; // ---------------------------------------------------------------------------------------- Import @@ -213,7 +213,8 @@ public static class RawTagCsvMapper // Historize flags. 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. var isArray = ParseFlag("IsArray", Cell("IsArray")); @@ -406,6 +407,7 @@ public static class RawTagCsvMapper tag.PollGroupId ?? "", hist.IsHistorized ? "true" : "", hist.HistorianTagname, + hist.HistorianReadOnly ? "true" : "", array.IsArray ? "true" : "", array.ArrayLength?.ToString(CultureInfo.InvariantCulture) ?? "", alarm.IsAlarm ? alarm.AlarmType : "", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs index 6d7c8bd3d..703dc93b6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -433,6 +433,7 @@ public static class CsvColumnMap "PollGroup", "IsHistorized", "HistorianTagname", + "HistorianReadOnly", "IsArray", "ArrayLength", "Alarm.AlarmType", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs index fa0483b5a..3924a5656 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagHistorizeConfig.cs @@ -1,10 +1,15 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; /// -/// Pure, driver-agnostic merge helper for the two server-side HistoryRead intent keys at the ROOT of a -/// tag's TagConfig JSON: isHistorized (camelCase bool — omit/false default) and -/// historianTagname (camelCase string override — omit when blank). These map to what the server's -/// AddressSpaceComposer.ExtractTagHistorize reads (see docs/Historian.md). +/// Pure, driver-agnostic merge helper for the server-side HistoryRead intent keys at the ROOT of a +/// tag's TagConfig JSON: isHistorized (camelCase bool — omit/false default), +/// historianTagname (camelCase string override — omit when blank) and historianReadOnly +/// (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 +/// reads (see docs/Historian.md); uses that same parse, so what the AdminUI shows is +/// what the runtime does. /// /// /// 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; /// public static class TagHistorizeConfig { - /// The two history-intent values parsed from a TagConfig JSON's root. + /// The history-intent values parsed from a TagConfig JSON's root. /// Whether the server exposes OPC UA HistoryRead over this tag's node. /// Optional historian tagname override; "" means default to the tag's FullName. - public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname); + /// Whether the historian tag is a read-only binding OtOpcUa never provisions or records into. + public readonly record struct HistorizeState(bool IsHistorized, string HistorianTagname, bool HistorianReadOnly = false); /// Reads the history-intent keys from a TagConfig JSON, defaulting any absent key /// (null/blank/malformed input ⇒ (false, "")). @@ -31,7 +37,9 @@ public static class TagHistorizeConfig var o = TagConfigJson.ParseOrNew(json); return new HistorizeState( 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); } /// Merges the two history-intent keys into , preserving every other @@ -41,13 +49,17 @@ public static class TagHistorizeConfig /// The tag's existing TagConfig JSON, or null/empty to start from a new object. /// Whether the server should expose OPC UA HistoryRead over this tag's node. /// Optional historian tagname override; null/blank omits the key. - /// The merged TagConfig JSON with the two history-intent keys applied. - public static string Set(string? json, bool isHistorized, string? historianTagname) + /// writes historianReadOnly: true, + /// drops the key (absent ⇒ owned), leaves whatever the JSON already carries untouched. + /// The merged TagConfig JSON with the history-intent keys applied. + public static string Set(string? json, bool isHistorized, string? historianTagname, bool? historianReadOnly = null) { var o = TagConfigJson.ParseOrNew(json); TagConfigJson.Set(o, "isHistorized", isHistorized ? true : null); TagConfigJson.Set(o, "historianTagname", string.IsNullOrWhiteSpace(historianTagname) ? null : historianTagname.Trim()); + if (historianReadOnly is { } readOnly) + TagConfigJson.Set(o, TagConfigIntent.HistorianReadOnlyKey, readOnly ? true : null); return TagConfigJson.Serialize(o); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs index ac51be902..822a71170 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs @@ -63,6 +63,18 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase())}."); + + // 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("AlarmHistorian:Enabled"); var consumed = options.Enabled || alarmHistorianEnabled; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 518a54548..cc87923f8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -199,7 +199,13 @@ public sealed class AddressSpaceApplier // 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 // 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; } @@ -621,6 +627,15 @@ public sealed class AddressSpaceApplier private static string ResolveHistorianName(RawTagPlan tag) => string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname; + /// + /// A historized value tag (not a native-alarm condition) whose historian tag OtOpcUa OWNS — i.e. not a + /// read-only binding (, lmxopcua#899). Only these are provisioned + /// (EnsureTags) 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. + /// + private static bool IsOwnedHistorizedValue(RawTagPlan tag) => + tag.IsHistorized && tag.Alarm is null && !tag.HistorianReadOnly; + /// /// 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 @@ -639,7 +654,7 @@ public sealed class AddressSpaceApplier // and skip them. foreach (var tag in plan.AddedRawTags) { - if (!tag.IsHistorized || tag.Alarm is not null) continue; + if (!IsOwnedHistorizedValue(tag)) continue; 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 // 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. + // 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) { var cur = d.Current; - if (!cur.IsHistorized || cur.Alarm is not null) continue; + if (!IsOwnedHistorizedValue(cur)) continue; var prev = d.Previous; - var wasProvisioned = prev.IsHistorized && prev.Alarm is null; + var wasProvisioned = IsOwnedHistorizedValue(prev); if (wasProvisioned && ResolveHistorianName(prev) == ResolveHistorianName(cur) && string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase)) @@ -662,12 +679,15 @@ public sealed class AddressSpaceApplier void AddRequest(RawTagPlan tag) { - // An unparseable data type is skipped (logged at Debug) rather than faulting the hook. - if (!Enum.TryParse(tag.DataType, ignoreCase: true, out var dataType)) + // Tag.DataType is an OPC UA built-in name (Float, Double, Byte, …); resolve it with the shared + // map (lmxopcua#898 — a bare Enum.TryParse 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( - "AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType", - tag.DataType); + _logger.LogInformation( + "AddressSpaceApplier: not provisioning historized tag {Node}: data type '{DataType}' has no historian mapping", + tag.NodeId, tag.DataType); return; } @@ -786,12 +806,15 @@ public sealed class AddressSpaceApplier /// HistorianName = the value the EnsureTags hook / materialiser write under (a non-alarm /// historized value variable's HistorianTagname override, else its FullName). The /// two diverge ONLY when an override is set. Returns null 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). /// /// The raw tag to resolve a historized ref for. /// The resolved historized ref pair, or null when the tag is not a historized value variable. 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 // default historian tagname; an override diverges only the historian name. ? new HistorizedTagRef( diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index e178aacb3..2b9e4612c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -143,7 +143,7 @@ public static class AddressSpaceChangeClassifier }).Equals(d.Current); // 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 // 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 @@ -160,6 +160,9 @@ public static class AddressSpaceChangeClassifier Writable = d.Current.Writable, IsHistorized = d.Current.IsHistorized, 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, IsArray = d.Current.IsArray, ArrayLength = d.Current.ArrayLength, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 7a176ce4e..e890af330 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -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 /// 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. +/// (lmxopcua#899, TagConfig.historianReadOnly) 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 (EnsureTags) 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). /// ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close /// over new members. Because forces a custom /// Equals/GetHashCode, the compiler's positional equality is suppressed, so a field added @@ -184,7 +189,8 @@ public sealed record RawTagPlan( AddressSpaceRealm Realm = AddressSpaceRealm.Raw, bool ArrayWritable = false, bool ScalarRefusesArrayValue = false, - string? NativeAlarmReference = null) + string? NativeAlarmReference = null, + bool HistorianReadOnly = false) { /// // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an @@ -209,6 +215,7 @@ public sealed record RawTagPlan( ArrayWritable == other.ArrayWritable && ScalarRefusesArrayValue == other.ScalarRefusesArrayValue && NativeAlarmReference == other.NativeAlarmReference && + HistorianReadOnly == other.HistorianReadOnly && ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); /// @@ -219,6 +226,7 @@ public sealed record RawTagPlan( 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(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue); hash.Add(NativeAlarmReference); + hash.Add(HistorianReadOnly); foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal); return hash.ToHashCode(); } @@ -847,7 +855,8 @@ public static class AddressSpaceComposer ArrayLength: intent.ArrayLength, ArrayWritable: arrayWritable, ScalarRefusesArrayValue: scalarRefusesArray, - NativeAlarmReference: nativeAlarmReference)); + NativeAlarmReference: nativeAlarmReference, + HistorianReadOnly: intent.HistorianReadOnly)); } containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index ea2347a60..1e474916f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -106,6 +106,41 @@ public sealed class ServerHistorianOptions /// public bool GatewayAtTimeFromRaw { get; init; } = true; + /// + /// What the tag provisioning hook may do to the historian's tags (lmxopcua#899): CreateOrUpdate (default — + /// the behaviour before #899: EnsureTags is an upsert that creates a missing tag and re-types / + /// re-describes an existing one), CreateOnly (Gateway backend only: each tag is looked up first and + /// an EXISTING one is left exactly as it is; only missing tags are created) or 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 of provisioning (and recording) under every policy. + /// + public string ProvisionTags { get; init; } = nameof(HistorianProvisionPolicy.CreateOrUpdate); + + /// + /// parsed; an unknown value reads as + /// here (the options validator fails host start on it first). + /// + public HistorianProvisionPolicy ProvisionPolicy => + TryParseProvisionPolicy(ProvisionTags, out var policy) ? policy : HistorianProvisionPolicy.CreateOrUpdate; + + /// Parses a value (case-insensitive, trimmed; blank ⇒ the default). + /// The configured value. + /// The parsed policy. + /// when the value names a policy (or is blank). + 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()) + { + if (!string.Equals(v, candidate.ToString(), StringComparison.OrdinalIgnoreCase)) continue; + policy = candidate; + return true; + } + return false; + } + /// /// Gateway backend only: the ServerHistorian:Gateway sub-section — knobs that exist because of the /// HistorianGateway's wire contract (see ). Ignored by the other backends. @@ -303,3 +338,16 @@ public sealed class GatewayHistorianSettings /// public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(30); } + +/// What the historian tag provisioning hook may do (, lmxopcua#899). +public enum HistorianProvisionPolicy +{ + /// Create a missing tag and update (re-type, re-describe) an existing one — the upsert, and the default. + CreateOrUpdate, + + /// Create a missing tag; never touch an existing one (Gateway backend only). + CreateOnly, + + /// No provisioning. + Off, +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 5d287f2d8..8acc830be 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -283,6 +283,8 @@ public static class ServiceCollectionExtensions { var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get(); 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. services.AddSingleton(sp => provisioningFactory(opts, sp)); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs index 39d1fd181..47021b414 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs @@ -124,4 +124,28 @@ public sealed class TagConfigIntentTests { 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); } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHistorianReadOnlyTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHistorianReadOnlyTests.cs new file mode 100644 index 000000000..de8376aa0 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHistorianReadOnlyTests.cs @@ -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; + +/// +/// lmxopcua#899: historianReadOnly must be a JSON boolean. The runtime honours only a bool true, so a +/// string "true" would deploy as an OWNED tag whose foreign historian tag then gets provisioned (upserted) +/// and recorded into — the deploy gate refuses it instead. +/// +[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"); + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataTypeNamesTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataTypeNamesTests.cs new file mode 100644 index 000000000..aa478d423 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataTypeNamesTests.cs @@ -0,0 +1,48 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian; + +/// +/// lmxopcua#898: a raw tag's DataType is an OPC UA built-in name. Every name the AdminUI offers +/// (RawTagModal.DataTypes) must resolve the way the historian provisioning hook expects, or be explicitly +/// unresolved — Float/Double/Byte/SByte used to be silently dropped. +/// +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(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianProviderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianProviderTests.cs index cfe199b5d..85ff66365 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianProviderTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianProviderTests.cs @@ -35,4 +35,21 @@ public sealed class GatewayHistorianProviderTests Assert.IsType(p.CreateProvisioning(sp)); await writer.DisposeAsync(); } + + /// lmxopcua#899: ServerHistorian:ProvisionTags reaches the provisioner (CreateOnly on, the default off). + [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(p.CreateProvisioning(Sp())); + Assert.Equal(createOnly, provisioner.CreateOnly); + await provisioner.DisposeAsync(); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs index 51fba3d5a..965d99e6d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs @@ -1,3 +1,4 @@ +using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Xunit; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; @@ -220,4 +221,120 @@ public sealed class GatewayTagProvisionerTests Assert.Equal(0, result.Skipped); 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; + } + + /// The default (CreateOrUpdate) never looks a tag up — it is the upsert, unchanged. + [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); + } + + /// 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. + [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.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); + } + + /// CreateOnly when every tag exists: no EnsureTags call at all. + [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.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); + } + + /// 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. + [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(new RpcException(new Status(StatusCode.Unavailable, "down"))) + : Task.FromException(new RpcException(new Status(StatusCode.NotFound, "no such tag"))), + }; + var p = new GatewayTagProvisioner(fake, NullLogger.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); + } + + /// CreateOnly looks up only historizable requests — a deferred type is skipped before any lookup. + [Fact] + public async Task CreateOnly_does_not_look_up_non_historizable_types() + { + var fake = new FakeHistorianGatewayClient { TagExistsResult = false }; + var p = new GatewayTagProvisioner(fake, NullLogger.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); + } + + /// lmxopcua#898: a 64-bit integer is skipped up front (the gateway cannot create Int8/UInt8). + [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); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/HistorianTypeMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/HistorianTypeMapperTests.cs index a5c581d8e..919849fd6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/HistorianTypeMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/HistorianTypeMapperTests.cs @@ -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.Int16, HistorianDataType.Int2)] [InlineData(DriverDataType.Int32, HistorianDataType.Int4)] - [InlineData(DriverDataType.Int64, HistorianDataType.Int8)] [InlineData(DriverDataType.UInt16, HistorianDataType.Uint4)] // fallback: UInt2 write deferred upstream [InlineData(DriverDataType.UInt32, HistorianDataType.Uint4)] - [InlineData(DriverDataType.UInt64, HistorianDataType.Uint8)] [InlineData(DriverDataType.Float32, HistorianDataType.Float)] [InlineData(DriverDataType.Float64, HistorianDataType.Double)] 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 } + /// 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. + [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(() => HistorianTypeMapper.ToHistorianDataType(d)); + Assert.Contains("losslessly", ex.Message); + } + [Fact] // matrix guard: a new DriverDataType member must be classified (mapped or explicitly deferred) public void Every_DriverDataType_member_is_classified() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs index 634d07e08..f325288ea 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagCsvRoundTripTests.cs @@ -68,6 +68,43 @@ public sealed class RawTagCsvRoundTripTests AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy }); } + /// 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. + [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"); + } + + /// 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. + [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] public void Variable_length_array_round_trips_lmxopcua_770() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs index 159691d96..727e43d33 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs @@ -143,4 +143,30 @@ public sealed class TagHistorizeConfigTests h.HistorianTagname.ShouldBe("TN"); 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(); + } + + /// A string "true" is what the runtime treats as NOT read-only; the AdminUI must say the same. + [Fact] + public void Read_reports_a_string_true_as_not_read_only_like_the_runtime() + => TagHistorizeConfig.Read("""{"isHistorized":true,"historianReadOnly":"true"}""").HistorianReadOnly.ShouldBeFalse(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs index 9b378e4a9..4aaa59603 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs @@ -460,4 +460,41 @@ public sealed class ServerHistorianOptionsValidatorTests result.Failed.ShouldBeTrue(); result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:TablePrefix")); } + + // ---- lmxopcua#899: ServerHistorian:ProvisionTags ---- + + /// An unknown policy fails, even when nothing consumes the section (like an unknown backend). + [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")); + } + + /// CreateOnly needs the Gateway's existence lookup; on an enabled SQL backend it fails host start. + [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); + } + + /// The policies parse case-insensitively; blank is the default. + [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); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs index 44aea3aff..bec987095 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs @@ -126,24 +126,88 @@ public sealed class AddressSpaceApplierProvisioningTests outcome.AddedNodes.ShouldBeGreaterThan(0); } - /// An added historized tag whose DataType string is not a is - /// skipped (no request) — the hook never throws on an unparseable type. + /// lmxopcua#898: Tag.DataType holds OPC UA built-in names (what the AdminUI writes). Float, Double, Byte and + /// SByte used to fail Enum.TryParse<DriverDataType> 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. [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 applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, prov); - // "Float" is NOT a DriverDataType member (the members are Float32/Float64); it must be skipped. var plan = PlanWithAddedTags( - HistorizedTag(displayName: "Bad", historianName: "Pump1.Bad", dataType: "Float"), - HistorizedTag(displayName: "Good", historianName: "Pump1.Good", dataType: "Float32")); + HistorizedTag(displayName: "F", historianName: "P.F", dataType: "Float"), + 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); await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - prov.Seen.Count.ShouldBe(1); - prov.Seen[0].TagName.ShouldBe("Pump1.Good"); + prov.Seen.Select(r => (r.TagName, r.DataType)).ShouldBe(new[] + { + ("P.F", DriverDataType.Float32), + ("P.D", DriverDataType.Float64), + ("P.B", DriverDataType.Int16), + ("P.S", DriverDataType.Int16), + ("P.F32", DriverDataType.Float32), + }, ignoreOrder: true); + } + + /// lmxopcua#899: a read-only binding (historianReadOnly: true) 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). + [Fact] + public async Task Apply_never_provisions_a_read_only_binding() + { + var prov = new CapturingProvisioner(); + var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger.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); + } + + /// lmxopcua#899: turning read-only OFF makes the tag owned, so it provisions like a historization flip. + [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.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" }); } /// #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); } + /// 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. + [Fact] + public void Apply_never_feeds_a_read_only_binding_to_the_recorder() + { + var sink = new CapturingSubscriptionSink(); + var applier = new AddressSpaceApplier( + NullOpcUaAddressSpaceSink.Instance, NullLogger.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") }); + } + /// A synchronously-throwing subscription sink must NOT block or break the publish — the /// address-space work still completes and returns its outcome. [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs index e66e328a0..6ffd870ef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs @@ -102,6 +102,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests [InlineData("writable")] [InlineData("historized")] [InlineData("historianTagname")] + [InlineData("historianReadOnly")] [InlineData("dataType")] [InlineData("array")] [InlineData("arrayLength")] @@ -118,6 +119,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests "writable" => prev with { Writable = true }, "historized" => prev with { IsHistorized = true }, "historianTagname" => prev with { IsHistorized = true, HistorianTagname = "Plant.PLC_A.Speed" }, + "historianReadOnly" => prev with { HistorianReadOnly = true }, // lmxopcua#899: node-inert "dataType" => prev with { DataType = "Double" }, "array" => prev with { IsArray = true, ArrayLength = 8 }, "arrayLength" => prev with { ArrayLength = 16 }, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs index dc7156cc0..dd23eae05 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs @@ -219,6 +219,42 @@ public sealed class AddressSpacePlannerDualNamespaceTests _ = prev; } + /// lmxopcua#899: a redeploy whose ONLY change is historianReadOnly 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. + [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(), Array.Empty(), Array.Empty(), + new[] { driver }, Array.Empty(), + 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 + } + /// /// THE PIN (2026-08-15 surgical-deploys review): a RAW-side edit to the backing tag's historian /// intent or array shape must produce a ChangedRawTags delta AND a coincident diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/UnsReferenceBackingParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/UnsReferenceBackingParityTests.cs index cff79373e..d7a20d15f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/UnsReferenceBackingParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/UnsReferenceBackingParityTests.cs @@ -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 // 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", + // 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( @@ -86,7 +89,7 @@ public sealed class UnsReferenceBackingParityTests { nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths), 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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs index df65dcdf9..ba5336ecb 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs @@ -20,6 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// public sealed class DeploymentArtifactRawUnsParityTests { + private const string RunTagConfig = "{\"isHistorized\":true,\"historianTagname\":\"SysPulse\",\"historianReadOnly\":true}"; + [Fact] 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 { 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 { @@ -81,7 +84,7 @@ public sealed class DeploymentArtifactRawUnsParityTests 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-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-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!.AlarmType.ShouldBe("OffNormalAlarm"); 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); var composedSpeedRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-1"); composedSpeedRef.NodeId.ShouldBe("filling/line1/station1/MotorSpeed"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs index 32192ca2e..a5ceafa6c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs @@ -81,5 +81,9 @@ public static class TagConfigGoldenCorpus "{\"address\":5,\"region\":\"Coils\",\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}", // 29 all intents combined "{\"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\"}", }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs index 8be82b260..fbdf4ea8a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs @@ -113,6 +113,38 @@ public sealed class AddHistorianProvisioningTests resolved.ShouldNotBeSameAs(NullHistorianProvisioning.Instance); } + /// lmxopcua#899: ServerHistorian:ProvisionTags=Off keeps the no-op even with the section enabled; the + /// other policies register the backend's provisioner (control). + [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 + { + ["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(); + (resolved is NullHistorianProvisioning).ShouldBe(!registered); + factoryInvoked.ShouldBe(registered); + } + [Fact] public void Section_enabled_passes_bound_options_to_factory() {