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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user