Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
T

713 lines
43 KiB
C#

using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
/// <see cref="AddressSpaceComposition"/> snapshots:
///
/// <list type="bullet">
/// <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
/// node id then call <c>RebuildAddressSpace</c> at the end so the sink can
/// actually tear down the OPC UA folders + variables.</item>
/// <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
/// will repopulate from the persisted artifact). For now we record the work.</item>
/// <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
/// that lands in F10b will decide between in-place property writes and
/// tear-down + rebuild.</item>
/// </list>
///
/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
/// and tests can capture every call.
/// </summary>
public sealed class AddressSpaceApplier
{
private readonly IOpcUaAddressSpaceSink _sink;
private readonly ILogger<AddressSpaceApplier> _logger;
private readonly IHistorianProvisioning _provisioning;
private readonly IHistorizedTagSubscriptionSink _historizedSubscriptions;
/// <summary>Initializes a new instance of the AddressSpaceApplier class.</summary>
/// <param name="sink">The OPC UA address space sink to apply changes to.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="provisioning">
/// Optional historian tag provisioner — when an address space is (re)built, historized added
/// tags are auto-ensured in the historian via <see cref="IHistorianProvisioning.EnsureTagsAsync"/>.
/// Defaults (a <c>null</c> argument) to the no-op <see cref="NullHistorianProvisioning"/>, so every
/// existing two-argument call site compiles and behaves unchanged. The provisioning round-trip is
/// dispatched fire-and-forget off <see cref="Apply"/> (which runs on the OPC UA publish actor's
/// pinned thread), so it can never block or break a deploy.
/// </param>
/// <param name="historizedSubscriptions">
/// Optional continuous-historization feed — when an address space is (re)applied, the add/remove
/// delta of historized tag refs (resolved EXACTLY as the provisioning hook above) is pushed to the
/// recorder so its dependency-mux interest converges to the currently-historized set. Defaults
/// (a <c>null</c> argument) to the no-op <see cref="NullHistorizedTagSubscriptionSink"/>, so every
/// existing call site compiles and behaves unchanged. The feed is a single non-blocking post off
/// <see cref="Apply"/> and is wrapped so it can never block or break a deploy.
/// </param>
public AddressSpaceApplier(
IOpcUaAddressSpaceSink sink,
ILogger<AddressSpaceApplier> logger,
IHistorianProvisioning? provisioning = null,
IHistorizedTagSubscriptionSink? historizedSubscriptions = null)
{
ArgumentNullException.ThrowIfNull(sink);
ArgumentNullException.ThrowIfNull(logger);
_sink = sink;
_logger = logger;
_provisioning = provisioning ?? NullHistorianProvisioning.Instance;
_historizedSubscriptions = historizedSubscriptions ?? NullHistorizedTagSubscriptionSink.Instance;
}
/// <summary>
/// Apply <paramref name="plan"/> to the sink. Returns a summary of what was applied so
/// callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
/// </summary>
/// <param name="plan">The plan to apply.</param>
/// <returns>A AddressSpaceApplyOutcome summarizing the applied changes.</returns>
public AddressSpaceApplyOutcome Apply(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
if (plan.IsEmpty)
{
_logger.LogDebug("AddressSpaceApplier: plan is empty; skipping sink writes");
return new AddressSpaceApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false);
}
var ts = DateTime.UtcNow;
var removedCount = 0;
foreach (var eq in plan.RemovedEquipment)
{
SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts);
removedCount++;
}
foreach (var alarm in plan.RemovedAlarms)
{
SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts);
removedCount++;
}
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below.
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count;
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count +
// A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
// re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps
// the stale node.
// ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the
// address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which
// re-spawns the affected driver actor without re-materialising any nodes.
// F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/
// Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is
// byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild.
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
// IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no
// alarm condition node) can be updated IN PLACE on the existing node via
// ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full
// rebuild and preserving subscriptions. The shape fields (DataType/IsArray/ArrayLength) are now
// surgical because the sink swaps DataType + ValueRank + ArrayDimensions in place and raises a
// GeneralModelChangeEvent. Any other tag difference (FullName/Name/DriverInstanceId/identity/alarm) —
// or a sink that lacks the surgical capability, or a node that turns out missing — falls back to a
// full rebuild (safe default).
var structuralRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 ||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d));
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList();
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
// covered for free and need no separate surgical pass.
var renamedFolders = plan.RenamedFolders;
var rebuilt = false;
var rebuildFailed = false;
if (structuralRebuild)
{
rebuildFailed = !SafeRebuild();
rebuilt = true;
}
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0)
{
if (_sink is ISurgicalAddressSpaceSink surgical)
{
var allApplied = true;
// Folder renames first — an in-place DisplayName swap on the existing Area/Line folder.
foreach (var rename in renamedFolders)
{
bool ok;
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
ok = false;
}
if (!ok) { allApplied = false; break; }
}
foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
{
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
// would so the in-place update matches what a rebuild would have produced. Array tags are
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
var writable = d.Current.Writable && !d.Current.IsArray;
var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
: null;
bool ok;
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
ok = false;
}
if (!ok) { allApplied = false; break; }
}
if (!allApplied) { rebuildFailed = !SafeRebuild(); rebuilt = true; }
}
else
{
// Sink lacks the surgical capability ⇒ rebuild (safe default).
rebuildFailed = !SafeRebuild();
rebuilt = true;
}
}
_logger.LogInformation(
"AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
// After the address-space work has completed, auto-provision the historian for the added
// historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block
// or break the deploy — Apply has already produced its outcome and returns it regardless.
ProvisionHistorizedTags(plan);
// Alongside provisioning: feed the continuous-historization recorder the add/remove delta of
// historized tag refs this plan changes, so its dependency-mux interest converges to exactly the
// currently-historized set. Same non-blocking + throw-safe discipline as the provisioning hook.
FeedHistorizedRefs(plan);
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed);
}
/// <summary>
/// Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request
/// list only and the gateway round-trip is dispatched fire-and-forget. The whole hook is wrapped
/// in try/catch — a synchronously-throwing provisioner (or any request-building fault) is
/// swallowed so it cannot break a deploy.
/// </summary>
/// <param name="plan">The plan whose added historized tags to ensure in the historian.</param>
private void ProvisionHistorizedTags(AddressSpacePlan plan)
{
try
{
List<HistorianTagProvisionRequest>? requests = null;
foreach (var tag in plan.AddedEquipmentTags)
{
// Only historized value variables are provisioned. Native-alarm tags materialise as
// Part 9 condition nodes (never historized value variables) — the materialiser resolves
// a historian tagname only for the non-alarm branch, so mirror that and skip them.
if (!tag.IsHistorized || tag.Alarm is not null) continue;
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable
// type is skipped (logged at Debug) rather than faulting the hook.
if (!Enum.TryParse<DriverDataType>(tag.DataType, ignoreCase: true, out var dataType))
{
_logger.LogDebug(
"AddressSpaceApplier: skipping historian provisioning for an added historized tag whose data type '{DataType}' is not a DriverDataType",
tag.DataType);
continue;
}
// Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank
// override falls back to the driver-side FullName.
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname;
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
}
if (requests is null) return;
// Fire-and-forget OFF the apply path. Never await/.Wait()/.Result here — Apply must return
// its outcome without blocking on the gateway. The continuation observes the task so a
// faulted provisioning never becomes an unobserved exception, and logs the tally.
var provisionCount = requests.Count;
var dispatch = _provisioning.EnsureTagsAsync(requests, CancellationToken.None);
_ = dispatch.ContinueWith(
t =>
{
if (!t.IsCompletedSuccessfully)
{
// Faulted OR canceled — never reach t.Result (which would re-throw and
// leave this discarded continuation unobserved).
_logger.LogWarning(t.Exception?.GetBaseException(),
"AddressSpaceApplier: historian provisioning of {Count} tag(s) did not complete; deploy unaffected", provisionCount);
return;
}
// Emit a tally on EVERY successful dispatch (not only on Failed/Skipped) so a fully-
// successful provisioning is observable AND a dormant no-op is detectable: the no-op
// NullHistorianProvisioning reports Requested=0 regardless of input, so a line showing
// dispatched={N} but requested=0 unmistakably flags "provisioning is not wired" — the
// exact invisibility that let the dormant-provisioner bug ship unnoticed.
var result = t.Result;
_logger.LogInformation(
"AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed);
},
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
}
catch (Exception ex)
{
// A synchronous fault (e.g. the provisioner throws before returning a task) must not break
// the deploy. Apply has already produced its outcome.
_logger.LogWarning(ex, "AddressSpaceApplier: historian provisioning hook faulted synchronously; deploy unaffected");
}
}
/// <summary>
/// Feed the continuous-historization recorder the add/remove delta of historized tag refs this
/// plan changes, so its dependency-mux interest converges to exactly the currently-historized set
/// after every deploy. The plan is a pure DIFF (an incremental/surgical apply carries a delta, not
/// the full set), so a delta feed is the only convergent design this hook can produce — the
/// recorder keeps the full set and re-registers it. Each ref is resolved EXACTLY as
/// <see cref="ProvisionHistorizedTags"/> / <c>MaterialiseEquipmentTags</c> resolve it
/// (override-or-FullName), and only non-alarm historized value variables count (native-alarm tags
/// materialise as Part 9 condition nodes, never historized value variables). Runs on the OPC UA
/// publish actor's pinned thread, so the only work here is building two small ref lists; the
/// downstream feed is a single non-blocking post behind the sink. The whole hook is wrapped so a
/// faulting feed can never block or break a deploy.
/// </summary>
/// <param name="plan">The plan whose historized-ref changes drive the recorder's interest set.</param>
private void FeedHistorizedRefs(AddressSpacePlan plan)
{
try
{
List<HistorizedTagRef>? added = null;
List<HistorizedTagRef>? removed = null;
// Added historized value variables → new interest.
foreach (var tag in plan.AddedEquipmentTags)
{
if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
}
// Removed historized value variables → drop interest.
foreach (var tag in plan.RemovedEquipmentTags)
{
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
}
// Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName
// change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the
// mux ref and the historian name) — an unchanged pair is a no-op.
foreach (var d in plan.ChangedEquipmentTags)
{
var prev = HistorizedRef(d.Previous);
var cur = HistorizedRef(d.Current);
if (prev == cur) continue;
if (prev is not null) (removed ??= new List<HistorizedTagRef>()).Add(prev);
if (cur is not null) (added ??= new List<HistorizedTagRef>()).Add(cur);
}
if (added is null && removed is null) return;
_historizedSubscriptions.UpdateHistorizedRefs(
added ?? (IReadOnlyList<HistorizedTagRef>)Array.Empty<HistorizedTagRef>(),
removed ?? (IReadOnlyList<HistorizedTagRef>)Array.Empty<HistorizedTagRef>());
}
catch (Exception ex)
{
// A synchronous fault in the feed (or in building the ref lists) must not break the deploy.
// Apply has already produced its outcome.
_logger.LogWarning(ex, "AddressSpaceApplier: historized-ref subscription feed faulted; deploy unaffected");
}
}
/// <summary>
/// Resolve the historized tag ref for <paramref name="tag"/> as a
/// <see cref="HistorizedTagRef"/> carrying BOTH identifiers the recorder needs: the
/// <c>MuxRef</c> = the driver-side <c>FullName</c> the dependency mux fans values by, and the
/// <c>HistorianName</c> = the value the EnsureTags hook / materialiser write under (a non-alarm
/// historized value variable's <c>HistorianTagname</c> override, else its <c>FullName</c>). The
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
/// value variable (not historized, or a native-alarm condition node).
/// </summary>
/// <param name="tag">The equipment tag to resolve a historized ref for.</param>
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null
? new HistorizedTagRef(
tag.FullName,
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the caller threads the
/// result into <see cref="AddressSpaceApplyOutcome.RebuildFailed"/> so a broken rebuild is
/// visible instead of reported as optimistic success (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the rebuild completed; <c>false</c> when the sink threw.</returns>
private bool SafeRebuild()
{
try
{
_sink.RebuildAddressSpace();
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: sink.RebuildAddressSpace threw");
return false;
}
}
/// <summary>
/// Build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
/// clients browsing the server see proper folder structure instead of flat tag ids.
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
/// present, so re-applies are cheap.
/// </summary>
/// <param name="composition">The composition result containing the hierarchy to materialise.</param>
public void MaterialiseHierarchy(AddressSpaceComposition composition)
{
ArgumentNullException.ThrowIfNull(composition);
foreach (var area in composition.UnsAreas)
{
SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName);
}
foreach (var line in composition.UnsLines)
{
SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName);
}
foreach (var equipment in composition.EquipmentNodes)
{
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName);
}
_logger.LogInformation(
"AddressSpaceApplier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})",
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot.
/// 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. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
/// logical-Id NodeIds and the already-materialised equipment folders. 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).
/// </summary>
/// <param name="composition">The composition result containing the equipment tags to materialise.</param>
public void MaterialiseEquipmentTags(AddressSpaceComposition 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 (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 = EquipmentNodeIds.SubFolder(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.
// Per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in composition.EquipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true);
}
else
{
// Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective
// historian tagname HERE (default-vs-override): a null/blank override falls back to the
// driver-side FullName; null means the tag is not historized at all.
string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
// Array writes are out of scope (Phase 4c read-only surface): force array tags read-only
// even if authored ReadWrite, so a client write cannot reach the driver write path which
// does not handle arrays (e.g. S7 BoxValueForWrite would crash).
var writable = tag.Writable && !tag.IsArray;
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength);
}
}
_logger.LogInformation(
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment})",
composition.EquipmentTags.Count,
composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>
/// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
/// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
/// this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
/// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only
/// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass.
/// </summary>
/// <param name="equipmentRootNodeId">The equipment root node the discovered nodes hang under; the
/// NodeAdded model-change is announced under this node.</param>
/// <param name="folders">The discovered folders to ensure (parent-first by depth).</param>
/// <param name="variables">The discovered variables to ensure (read-only value nodes).</param>
public void MaterialiseDiscoveredNodes(
string equipmentRootNodeId,
IReadOnlyList<DiscoveredFolder> folders,
IReadOnlyList<DiscoveredVariable> variables)
{
ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId);
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(variables);
if (folders.Count == 0 && variables.Count == 0) return;
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName);
foreach (var v in variables)
{
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
var writable = v.Writable && !v.IsArray;
SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength);
}
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId);
_logger.LogInformation(
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars})",
equipmentRootNodeId, folders.Count, variables.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(AddressSpaceComposition 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 = EquipmentNodeIds.SubFolder(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.
// NOTE (H5): a VirtualTag's Historize flag is honoured on the WRITE side only — VirtualTagHostActor
// forwards historized results to IHistoryWriter. It is intentionally NOT materialised as an SDK
// Historizing/HistoryRead variable here (no server-side OPC UA HistoryRead for vtags), so these
// stay plain read-only computed-output nodes.
foreach (var v in composition.EquipmentVirtualTags)
{
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
? v.EquipmentId
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
// VirtualTags are computed outputs — read-only nodes (no inbound write).
SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false);
}
_logger.LogInformation(
"AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment})",
composition.EquipmentVirtualTags.Count,
composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
/// <summary>
/// T14 — materialise real OPC UA Part 9 <c>AlarmConditionState</c> nodes from a composition
/// snapshot. For each <b>enabled</b> <see cref="EquipmentScriptedAlarmPlan"/>, register a
/// condition node (keyed by its <see cref="EquipmentScriptedAlarmPlan.ScriptedAlarmId"/>, which
/// is the same id <c>OpcUaPublishActor.AlarmStateUpdate</c> targets) under its equipment folder.
/// Disabled alarms are skipped — they expose no node. Must run AFTER
/// <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
/// <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
/// </summary>
/// <param name="composition">The composition result containing the scripted alarms to materialise.</param>
public void MaterialiseScriptedAlarms(AddressSpaceComposition composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.EquipmentScriptedAlarms.Count == 0) return;
var materialised = 0;
foreach (var alarm in composition.EquipmentScriptedAlarms)
{
if (!alarm.Enabled) continue;
SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false);
materialised++;
}
_logger.LogInformation(
"AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment})",
materialised,
composition.EquipmentScriptedAlarms.Where(a => a.Enabled)
.Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count());
}
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
{
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); }
}
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); }
}
// A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
// {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side
// only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags),
// so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild.
// Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current
// or future) makes the override unequal → falls back to a full rebuild (safe default).
private static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
(d.Previous with
{
Expression = d.Current.Expression,
DependencyRefs = d.Current.DependencyRefs,
Historize = d.Current.Historize,
}).Equals(d.Current);
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
// override-unequal default also covers any future field.
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
// AddressSpaceComposer.ExtractTagFullName falls back to the RAW TagConfig blob as FullName, so a DataType/
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
private static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
d.Previous.Alarm is null && d.Current.Alarm is null &&
(d.Previous with
{
Writable = d.Current.Writable,
IsHistorized = d.Current.IsHistorized,
HistorianTagname = d.Current.HistorianTagname,
DataType = d.Current.DataType,
IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength,
}).Equals(d.Current);
/// <summary>The "no-event" condition state written to a removed equipment / alarm node before the
/// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
/// Drives Retain to false so a removed condition stops replaying on ConditionRefresh.</summary>
private static readonly AlarmConditionSnapshot RemovedConditionState = new(
Active: false,
Acknowledged: true,
Confirmed: true,
Enabled: true,
Shelving: AlarmShelvingKind.Unshelved,
Severity: 0,
Message: string.Empty);
private void SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
{
try { _sink.WriteAlarmCondition(nodeId, state, ts); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); }
}
private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative)
{
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); }
}
}
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
/// <para><see cref="RebuildCalled"/> means a rebuild was ATTEMPTED; <see cref="RebuildFailed"/> means the
/// attempt threw (the sink's address space is now stale/partial). <see cref="FailedNodes"/> counts
/// swallowed per-node sink failures in <see cref="AddressSpaceApplier.Apply"/>'s own passes (the removal
/// alarm-condition writes). The <c>Materialise*</c> passes report their own failed-node tallies via their
/// <c>int</c> returns — the publish actor sums both channels so a degraded apply is operator-visible
/// (Error log + <c>otopcua.opcua.apply.failed</c> meter) instead of reported as optimistic success
/// (archreview 01/S-1). New trailing fields are defaulted so every existing construction compiles.</para></summary>
public sealed record AddressSpaceApplyOutcome(
int RemovedNodes,
int AddedNodes,
int ChangedNodes,
bool RebuildCalled,
bool RebuildFailed = false,
int FailedNodes = 0);