feat(opcua): carry Equipment-namespace tags through the deployment composition

Add EquipmentTagPlan + an init-only EquipmentTags member on Phase7CompositionResult
(mirror of GalaxyTags). Populate it compose-side (Tag.EquipmentId != null AND owning
namespace Kind == Equipment) and artifact-decode-side via BuildEquipmentTagPlans, with
FullName extracted from Tag.TagConfig. Init-only member (not a 7th positional param) so
existing convenience constructors + call sites are untouched.
This commit is contained in:
Joseph Doherty
2026-06-06 14:42:38 -04:00
parent c18943f6e1
commit febe462750
4 changed files with 262 additions and 8 deletions
@@ -1,12 +1,14 @@
{
"planPath": "docs/plans/2026-06-06-equipment-namespace-structure-milestone.md",
"scopeDoc": "docs/plans/2026-06-06-equipment-namespace-materialization-scope.md",
"branch": "feat/equipment-namespace-structure",
"tasks": [
{"id": 103, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "pending"},
{"id": 104, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "pending", "blockedBy": [103]},
{"id": 105, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "pending", "blockedBy": [104]},
{"id": 106, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [104]},
{"id": 107, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [105]},
{"id": 108, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [105, 106]}
{"id": 0, "nativeTaskId": 86, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "completed", "blockedBy": []},
{"id": 1, "nativeTaskId": 87, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "completed", "blockedBy": [86]},
{"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "pending", "blockedBy": [87]},
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [87]},
{"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [88]},
{"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [88, 89]}
],
"lastUpdated": "2026-06-06"
}
@@ -1,3 +1,4 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -45,6 +46,16 @@ public sealed record Phase7CompositionResult(
: this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
{
}
/// <summary>
/// Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
/// in an <c>Equipment</c>-kind namespace. Mirror of <see cref="GalaxyTags"/> for the UNS
/// equipment-signal path: <c>Phase7Applier.MaterialiseEquipmentTags</c> materialises each as
/// a Variable under its existing equipment folder. Declared as an init-only member defaulting
/// to empty (rather than a 7th positional parameter) so every existing convenience
/// constructor + call site keeps compiling unchanged; new producers set it via initializer.
/// </summary>
public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
}
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
@@ -68,6 +79,23 @@ public sealed record GalaxyTagPlan(
string DataType,
string MxAccessRef);
/// <summary>
/// One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/>
/// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the parent
/// <see cref="EquipmentId"/> folder (already materialised by <c>Phase7Applier.MaterialiseHierarchy</c>)
/// the variable hangs under, the optional <see cref="FolderPath"/> sub-folder, the leaf
/// <see cref="Name"/> display, the OPC UA <see cref="DataType"/>, and the driver-side
/// <see cref="FullName"/> reference (extracted from <c>Tag.TagConfig</c>) used as the variable
/// NodeId + read/write routing key. The equipment-signal analogue of <see cref="GalaxyTagPlan"/>.
/// </summary>
public sealed record EquipmentTagPlan(
string EquipmentId,
string DriverInstanceId,
string FolderPath,
string Name,
string DataType,
string FullName);
/// <summary>
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
@@ -178,6 +206,56 @@ public static class Phase7Composer
MxAccessRef: string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}"))
.ToList();
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags);
// Equipment tags = the inverse filter: a Tag bound to an Equipment (non-null EquipmentId)
// whose driver's namespace is Equipment-kind. FullName is the driver-side wire reference
// pulled from TagConfig — it becomes the variable's NodeId + read/write routing key.
var equipmentTags = tags
.Where(t => t.EquipmentId is not null)
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
&& ns.Kind == NamespaceKind.Equipment)
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
.ThenBy(t => t.FolderPath, StringComparer.Ordinal)
.ThenBy(t => t.Name, StringComparer.Ordinal)
.Select(t => new EquipmentTagPlan(
EquipmentId: t.EquipmentId!,
DriverInstanceId: t.DriverInstanceId,
FolderPath: t.FolderPath ?? string.Empty,
Name: t.Name,
DataType: t.DataType,
FullName: ExtractTagFullName(t.TagConfig)))
.ToList();
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags)
{
EquipmentTags = equipmentTags,
};
}
/// <summary>
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
/// driver stores the wire-level address in a top-level <c>FullName</c> field. Replicated from
/// <c>EquipmentNodeWalker.ExtractFullName</c> because OpcUaServer does not reference the Core
/// driver assembly (kept in sync with the artifact-decode copy in <c>DeploymentArtifact</c>).
/// Falls back to the raw blob when it is not a JSON object with a string <c>FullName</c>.
/// </summary>
/// <param name="tagConfig">The tag's wire-level address JSON.</param>
/// <returns>The extracted full reference, or the raw blob when no <c>FullName</c> is present.</returns>
private static string ExtractTagFullName(string tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
&& fullName.ValueKind == JsonValueKind.String)
{
return fullName.GetString() ?? tagConfig;
}
}
catch (JsonException) { /* fall through to raw blob */ }
return tagConfig;
}
}
@@ -107,8 +107,12 @@ public static class DeploymentArtifact
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
var equipmentTags = BuildEquipmentTagPlans(root);
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags);
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
{
EquipmentTags = equipmentTags,
};
}
catch (JsonException)
{
@@ -203,6 +207,116 @@ public static class DeploymentArtifact
return result;
}
/// <summary>
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
/// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then
/// emit one <see cref="EquipmentTagPlan"/> per qualifying tag. The artifact-decode mirror of
/// <c>Phase7Composer.Compose</c>'s equipment filter — the inverse of <see cref="BuildGalaxyTagPlans"/>
/// — so the compose-side + artifact-decode plans agree on the same set of tags. FullName is
/// read from each tag's TagConfig blob (top-level "FullName" field).
/// </summary>
private static IReadOnlyList<EquipmentTagPlan> BuildEquipmentTagPlans(JsonElement root)
{
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentTagPlan>();
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentTagPlan>();
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentTagPlan>();
// namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0);
// tolerate the string form too (matches BuildGalaxyTagPlans's number/string handling).
var equipmentNamespaces = new HashSet<string>(StringComparer.Ordinal);
foreach (var el in nsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
if (!el.TryGetProperty("Kind", out var kindEl)) continue;
var isEquipment = kindEl.ValueKind switch
{
JsonValueKind.Number => kindEl.GetInt32() == 0, // NamespaceKind.Equipment = 0
JsonValueKind.String => string.Equals(kindEl.GetString(), "Equipment", StringComparison.Ordinal),
_ => false,
};
if (isEquipment) equipmentNamespaces.Add(id!);
}
// driverInstanceId → namespaceId
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var el in diArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
driverToNamespace[id!] = ns!;
}
var result = new List<EquipmentTagPlan>(tagsArr.GetArrayLength());
foreach (var el in tagsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
// Equipment tags REQUIRE a non-null EquipmentId (the inverse of the Galaxy filter).
if (!el.TryGetProperty("EquipmentId", out var eqEl) || eqEl.ValueKind == JsonValueKind.Null) continue;
var equipmentId = eqEl.GetString();
if (string.IsNullOrWhiteSpace(equipmentId)) continue;
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
? fpEl.GetString() : null;
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString() : null;
if (string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
if (!equipmentNamespaces.Contains(nsId)) continue;
result.Add(new EquipmentTagPlan(
EquipmentId: equipmentId!,
DriverInstanceId: di!,
FolderPath: folder ?? string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
FullName: ExtractTagFullName(tagConfig)));
}
result.Sort((a, b) =>
{
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
if (byEquipment != 0) return byEquipment;
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
if (byFolder != 0) return byFolder;
return string.CompareOrdinal(a.Name, b.Name);
});
return result;
}
/// <summary>
/// Extract the driver-side full reference from a tag's TagConfig JSON (top-level "FullName"
/// field). The artifact-decode mirror of <c>Phase7Composer.ExtractTagFullName</c> /
/// <c>EquipmentNodeWalker.ExtractFullName</c> — replicated because Runtime does not reference
/// the Core driver assembly. Falls back to the raw blob when absent or non-JSON.
/// </summary>
private static string ExtractTagFullName(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig ?? string.Empty;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
&& fullName.ValueKind == JsonValueKind.String)
{
return fullName.GetString() ?? tagConfig;
}
}
catch (JsonException) { /* fall through to raw blob */ }
return tagConfig;
}
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
where T : class
{
@@ -117,6 +117,66 @@ public sealed class DeploymentArtifactTests
c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1");
}
/// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an
/// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted
/// from the tag's TagConfig blob — the equipment-signal mirror of the Galaxy-tag path. A
/// SystemPlatform (Galaxy) tag in the same blob must NOT leak into EquipmentTags and must
/// still route to GalaxyTags.
/// </summary>
[Fact]
public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
new { DriverInstanceId = "drv-galaxy", DriverType = "Galaxy", DriverConfig = "{}", NamespaceId = "ns-sp" },
},
Tags = new object[]
{
new
{
TagId = "tag-eq",
DriverInstanceId = "drv-modbus",
EquipmentId = "eq-1",
Name = "Speed",
FolderPath = (string?)null,
DataType = "Float",
TagConfig = "{\"FullName\":\"40001\"}",
},
new
{
TagId = "tag-gx",
DriverInstanceId = "drv-galaxy",
EquipmentId = (string?)null,
Name = "Temp",
FolderPath = "area",
DataType = "Float",
TagConfig = "{\"FullName\":\"area.Temp\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var tag = c.EquipmentTags.ShouldHaveSingleItem();
tag.EquipmentId.ShouldBe("eq-1");
tag.DriverInstanceId.ShouldBe("drv-modbus");
tag.Name.ShouldBe("Speed");
tag.DataType.ShouldBe("Float");
tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
// The Galaxy tag still routes to GalaxyTags and does NOT leak into EquipmentTags.
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
}
/// <summary>Verifies that specs missing required fields are dropped.</summary>
[Fact]
public void Spec_missing_required_fields_is_dropped()