5 Commits

Author SHA1 Message Date
Joseph Doherty a79ed5fff1 test(opcua): full equipment-namespace structure pipeline against a real SDK node manager
v2-ci / build (push) Failing after 35s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Entities -> Phase7Composer.Compose -> MaterialiseHierarchy + MaterialiseEquipmentTags ->
real OtOpcUaNodeManager, asserting the Area/Line/Equipment folders + the equipment-signal
Variable land in a live OPC UA address space (structure-only). Also covers compose-side
EquipmentTags extraction. The cluster-level deploy + network-browse E2E + scadaproj loader
need the docker-dev fixture (not runnable on this dev box) and are tracked as a follow-up.
2026-06-06 15:07:45 -04:00
Joseph Doherty aaf869145a fix(opcua): equipment-tag planner diff + folder-scoped NodeIds (review findings)
Two bundle-review fixes + idempotency coverage:
- CRITICAL: the planner ignored EquipmentTags, so an incremental deploy changing only
  equipment tags produced an empty plan and HandleRebuild short-circuited before
  materialising them. Add TagId to EquipmentTagPlan + Added/Removed/ChangedEquipmentTags
  to Phase7Plan (diffed by TagId, in IsEmpty, driving Apply's needsRebuild) — mirroring
  the GalaxyTags treatment.
- IMPORTANT: equipment variable NodeId was the raw driver FullName, which collides across
  identical machines (e.g. two PLCs both exposing register 40001) — the second variable
  was silently dropped. NodeId is now folder-scoped (parent/Name); FullName stays on
  EquipmentTagPlan for the later values-routing milestone.
- Task 4: SDK-backed idempotency test (double-apply -> single variable); restart-safety
  confirmed (RestoreApplied reuses the same RebuildAddressSpace -> HandleRebuild path).
- Minor: align composer equipment-tag sort with the artifact decoder (coalesce FolderPath).
2026-06-06 15:02:50 -04:00
Joseph Doherty 08cddfe128 fix(opcua): UNS equipment folders browse by friendly Name, NodeId stays the logical Id
Equipment folder DisplayName was the colloquial MachineCode; the live rebuild (artifact
ReadEquipmentNode) + composer now use the UNS level-5 Name segment, matching Area/Line
folders + EquipmentNodeWalker. NodeId stays the logical EquipmentId so browse-path
resolution + ACLs are unaffected.
2026-06-06 14:51:12 -04:00
Joseph Doherty df0dc516c3 feat(opcua): materialise Equipment-namespace tags in the live rebuild
Add Phase7Applier.MaterialiseEquipmentTags — a sink-based pass (Task-0 decision A) that
ensures each EquipmentTagPlan's Variable (NodeId = FullName) under its existing equipment
folder, nesting any FolderPath as a sub-folder. Wire it into OpcUaPublishActor.HandleRebuild
after the Galaxy pass. Variables start BadWaitingForInitialData; never re-creates equipment
folders (decision #4).
2026-06-06 14:46:38 -04:00
Joseph Doherty febe462750 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.
2026-06-06 14:42:38 -04:00
11 changed files with 655 additions and 18 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": "completed", "blockedBy": [87]},
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "completed", "blockedBy": [87]},
{"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "completed", "blockedBy": [88]},
{"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "completed", "blockedBy": [88, 89]}
],
"lastUpdated": "2026-06-06"
}
@@ -70,18 +70,19 @@ public sealed class Phase7Applier
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedGalaxyTags.Count;
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedGalaxyTags.Count;
plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count;
// Any add/remove of Equipment, ScriptedAlarm, or Galaxy tag topology requires a real
// address-space rebuild. Driver-instance changes don't touch the address-space topology
// directly — they go through DriverHostActor's spawn-plan in Runtime.
// Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, or Equipment tag topology requires
// a real address-space rebuild. Driver-instance changes don't touch the address-space
// topology directly — they go through DriverHostActor's spawn-plan in Runtime.
var needsRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0;
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0;
if (needsRebuild)
{
@@ -172,6 +173,71 @@ public sealed class Phase7Applier
composition.GalaxyTags.Count, foldersCreated.Count);
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal
/// analogue of <see cref="MaterialiseGalaxyTags"/>. For each <see cref="EquipmentTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent.
/// <para>
/// <b>Task 0 architecture decisions (recorded per the equipment-namespace-structure
/// plan).</b> Decision #1 = <b>A</b> — a sink-based pass, NOT a reuse of
/// <c>EquipmentNodeWalker</c>: no sink-backed <c>IAddressSpaceBuilder</c> adapter exists
/// (<c>GenericDriverNodeManager.CapturingBuilder</c> decorates another builder, not the
/// sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path
/// NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the
/// already-materialised equipment folders (decision #4). Decision #4 = this pass adds
/// ONLY variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns
/// the equipment folders and this pass never re-creates them. The sink's
/// <c>EnsureVariable</c> takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// </para>
/// </summary>
/// <param name="composition">The composition result containing the equipment tags to materialise.</param>
public void MaterialiseEquipmentTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.EquipmentTags.Count == 0) return;
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create
// the equipment folder here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in composition.EquipmentTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
var folderNodeId = EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
// ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
// Like the Galaxy pass, per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in composition.EquipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
var nodeId = $"{parent}/{tag.Name}";
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType);
}
_logger.LogInformation(
"Phase7Applier: equipment tags materialised (tags={Tags}, equipment={Equipment})",
composition.EquipmentTags.Count,
composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
/// folder so two equipments' identically-named sub-folders never collide.</summary>
private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) =>
$"{equipmentId}/{folderPath}";
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
{
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
@@ -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,27 @@ 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 stable
/// <see cref="TagId"/> (diff identity), 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>) the later values milestone routes reads/writes by. The variable's NodeId
/// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
/// (e.g. a Modbus register) is not unique across identical machines. The equipment-signal
/// analogue of <see cref="GalaxyTagPlan"/>.
/// </summary>
public sealed record EquipmentTagPlan(
string TagId,
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
@@ -141,7 +173,10 @@ public static class Phase7Composer
var nodes = equipment
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
.ToList();
var plans = driverInstances
@@ -178,6 +213,57 @@ 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 ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
.ThenBy(t => t.Name, StringComparer.Ordinal)
.Select(t => new EquipmentTagPlan(
TagId: t.TagId,
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;
}
}
@@ -26,17 +26,33 @@ public sealed record Phase7Plan(
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
{
/// <summary>
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
/// init-only members (defaulting empty) rather than positional parameters so existing
/// <c>Phase7Plan</c> construction sites compile unchanged — consistent with how
/// <see cref="Phase7CompositionResult.EquipmentTags"/> was added. Without these, an
/// incremental deploy that changes ONLY equipment tags produced an empty plan and
/// <c>OpcUaPublishActor.HandleRebuild</c> short-circuited before materialising them.
/// </summary>
public IReadOnlyList<EquipmentTagPlan> AddedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagPlan> RemovedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
public bool IsEmpty =>
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0;
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0;
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
}
public static class Phase7Planner
@@ -74,11 +90,21 @@ public static class Phase7Planner
t => t.TagId,
(a, b) => new Phase7Plan.GalaxyTagDelta(a, b));
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
previous.EquipmentTags, next.EquipmentTags,
t => t.TagId,
(a, b) => new Phase7Plan.EquipmentTagDelta(a, b));
return new Phase7Plan(
addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv,
addedAlarm, removedAlarm, changedAlarm,
addedGalaxy, removedGalaxy, changedGalaxy);
addedGalaxy, removedGalaxy, changedGalaxy)
{
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,
ChangedEquipmentTags = changedEqTags,
};
}
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
@@ -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,118 @@ 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 tagId = el.TryGetProperty("TagId", out var tidEl) ? tidEl.GetString() : null;
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(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
if (!equipmentNamespaces.Contains(nsId)) continue;
result.Add(new EquipmentTagPlan(
TagId: tagId!,
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
{
@@ -251,7 +367,10 @@ public static class DeploymentArtifact
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
{
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
var displayName = el.TryGetProperty("MachineCode", out var mcEl) ? mcEl.GetString() : null;
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching UnsArea/UnsLine
// + the live rebuild's source of truth) — NOT the colloquial MachineCode. NodeId stays the
// logical EquipmentId.
var displayName = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
var lineId = el.TryGetProperty("UnsLineId", out var lineEl) ? lineEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) return null;
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
@@ -230,6 +230,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
// + Variable node exist so clients can browse them. The Galaxy driver fills values
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
_applier.MaterialiseGalaxyTags(composition);
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
// FolderPath sub-folder) exists under its already-materialised equipment folder so
// clients can browse them. Live values arrive in a later milestone; until then the
// variables show BadWaitingForInitialData.
_applier.MaterialiseEquipmentTags(composition);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
@@ -4,6 +4,8 @@ using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
@@ -110,6 +112,109 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
}
/// <summary>Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
/// applying the same composition twice yields a single Variable node (no duplicates). This is the
/// restart-safety guarantee — HandleRebuild runs on both the apply path and the
/// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
/// restart re-runs this pass and must not double-materialise.</summary>
[Fact]
public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.EquipmentTags",
ApplicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
},
};
// Equipment folder first (the variable's parent), then the tag pass — applied twice.
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseEquipmentTags(composition);
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
}
/// <summary>
/// Full structure-materialisation pipeline against a real SDK node manager: real Config
/// entities (Area / Line / Equipment + an Equipment-namespace Tag) → <see cref="Phase7Composer.Compose"/>
/// → MaterialiseHierarchy + MaterialiseEquipmentTags → <see cref="OtOpcUaNodeManager"/>. Proves
/// an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal
/// Variable in a live OPC UA address space (structure-only; live values are a later milestone).
/// Also covers the compose-side EquipmentTags extraction. The cluster-level deploy +
/// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked
/// as a follow-up.
/// </summary>
[Fact]
public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.EquipmentE2e",
ApplicationUri = $"urn:OtOpcUa.EquipmentE2e:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
// One area / line / equipment + a Modbus FK driver in an Equipment-kind namespace, with a
// single equipment-bound Tag (the signal). Equipment.Name is the UNS browse segment.
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 = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
var equipment = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-modbus", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
var tag = new Tag { TagId = "tag-speed", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\"}" };
var composition = Phase7Composer.Compose(
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
Array.Empty<ScriptedAlarm>(), new[] { tag }, new[] { ns });
// Compose-side EquipmentTags extraction (the inverse of the Galaxy filter).
var planned = composition.EquipmentTags.ShouldHaveSingleItem();
planned.EquipmentId.ShouldBe("eq-1");
planned.FullName.ShouldBe("40001");
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
sdkServer.NodeManager!.FolderCount.ShouldBe(3); // filling area + line-1 + station-1 equipment
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
@@ -177,6 +177,110 @@ public sealed class Phase7ApplierTests
sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
}
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
/// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
/// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
/// folder (decision #4).</summary>
[Fact]
public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float"));
}
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
/// folder (not the namespace root), with the variable parented to that sub-folder and a
/// folder-scoped NodeId.</summary>
[Fact]
public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float"));
}
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
/// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one
/// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName.</summary>
[Fact]
public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float"));
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
}
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
/// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment
/// tags, so a tags-only deploy is no longer a silent no-op).</summary>
[Fact]
public void Added_equipment_tags_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.</summary>
[Fact]
public void Added_galaxy_tags_trigger_rebuild()
@@ -75,6 +75,22 @@ public sealed class Phase7ComposerPurityTests
.ShouldBe(new[] { "a", "m", "z" });
}
/// <summary>Verifies UNS equipment folders browse by the friendly UNS <c>Name</c> segment
/// (not the colloquial MachineCode, not the logical EquipmentId) while the NodeId stays the
/// logical EquipmentId — so browse-path resolution + ACLs are unaffected (decision #3).</summary>
[Fact]
public void Equipment_node_DisplayName_is_the_UNS_Name_not_MachineCode()
{
var equipment = new[] { NewEquipment("filling-eq") }; // Name="filling-eq", MachineCode="FILLING-EQ"
var node = Phase7Composer.Compose(equipment, Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>())
.EquipmentNodes.ShouldHaveSingleItem();
node.EquipmentId.ShouldBe("filling-eq"); // NodeId stays the logical Id
node.DisplayName.ShouldBe("filling-eq"); // browse name = UNS Name segment
node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode
}
private static Equipment NewEquipment(string id) => new()
{
EquipmentId = id,
@@ -30,6 +30,31 @@ public sealed class Phase7PlannerTests
plan.IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change)
/// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the
/// IsEmpty gate before materialising the new equipment variables.</summary>
[Fact]
public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1");
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
[Fact]
public void New_equipment_goes_to_AddedEquipment()
@@ -117,6 +117,89 @@ 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.TagId.ShouldBe("tag-eq");
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 ParseComposition sets the equipment folder DisplayName to the UNS <c>Name</c>
/// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so
/// equipment browses by its friendly UNS name. NodeId stays the logical EquipmentId.
/// </summary>
[Fact]
public void ParseComposition_equipment_DisplayName_is_UNS_Name_not_MachineCode()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Equipment = new[]
{
new { EquipmentId = "eq-1", Name = "filling-eq", MachineCode = "FILLING-EQ", UnsLineId = "line-1" },
},
});
var node = DeploymentArtifact.ParseComposition(blob).EquipmentNodes.ShouldHaveSingleItem();
node.EquipmentId.ShouldBe("eq-1");
node.DisplayName.ShouldBe("filling-eq");
}
/// <summary>Verifies that specs missing required fields are dropped.</summary>
[Fact]
public void Spec_missing_required_fields_is_dropped()