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,
+ };
+}