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.