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()
{