diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index eee64688..7693ac45 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -579,7 +579,14 @@ public sealed class AddressSpaceApplier
}
///
- /// Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
+ /// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override falls
+ /// back to the RawPath (== NodeId).
+ ///
+ private static string ResolveHistorianName(RawTagPlan tag) =>
+ string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
+
+ ///
+ /// 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
/// list only and the gateway round-trip is dispatched fire-and-forget. The whole hook is wrapped
/// in try/catch — a synchronously-throwing provisioner (or any request-building fault) is
@@ -597,22 +604,39 @@ public sealed class AddressSpaceApplier
foreach (var tag in plan.AddedRawTags)
{
if (!tag.IsHistorized || tag.Alarm is not null) continue;
+ AddRequest(tag);
+ }
- // Parse the driver-agnostic data type from the tag's DataType string. An unparseable
- // type is skipped (logged at Debug) rather than faulting the hook.
+ // #600.1: a historization flip (false->true) or a historian-name / data-type change on an
+ // 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.
+ foreach (var d in plan.ChangedRawTags)
+ {
+ var cur = d.Current;
+ if (!cur.IsHistorized || cur.Alarm is not null) continue;
+ var prev = d.Previous;
+ var wasProvisioned = prev.IsHistorized && prev.Alarm is null;
+ if (wasProvisioned
+ && ResolveHistorianName(prev) == ResolveHistorianName(cur)
+ && string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase))
+ continue;
+ AddRequest(cur);
+ }
+
+ 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))
{
_logger.LogDebug(
- "AddressSpaceApplier: skipping historian provisioning for an added historized tag whose data type '{DataType}' is not a DriverDataType",
+ "AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType",
tag.DataType);
- continue;
+ return;
}
- // Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
- // falls back to the RawPath (== NodeId).
- var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
(requests ??= new List()).Add(
- new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
+ new HistorianTagProvisionRequest(ResolveHistorianName(tag), dataType, EngineeringUnit: null, Description: tag.Name));
}
if (requests is null) return;
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 9e6141a3..44aea3af 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs
@@ -146,6 +146,46 @@ public sealed class AddressSpaceApplierProvisioningTests
prov.Seen[0].TagName.ShouldBe("Pump1.Good");
}
+ /// #600.1: a CHANGED raw tag that flips to historized, or whose historian name / data type
+ /// changes, is provisioned; an unchanged-historization edit and an alarm-bearing tag are not. The
+ /// added "Control" tag is the positive control so the absences are not a vacuous no-dispatch.
+ [Fact]
+ public async Task Apply_provisions_changed_tags_whose_historization_flips_or_name_changes()
+ {
+ var prov = new CapturingProvisioner();
+ var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, prov);
+
+ var flipOff = NonHistorizedTag("Flip", "Float32") with { TagId = "t-flip", NodeId = "Plant/Flip" };
+ var flipOn = flipOff with { IsHistorized = true };
+ var renOld = HistorizedTag("Ren", "Old.Name", "Float32") with { TagId = "t-ren" };
+ var renNew = renOld with { HistorianTagname = "New.Name" };
+ var sameOld = HistorizedTag("Same", "Same.Name", "Float32") with { TagId = "t-same", Writable = false };
+ var sameNew = sameOld with { Writable = true }; // non-historian edit
+ var alarmOld = NonHistorizedTag("Alm", "Float32") with { TagId = "t-alm" };
+ var alarmNew = alarmOld with
+ {
+ IsHistorized = true,
+ Alarm = new EquipmentTagAlarmInfo("AlarmCondition", 500),
+ };
+
+ var plan = PlanWithAddedTags(HistorizedTag("Control", "Control.Name", "Float32")) with
+ {
+ ChangedRawTags = new[]
+ {
+ new AddressSpacePlan.RawTagDelta(flipOff, flipOn),
+ new AddressSpacePlan.RawTagDelta(renOld, renNew),
+ new AddressSpacePlan.RawTagDelta(sameOld, sameNew),
+ new AddressSpacePlan.RawTagDelta(alarmOld, alarmNew),
+ },
+ };
+
+ applier.Apply(plan);
+
+ await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ prov.Seen.Select(r => r.TagName).ShouldBe(
+ new[] { "Control.Name", "Plant/Flip", "New.Name" }, ignoreOrder: true);
+ }
+
/// Capturing that records every log line and signals a
/// the first time a "historian provisioning completed" tally is
/// emitted, so the fire-and-forget continuation's log can be awaited deterministically.