merge: equipment-namespace live values (VirtualTag route)
v2-ci / build (push) Failing after 36s
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

This commit is contained in:
Joseph Doherty
2026-06-07 09:33:21 -04:00
17 changed files with 1671 additions and 13 deletions
@@ -70,19 +70,24 @@ public sealed class Phase7Applier
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count;
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count;
plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
// 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.
// Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, Equipment tag, or Equipment
// VirtualTag 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.
// TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
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.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0;
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;
if (needsRebuild)
{
@@ -233,6 +238,53 @@ public sealed class Phase7Applier
composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
/// analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
/// practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
/// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
/// (<c>parent/Name</c>) — NOT the <see cref="EquipmentVirtualTagPlan.VirtualTagId"/> or
/// <see cref="EquipmentVirtualTagPlan.Expression"/> — so identically-named VirtualTags on
/// different equipments never collide in the sink (which keys on NodeId). Variables start
/// BadWaitingForInitialData; <c>VirtualTagActor</c> fills live values in a later milestone.
/// Idempotent (per-variable idempotency relies on the sink's own <c>EnsureVariable</c>).
/// </summary>
/// <param name="composition">The composition result containing the equipment VirtualTags to materialise.</param>
public void MaterialiseEquipmentVirtualTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.EquipmentVirtualTags.Count == 0) return;
// Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (never re-create it here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var v in composition.EquipmentVirtualTags)
{
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
var folderNodeId = EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath);
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
// Parent is the FolderPath sub-folder when set, else the equipment folder directly.
foreach (var v in composition.EquipmentVirtualTags)
{
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
? v.EquipmentId
: EquipmentSubFolderNodeId(v.EquipmentId, v.FolderPath);
var nodeId = $"{parent}/{v.Name}";
SafeEnsureVariable(nodeId, parent, v.Name, v.DataType);
}
_logger.LogInformation(
"Phase7Applier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment})",
composition.EquipmentVirtualTags.Count,
composition.EquipmentVirtualTags.Select(v => v.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) =>
@@ -56,6 +56,10 @@ public sealed record Phase7CompositionResult(
/// constructor + call site keeps compiling unchanged; new producers set it via initializer.
/// </summary>
public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
/// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
/// defaults empty so every existing constructor + call site keeps compiling.</summary>
public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
}
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
@@ -100,6 +104,50 @@ public sealed record EquipmentTagPlan(
string DataType,
string FullName);
/// <summary>
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
/// <see cref="Script"/> for the expression). The VirtualTag value analogue of
/// <see cref="EquipmentTagPlan"/>: <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c>
/// materialises each as a Variable under its equipment folder with a folder-scoped NodeId
/// (<c>EquipmentId/Name</c>, or <c>EquipmentId/FolderPath/Name</c> when a sub-folder is set),
/// and <c>VirtualTagHostActor</c> spawns a <c>VirtualTagActor</c> per plan that evaluates
/// <see cref="Expression"/> over <see cref="DependencyRefs"/> and publishes the value back to
/// that NodeId. <see cref="DependencyRefs"/> = the distinct <c>ctx.GetTag("…")</c> literals in
/// the script source.
/// </summary>
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
string FolderPath,
string Name,
string DataType,
string Expression,
IReadOnlyList<string> DependencyRefs)
{
/// <summary>Structural equality: the auto-generated record equality would compare
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every
/// VirtualTag as "changed" on every parse (fresh list instances). Compare it element-wise
/// so a no-op redeploy diffs empty.</summary>
public bool Equals(EquipmentVirtualTagPlan? other) =>
other is not null &&
VirtualTagId == other.VirtualTagId &&
EquipmentId == other.EquipmentId &&
FolderPath == other.FolderPath &&
Name == other.Name &&
DataType == other.DataType &&
Expression == other.Expression &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
return hash.ToHashCode();
}
}
/// <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
@@ -151,6 +199,8 @@ public static class Phase7Composer
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="tags">The tags.</param>
/// <param name="namespaces">The namespaces.</param>
/// <param name="virtualTags">The Equipment-namespace virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
@@ -159,8 +209,12 @@ public static class Phase7Composer
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<Tag> tags,
IReadOnlyList<Namespace> namespaces)
IReadOnlyList<Namespace> namespaces,
IReadOnlyList<VirtualTag>? virtualTags = null,
IReadOnlyList<Script>? scripts = null)
{
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
var resolvedScripts = scripts ?? Array.Empty<Script>();
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
@@ -234,9 +288,31 @@ public static class Phase7Composer
FullName: ExtractTagFullName(t.TagConfig)))
.ToList();
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
// expression source. DependencyRefs = the distinct ctx.GetTag("…") literals the
// VirtualTagActor subscribes to. VirtualTag has no FolderPath today → "".
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
var equipmentVirtualTags = vtags
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
.ThenBy(v => v.Name, StringComparer.Ordinal)
.Select(v =>
{
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
return new EquipmentVirtualTagPlan(
VirtualTagId: v.VirtualTagId,
EquipmentId: v.EquipmentId,
FolderPath: string.Empty,
Name: v.Name,
DataType: v.DataType,
Expression: src,
DependencyRefs: ExtractDependencyRefs(src));
})
.ToList();
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
};
}
@@ -266,4 +342,24 @@ public static class Phase7Composer
catch (JsonException) { /* fall through to raw blob */ }
return tagConfig;
}
private static readonly System.Text.RegularExpressions.Regex GetTagRefRegex =
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>Distinct <c>ctx.GetTag("ref")</c> string literals in a VirtualTag script source,
/// in first-seen order — the dependency refs the VirtualTagActor subscribes to.</summary>
/// <param name="scriptSource">The VirtualTag's script source.</param>
/// <returns>The distinct dependency refs in first-seen order.</returns>
private static IReadOnlyList<string> ExtractDependencyRefs(string scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (System.Text.RegularExpressions.Match m in GetTagRefRegex.Matches(scriptSource))
{
var r = m.Groups[1].Value;
if (seen.Add(r)) result.Add(r);
}
return result;
}
}
@@ -40,19 +40,36 @@ public sealed record Phase7Plan(
/// <inheritdoc cref="AddedEquipmentTags"/>
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
/// <summary>
/// Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
/// The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
/// <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
/// compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
/// </summary>
public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
public IReadOnlyList<EquipmentVirtualTagPlan> RemovedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
public IReadOnlyList<EquipmentVirtualTagDelta> ChangedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagDelta>();
/// <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 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0;
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.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 sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
}
public static class Phase7Planner
@@ -95,6 +112,16 @@ public static class Phase7Planner
t => t.TagId,
(a, b) => new Phase7Plan.EquipmentTagDelta(a, b));
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
// list instances, identical contents) correctly diffs to empty; only a real content change is
// flagged as changed.
var (addedVTags, removedVTags, changedVTags) = DiffById(
previous.EquipmentVirtualTags, next.EquipmentVirtualTags,
t => t.VirtualTagId,
(a, b) => new Phase7Plan.EquipmentVirtualTagDelta(a, b));
return new Phase7Plan(
addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv,
@@ -104,6 +131,9 @@ public static class Phase7Planner
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,
ChangedEquipmentTags = changedEqTags,
AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags,
};
}
@@ -193,10 +193,12 @@ public static class DeploymentArtifact
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
var equipmentTags = BuildEquipmentTagPlans(root);
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root);
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
};
}
catch (JsonException)
@@ -251,6 +253,7 @@ public static class DeploymentArtifact
full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray())
{
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
};
}
@@ -490,6 +493,91 @@ public static class DeploymentArtifact
return result;
}
/// <summary>
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
/// <c>Phase7Composer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
/// plans agree. <c>Expression</c> = the joined Script's <c>SourceCode</c> (empty when the
/// ScriptId is absent); <c>DependencyRefs</c> = the distinct <c>ctx.GetTag("…")</c> literals in
/// that source; <c>FolderPath</c> is always "" (VirtualTag has no FolderPath today). Ordered by
/// EquipmentId then Name to match the composer's deterministic ordering.
/// </summary>
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(JsonElement root)
{
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentVirtualTagPlan>();
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
if (root.TryGetProperty("Scripts", out var scriptsArr) && scriptsArr.ValueKind == JsonValueKind.Array)
{
foreach (var el in scriptsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var sid = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(sid)) continue;
var src = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
? srcEl.GetString() : null;
scriptSourceById[sid!] = src ?? string.Empty;
}
}
var result = new List<EquipmentVirtualTagPlan>(vtArr.GetArrayLength());
foreach (var el in vtArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var virtualTagId = el.TryGetProperty("VirtualTagId", out var vidEl) ? vidEl.GetString() : null;
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
var scriptId = el.TryGetProperty("ScriptId", out var sidEl) ? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId)
|| string.IsNullOrWhiteSpace(name)) continue;
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
? src : string.Empty;
result.Add(new EquipmentVirtualTagPlan(
VirtualTagId: virtualTagId!,
EquipmentId: equipmentId!,
FolderPath: string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
Expression: source,
DependencyRefs: ExtractDependencyRefs(source)));
}
result.Sort((a, b) =>
{
var byEquipment = string.CompareOrdinal(a.EquipmentId, b.EquipmentId);
return byEquipment != 0 ? byEquipment : string.CompareOrdinal(a.Name, b.Name);
});
return result;
}
private static readonly System.Text.RegularExpressions.Regex GetTagRefRegex =
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>
/// Distinct <c>ctx.GetTag("ref")</c> string literals in a VirtualTag script source, in
/// first-seen order. The artifact-decode mirror of <c>Phase7Composer.ExtractDependencyRefs</c>
/// — replicated (with the same regex) because Runtime does not reference the OpcUaServer
/// compose assembly; kept in sync with that copy.
/// </summary>
private static IReadOnlyList<string> ExtractDependencyRefs(string scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (System.Text.RegularExpressions.Match m in GetTagRefRegex.Matches(scriptSource))
{
var r = m.Groups[1].Value;
if (seen.Add(r)) result.Add(r);
}
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> /
@@ -3,6 +3,7 @@ using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
@@ -14,6 +15,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -52,8 +54,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly IActorRef? _dependencyMux;
private readonly IActorRef? _opcUaPublishActor;
private readonly IDriverHealthPublisher _healthPublisher;
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
private readonly IActorRef? _virtualTagHostOverride;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>The single VirtualTag-host child that spawns/reconciles Equipment-namespace
/// VirtualTagActors and bridges their results onto the OPC UA publish actor. Spawned in
/// <see cref="PreStart"/> when an OPC UA publish actor is wired; receives
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> from <see cref="PushDesiredSubscriptions"/>.</summary>
private IActorRef? _virtualTagHost;
private RevisionHash? _currentRevision;
private DeploymentId? _applyingDeploymentId;
@@ -85,6 +95,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
/// <param name="healthPublisher">Optional driver-health publisher; defaults to <see cref="NullDriverHealthPublisher"/>
/// so test harnesses and smoke fixtures don't need to wire it.</param>
/// <param name="virtualTagEvaluator">Optional evaluator handed to the spawned
/// <see cref="VirtualTagHostActor"/>'s children; defaults to <see cref="NullVirtualTagEvaluator"/>
/// (the dev/Mac path where no expression is evaluated). Production passes the DI-resolved
/// Roslyn evaluator.</param>
/// <param name="virtualTagHostOverride">Test seam: when supplied, this actor is used as the
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
/// production (the real host is spawned).</param>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -93,9 +111,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IReadOnlySet<string>? localRoles = null,
IActorRef? dependencyMux = null,
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null) =>
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
IActorRef? virtualTagHostOverride = null) =>
Akka.Actor.Props.Create(() => new DriverHostActor(
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor, healthPublisher));
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
healthPublisher, virtualTagEvaluator, virtualTagHostOverride));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -106,6 +127,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <param name="dependencyMux">Optional actor reference for dependency multiplexing.</param>
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
/// <param name="healthPublisher">Optional driver-health publisher; defaults to <see cref="NullDriverHealthPublisher"/>.</param>
/// <param name="virtualTagEvaluator">Optional evaluator handed to the VirtualTag host's children;
/// defaults to <see cref="NullVirtualTagEvaluator"/>.</param>
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -114,7 +139,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
IReadOnlySet<string>? localRoles = null,
IActorRef? dependencyMux = null,
IActorRef? opcUaPublishActor = null,
IDriverHealthPublisher? healthPublisher = null)
IDriverHealthPublisher? healthPublisher = null,
IVirtualTagEvaluator? virtualTagEvaluator = null,
IActorRef? virtualTagHostOverride = null)
{
_dbFactory = dbFactory;
_localNode = localNode;
@@ -124,6 +151,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_dependencyMux = dependencyMux;
_opcUaPublishActor = opcUaPublishActor;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
_virtualTagHostOverride = virtualTagHostOverride;
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
Become(Steady);
@@ -136,9 +165,40 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DeploymentsTopic, Self));
// Subscribe to driver-control topic so AdminUI Reconnect/Restart commands land here.
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DriverControlTopic, Self));
// Spawn the VirtualTag host BEFORE Bootstrap so the bootstrap-restore path (which routes
// through PushDesiredSubscriptions and Tells ApplyVirtualTags) has a live host to target.
SpawnVirtualTagHost();
Bootstrap();
}
/// <summary>
/// Spawns the single <see cref="VirtualTagHostActor"/> child that owns the Equipment-namespace
/// VirtualTagActors and bridges their results onto the OPC UA publish actor. A test-supplied
/// <c>virtualTagHostOverride</c> short-circuits the spawn so a probe can intercept
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/>. The real host requires a non-null
/// <see cref="_opcUaPublishActor"/> (its ctor throws otherwise), so when no publish actor is
/// wired (legacy ControlPlane test harnesses with no OPC UA sink) the host is left null and
/// ApplyVirtualTags becomes a no-op — VirtualTags can't have anywhere to publish without it.
/// </summary>
private void SpawnVirtualTagHost()
{
if (_virtualTagHostOverride is not null)
{
_virtualTagHost = _virtualTagHostOverride;
return;
}
if (_opcUaPublishActor is null)
{
_log.Debug("DriverHost {Node}: no OPC UA publish actor wired; skipping VirtualTag host spawn", _localNode);
return;
}
_virtualTagHost = Context.ActorOf(
VirtualTagHostActor.Props(_opcUaPublishActor, _dependencyMux, _virtualTagEvaluator),
"virtual-tag-host");
}
private void Bootstrap()
{
// Read the most-recent NodeDeploymentState for this node; if it's Applied, jump
@@ -459,6 +519,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_log.Info("DriverHost {Node}: SubscribeBulk pushed {Refs} references across {Drivers} driver(s)",
_localNode, total, refsByDriver.Count);
}
// Hand the Equipment-namespace VirtualTags to the host so it spawns/reconciles a
// VirtualTagActor per plan and streams their evaluated values back onto the just-rebuilt
// address space. Runs on BOTH the fresh-apply path (ApplyAndAck) and the bootstrap-restore
// path (RestoreApplied) because both call this method, so one send covers both.
// NOTE: the Stale-recovery path (TryRecoverFromStale) does NOT call PushDesiredSubscriptions,
// so — like drivers — VirtualTags remain empty after a Stale recovery until the next
// deployment dispatch. This is intentional and consistent with driver recovery: the Stale
// path only restores the revision marker + NodeDeploymentState; a subsequent dispatch
// (or a redeploy from AdminUI) triggers the full apply + subscribe pass.
_virtualTagHost?.Tell(new VirtualTagHostActor.ApplyVirtualTags(composition.EquipmentVirtualTags));
if (composition.EquipmentVirtualTags.Count > 0)
{
_log.Info("DriverHost {Node}: applied {Count} Equipment VirtualTag(s) to the VirtualTag host",
_localNode, composition.EquipmentVirtualTags.Count);
}
}
private void SpawnChild(DriverInstanceSpec spec)
@@ -238,6 +238,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
// clients can browse them. Live values arrive in a later milestone; until then the
// variables show BadWaitingForInitialData.
_applier.MaterialiseEquipmentTags(composition);
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
// equipment folder with a folder-scoped NodeId. The VirtualTagActor fills live values in a
// later milestone; until then the variables show BadWaitingForInitialData (same as tags).
_applier.MaterialiseEquipmentVirtualTags(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})",
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration;
@@ -89,6 +90,16 @@ public static class ServiceCollectionExtensions
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
var healthPublisher = resolver.GetService<IDriverHealthPublisher>() ?? NullDriverHealthPublisher.Instance;
// Production evaluator is the Host's RoslynVirtualTagEvaluator (registered as
// IVirtualTagEvaluator); fall back to the null evaluator for test harnesses that don't
// register one (VirtualTagActor children then evaluate to nothing).
var virtualTagEvaluator = resolver.GetService<IVirtualTagEvaluator>();
if (virtualTagEvaluator is null)
{
loggerFactory.CreateLogger("ZB.MOM.WW.OtOpcUa.Runtime.ServiceCollectionExtensions")
.LogWarning("IVirtualTagEvaluator not registered; Equipment VirtualTags will evaluate to NoChange (no live values). Expected only in test harnesses — driver-role nodes should register RoslynVirtualTagEvaluator.");
virtualTagEvaluator = NullVirtualTagEvaluator.Instance;
}
var dbHealth = system.ActorOf(
DbHealthProbeActor.Props(dbFactory),
@@ -119,7 +130,8 @@ public static class ServiceCollectionExtensions
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
dependencyMux: mux,
opcUaPublishActor: publishActor,
healthPublisher: healthPublisher),
healthPublisher: healthPublisher,
virtualTagEvaluator: virtualTagEvaluator),
DriverHostActorName);
registry.Register<DriverHostActorKey>(driverHost);
@@ -0,0 +1,156 @@
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// <summary>
/// Supervisor that gives Equipment-namespace VirtualTags live values. For each
/// <see cref="EquipmentVirtualTagPlan"/> in the desired set it spawns one child
/// <see cref="VirtualTagActor"/> (which self-registers with the dependency mux and evaluates its
/// expression on dependency changes) and remembers the plan's <b>folder-scoped NodeId</b>. When a
/// child reports a fresh <see cref="VirtualTagActor.EvaluationResult"/>, the host bridges it onto
/// an <see cref="OpcUaPublishActor.AttributeValueUpdate"/> targeting that NodeId so the
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
///
/// <para>
/// The published NodeId is computed with the <b>identical</b> formula
/// <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c> uses to materialise the variable —
/// <c>{parent}/{Name}</c> where <c>parent = IsNullOrWhiteSpace(FolderPath) ? EquipmentId :
/// {EquipmentId}/{FolderPath}</c> — or the value would land on a NodeId that does not exist.
/// </para>
/// </summary>
public sealed class VirtualTagHostActor : ReceiveActor
{
/// <summary>Reconciles the live VirtualTag children to exactly the supplied desired set:
/// stops children whose vtagId is gone, spawns children for new vtagIds, and rebuilds the
/// vtagId→NodeId map so renames are reflected.</summary>
/// <param name="Plans">The desired Equipment-namespace VirtualTag plans.</param>
public sealed record ApplyVirtualTags(IReadOnlyList<EquipmentVirtualTagPlan> Plans);
private readonly IActorRef _publishActor;
private readonly IActorRef? _mux;
private readonly IVirtualTagEvaluator _evaluator;
private readonly ILoggingAdapter _log = Context.GetLogger();
// vtagId -> spawned child VirtualTagActor.
private readonly Dictionary<string, IActorRef> _children = new(StringComparer.Ordinal);
// vtagId -> folder-scoped OPC UA NodeId the materialiser placed the variable at.
private readonly Dictionary<string, string> _nodeIdByVtag = new(StringComparer.Ordinal);
/// <summary>Factory method to create Props for a VirtualTagHostActor.</summary>
/// <param name="publishActor">The OPC UA publish actor that consumes
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> bridged from child results.</param>
/// <param name="mux">Optional dependency multiplexer; passed to each spawned child so it can
/// register interest in its dependency refs. Null on the dev/Mac path (no live values).</param>
/// <param name="evaluator">The evaluator each child uses to compute its expression.</param>
public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) =>
Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator));
/// <summary>Initializes a new instance of the <see cref="VirtualTagHostActor"/> class.</summary>
/// <param name="publishActor">The OPC UA publish actor results are bridged to.</param>
/// <param name="mux">Optional dependency multiplexer passed to each spawned child.</param>
/// <param name="evaluator">The evaluator each child uses to compute its expression.</param>
public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator)
{
ArgumentNullException.ThrowIfNull(publishActor);
ArgumentNullException.ThrowIfNull(evaluator);
_publishActor = publishActor;
_mux = mux;
_evaluator = evaluator;
Receive<ApplyVirtualTags>(OnApply);
Receive<VirtualTagActor.EvaluationResult>(OnResult);
Receive<Terminated>(OnChildTerminated);
}
private void OnApply(ApplyVirtualTags msg)
{
var desired = new HashSet<string>(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);
// Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its
// PostStop, which unregisters its interest from the mux.
foreach (var vtagId in _children.Keys.Where(id => !desired.Contains(id)).ToList())
{
Context.Stop(_children[vtagId]);
_children.Remove(vtagId);
}
// Rebuild the NodeId map every apply so renames (Name/FolderPath/EquipmentId changes) are
// picked up. The map only contains currently-desired vtags, so a result for a removed vtag
// finds no entry and is dropped.
_nodeIdByVtag.Clear();
foreach (var p in msg.Plans)
{
_nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
}
// Spawn children for new vtagIds only — existing children keep their mux subscriptions and
// last-value dedup state. Expression/dependency changes on an existing vtag are NOT
// re-applied here; the loader's vtags are stable, and a future enhancement can stop+respawn
// a child whose plan changed (the diff already identifies ChangedEquipmentVirtualTags).
foreach (var p in msg.Plans)
{
// TODO(equipment-virtualtags): when a plan's Expression/DependencyRefs change in place
// (ChangedEquipmentVirtualTags), stop+respawn the child here; today only spawn-new/stop-removed
// is handled (loader vtags are stable).
if (_children.ContainsKey(p.VirtualTagId)) continue;
// Auto-name the child: vtagIds can contain characters illegal in actor names, so let Akka
// assign a safe unique name. The child self-registers with the mux in PreStart.
var child = Context.ActorOf(VirtualTagActor.Props(
virtualTagId: p.VirtualTagId,
expression: p.Expression,
evaluator: _evaluator,
scriptId: p.VirtualTagId,
publisherFactory: null,
dependencyRefs: p.DependencyRefs,
mux: _mux));
Context.Watch(child);
_children[p.VirtualTagId] = child;
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
}
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
desired.Count, _children.Count);
}
private void OnResult(VirtualTagActor.EvaluationResult result)
{
// A result may arrive for a vtag that was just removed from the desired set (the child's
// last in-flight message). With no NodeId mapping we have nowhere to land it — drop silently.
if (!_nodeIdByVtag.TryGetValue(result.VirtualTagId, out var nodeId))
{
return;
}
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc));
}
private void OnChildTerminated(Terminated msg)
{
var stale = _children.Where(kv => kv.Value.Equals(msg.ActorRef)).Select(kv => kv.Key).ToList();
foreach (var id in stale)
{
_children.Remove(id);
// NodeId map is rebuilt on the next ApplyVirtualTags; leaving the mapping is harmless
// (no child will publish for it until respawned). A dead child is respawned on next apply.
_log.Warning("VirtualTagHost: child for vtag {VirtualTagId} terminated; will respawn on next apply", id);
}
}
/// <summary>Folder-scoped NodeId for a VirtualTag plan — MUST match
/// <c>Phase7Applier.MaterialiseEquipmentVirtualTags</c> exactly, or the published value lands on a
/// NodeId that was never materialised.</summary>
private static string NodeIdFor(EquipmentVirtualTagPlan p)
{
var parent = string.IsNullOrWhiteSpace(p.FolderPath)
? p.EquipmentId
: $"{p.EquipmentId}/{p.FolderPath}";
return $"{parent}/{p.Name}";
}
}