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