From 440929c82a1b9dfa3f1338fcce1209161ab346a2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 14 Jun 2026 18:55:04 -0400 Subject: [PATCH] feat(historian): carry isHistorized + historianTagname through EquipmentTagPlan (byte-parity) --- .../Phase7Composer.cs | 42 ++++- .../Drivers/DeploymentArtifact.cs | 32 +++- .../ExtractTagHistorizeTests.cs | 33 ++++ .../DeploymentArtifactHistorizeParityTests.cs | 167 ++++++++++++++++++ 4 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index ddeae6b9..46e2ab54 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -78,6 +78,11 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI /// (DeploymentArtifact.BuildEquipmentTagPlans) for byte-parity. carries /// the optional native-alarm intent parsed from Tag.TagConfig's alarm object (null ⇒ /// a plain value variable); it too is parsed identically on the artifact-decode side for byte-parity. +/// / carry the optional server-side +/// HistoryRead intent parsed from Tag.TagConfig's isHistorized bool + +/// historianTagname string (Phase C); a null means the historian +/// tagname defaults to (resolved later, not here). Both are parsed identically +/// on the artifact-decode side for byte-parity. /// public sealed record EquipmentTagPlan( string TagId, @@ -88,7 +93,9 @@ public sealed record EquipmentTagPlan( string DataType, string FullName, bool Writable, - EquipmentTagAlarmInfo? Alarm); + EquipmentTagAlarmInfo? Alarm, + bool IsHistorized = false, + string? HistorianTagname = null); /// Native-alarm intent parsed from an equipment tag's TagConfig.alarm object. Null ⇒ /// the tag is a plain value variable. is an OPC UA Part 9 subtype string @@ -337,7 +344,9 @@ public static class Phase7Composer DataType: t.DataType, FullName: ExtractTagFullName(t.TagConfig), Writable: t.AccessLevel == TagAccessLevel.ReadWrite, - Alarm: ExtractTagAlarm(t.TagConfig))) + Alarm: ExtractTagAlarm(t.TagConfig), + IsHistorized: ExtractTagHistorize(t.TagConfig).IsHistorized, + HistorianTagname: ExtractTagHistorize(t.TagConfig).HistorianTagname)) .ToList(); // Per-equipment tag base = the shared substring-before-first-dot across each equipment's @@ -474,4 +483,33 @@ public static class Phase7Composer } catch (JsonException) { return null; } } + + /// Parses the optional server-side HistoryRead intent from a tag's TagConfig JSON: + /// the isHistorized bool (absent / not a bool / non-object root / blank / malformed ⇒ + /// false) and the optional historianTagname string override (absent / not a string / + /// whitespace-or-empty ⇒ null, meaning the historian tagname defaults to the tag's FullName, + /// resolved later). The raw string value is used — not trimmed — matching ExtractTagFullName / + /// ExtractTagAlarm. Never throws. The artifact-decode side + /// (DeploymentArtifact.ExtractTagHistorize) MUST parse identically (byte-parity). + internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); + try + { + using var doc = JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); + var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl) + && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) + && hEl.GetBoolean(); + string? tagname = null; + if (doc.RootElement.TryGetProperty("historianTagname", out var nEl) + && nEl.ValueKind == JsonValueKind.String) + { + var raw = nEl.GetString(); + if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; + } + return (isHistorized, tagname); + } + catch (JsonException) { return (false, null); } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 5f59fa21..c8b92df8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -446,7 +446,9 @@ public static class DeploymentArtifact DataType: dataType ?? "BaseDataType", FullName: ExtractTagFullName(tagConfig), Writable: writable, - Alarm: ExtractTagAlarm(tagConfig))); + Alarm: ExtractTagAlarm(tagConfig), + IsHistorized: ExtractTagHistorize(tagConfig).IsHistorized, + HistorianTagname: ExtractTagHistorize(tagConfig).HistorianTagname)); } result.Sort((a, b) => @@ -672,6 +674,34 @@ public static class DeploymentArtifact catch (JsonException) { return null; } } + /// Parses the optional server-side HistoryRead intent from a tag's TagConfig JSON: + /// the isHistorized bool (absent / not a bool / non-object root / blank / malformed ⇒ + /// false) and the optional historianTagname string override (absent / not a string / + /// whitespace-or-empty ⇒ null, meaning the historian tagname defaults to the tag's FullName, + /// resolved later). The raw string value is used — not trimmed. Never throws. The live-edit side + /// (Phase7Composer.ExtractTagHistorize) MUST parse identically (byte-parity). + private static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); + try + { + using var doc = JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); + var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl) + && (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False) + && hEl.GetBoolean(); + string? tagname = null; + if (doc.RootElement.TryGetProperty("historianTagname", out var nEl) + && nEl.ValueKind == JsonValueKind.String) + { + var raw = nEl.GetString(); + if (!string.IsNullOrWhiteSpace(raw)) tagname = raw; + } + return (isHistorized, tagname); + } + catch (JsonException) { return (false, null); } + } + private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader) where T : class { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs new file mode 100644 index 00000000..3fe835e1 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs @@ -0,0 +1,33 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public class ExtractTagHistorizeTests +{ + [Theory] + // isHistorized true, no explicit tagname ⇒ tagname null (defaults to FullName later). + [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true}", true, null)] + // isHistorized true with an explicit historian-tagname override. + [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\"WW.Tag\"}", true, "WW.Tag")] + // Absent isHistorized ⇒ false. + [InlineData("{\"FullName\":\"T.A\"}", false, null)] + // historianTagname parses independently of the flag. + [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":false,\"historianTagname\":\"WW.Tag\"}", false, "WW.Tag")] + // Blank/whitespace tagname ⇒ null. + [InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] + // null / empty / malformed-JSON / array-root ⇒ (false, null), never throws. + [InlineData(null, false, null)] + [InlineData("", false, null)] + [InlineData("not json {", false, null)] + [InlineData("[1,2]", false, null)] + // Wrong type for isHistorized (string, not bool) ⇒ false. + [InlineData("{\"isHistorized\":\"yes\"}", false, null)] + public void ExtractTagHistorize_parses_or_returns_defaults(string? cfg, bool expectedHistorized, string? expectedTagname) + { + var (isHistorized, historianTagname) = Phase7Composer.ExtractTagHistorize(cfg); + isHistorized.ShouldBe(expectedHistorized); + historianTagname.ShouldBe(expectedTagname); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs new file mode 100644 index 00000000..fc1493b8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs @@ -0,0 +1,167 @@ +using System.Linq; +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Proves the Phase C HistoryRead intent (isHistorized + optional historianTagname), +/// which rides inside the raw TagConfig JSON blob, round-trips with byte-parity through both +/// equipment-tag producers: the live-edit composer () and the +/// artifact decoder (). +/// The artifact serializer re-parses the SAME TagConfig string both sides emit, so no +/// ConfigComposer change is needed — the flags are already carried in the blob. +/// +public sealed class DeploymentArtifactHistorizeParityTests +{ + /// + /// One draft consumed by both producers: a historized equipment tag with no explicit historian + /// tagname (defaults to FullName later, so HistorianTagname is null on both sides), plus a + /// historized equipment tag WITH an explicit historianTagname override, plus a plain + /// non-historized tag. The decoded EquipmentTags must equal the composer's element-wise + /// (positional-record value equality) and in the same order, proving IsHistorized / + /// HistorianTagname are derived byte-identically on both seams. + /// + [Fact] + public void Composer_and_artifact_agree_on_historized_equipment_tags() + { + var ns = new Namespace + { + NamespaceId = "ns-eq", + ClusterId = "c1", + Kind = NamespaceKind.Equipment, + NamespaceUri = "urn:eq", + }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", + ClusterId = "c1", + NamespaceId = "ns-eq", + Name = "Modbus1", + DriverType = "Modbus", + DriverConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment + { + EquipmentId = "eq-1", + DriverInstanceId = "drv-modbus", + UnsLineId = "line-1", + Name = "FillingPump", + MachineCode = "FILLINGPUMP", + }; + + // Historized, no explicit tagname → HistorianTagname null (defaults to FullName later). + var histDefaultTag = new Tag + { + TagId = "tag-hist-default", + DriverInstanceId = "drv-modbus", + EquipmentId = "eq-1", + FolderPath = null, + Name = "Flow", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"40001\",\"isHistorized\":true}", + }; + // Historized WITH an explicit historian-tagname override. + var histOverrideTag = new Tag + { + TagId = "tag-hist-override", + DriverInstanceId = "drv-modbus", + EquipmentId = "eq-1", + FolderPath = null, + Name = "Pressure", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"40002\",\"isHistorized\":true,\"historianTagname\":\"Plant.Line1.Pressure\"}", + }; + // Plain tag — not historized. + var plainTag = new Tag + { + TagId = "tag-plain", + DriverInstanceId = "drv-modbus", + EquipmentId = "eq-1", + FolderPath = null, + Name = "Speed", + DataType = "Float", + AccessLevel = TagAccessLevel.ReadWrite, + TagConfig = "{\"FullName\":\"40003\"}", + }; + + var areas = new[] { area }; + var lines = new[] { line }; + var equipment = new[] { equip }; + var drivers = new[] { driver }; + var tags = new[] { histDefaultTag, histOverrideTag, plainTag }; + var namespaces = new[] { ns }; + + // ---- Side 1: the live-edit composer ---- + var composed = Phase7Composer.Compose( + areas, lines, equipment, drivers, Array.Empty(), tags, namespaces); + + // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind }, + }, + DriverInstances = new[] + { + new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId }, + }, + Tags = new[] + { + ToSnapshot(histDefaultTag), + ToSnapshot(histOverrideTag), + ToSnapshot(plainTag), + }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + // ---- Full byte-parity: every field, same order (positional-record value equality) ---- + decoded.EquipmentTags.Count.ShouldBe(3); + decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); + + // Spell out the Phase C fields per-tag so a divergence names the offending tag. + var histDefault = decoded.EquipmentTags.Single(t => t.TagId == "tag-hist-default"); + histDefault.IsHistorized.ShouldBeTrue(); + histDefault.HistorianTagname.ShouldBeNull(); + composed.EquipmentTags.Single(t => t.TagId == "tag-hist-default").IsHistorized.ShouldBeTrue(); + composed.EquipmentTags.Single(t => t.TagId == "tag-hist-default").HistorianTagname.ShouldBeNull(); + + var histOverride = decoded.EquipmentTags.Single(t => t.TagId == "tag-hist-override"); + histOverride.IsHistorized.ShouldBeTrue(); + histOverride.HistorianTagname.ShouldBe("Plant.Line1.Pressure"); + composed.EquipmentTags.Single(t => t.TagId == "tag-hist-override").IsHistorized.ShouldBeTrue(); + composed.EquipmentTags.Single(t => t.TagId == "tag-hist-override").HistorianTagname.ShouldBe("Plant.Line1.Pressure"); + + var plain = decoded.EquipmentTags.Single(t => t.TagId == "tag-plain"); + plain.IsHistorized.ShouldBeFalse(); + plain.HistorianTagname.ShouldBeNull(); + composed.EquipmentTags.Single(t => t.TagId == "tag-plain").IsHistorized.ShouldBeFalse(); + composed.EquipmentTags.Single(t => t.TagId == "tag-plain").HistorianTagname.ShouldBeNull(); + } + + /// The Pascal-case snapshot a EF entity serialises to in the artifact + /// (matches ConfigComposer); the equipment-tag decoder re-parses these fields — including the raw + /// TagConfig blob the Phase C flags ride inside. + private static object ToSnapshot(Tag t) => new + { + t.TagId, + t.DriverInstanceId, + t.EquipmentId, + t.Name, + t.FolderPath, + t.DataType, + AccessLevel = (int)t.AccessLevel, + t.TagConfig, + }; +}