using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
///
/// Side-effecting orchestrator over . Drives an
/// to materialise the diff between two
/// snapshots:
///
///
/// - RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
/// node id then call RebuildAddressSpace at the end so the sink can
/// actually tear down the OPC UA folders + variables.
/// - AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
/// will repopulate from the persisted artifact). For now we record the work.
/// - ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
/// that lands in F10b will decide between in-place property writes and
/// tear-down + rebuild.
///
///
/// This is the side-effecting layer Task 47 deferred to F14. It stays pure-of-SDK so
/// production binds a real SDK sink, dev/Mac binds ,
/// and tests can capture every call.
///
public sealed class Phase7Applier
{
private readonly IOpcUaAddressSpaceSink _sink;
private readonly ILogger _logger;
/// Initializes a new instance of the Phase7Applier class.
/// The OPC UA address space sink to apply changes to.
/// The logger instance.
public Phase7Applier(IOpcUaAddressSpaceSink sink, ILogger logger)
{
ArgumentNullException.ThrowIfNull(sink);
ArgumentNullException.ThrowIfNull(logger);
_sink = sink;
_logger = logger;
}
///
/// Apply to the sink. Returns a summary of what was applied so
/// callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
///
/// The plan to apply.
/// A Phase7ApplyOutcome summarizing the applied changes.
public Phase7ApplyOutcome Apply(Phase7Plan plan)
{
ArgumentNullException.ThrowIfNull(plan);
if (plan.IsEmpty)
{
_logger.LogDebug("Phase7Applier: plan is empty; skipping sink writes");
return new Phase7ApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false);
}
var ts = DateTime.UtcNow;
var removedCount = 0;
foreach (var eq in plan.RemovedEquipment)
{
SafeWriteAlarmState(eq.EquipmentId, active: false, acknowledged: false, ts);
removedCount++;
}
foreach (var alarm in plan.RemovedAlarms)
{
SafeWriteAlarmState(alarm.ScriptedAlarmId, active: false, acknowledged: false, ts);
removedCount++;
}
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.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.
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;
if (needsRebuild)
{
try
{
_sink.RebuildAddressSpace();
}
catch (Exception ex)
{
_logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw");
}
}
_logger.LogInformation(
"Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
addedCount, removedCount, changedCount, needsRebuild);
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
}
///
/// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// composition snapshot. Called by OpcUaPublishActor after a rebuild so OPC UA
/// clients browsing the server see proper folder structure instead of flat tag ids.
/// Idempotent: each EnsureFolder call returns the existing folder if already
/// present, so re-applies are cheap.
///
/// The composition result containing the hierarchy to materialise.
public void MaterialiseHierarchy(Phase7CompositionResult 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(
"Phase7Applier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})",
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
}
///
/// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot:
/// for each , ensure its FolderPath segment exists (a folder
/// under the namespace root), then ensure a Variable node sits inside that folder for
/// the leaf . Variable starts with BadWaitingForInitialData;
/// the Galaxy driver's OnDataChange path fills the value in once SubscribeBulk lands.
/// Idempotent.
///
/// The composition result containing the Galaxy tags to materialise.
public void MaterialiseGalaxyTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.GalaxyTags.Count == 0) return;
// Folders first — each distinct FolderPath becomes one folder under the root.
var foldersCreated = new HashSet(StringComparer.Ordinal);
foreach (var tag in composition.GalaxyTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
if (!foldersCreated.Add(tag.FolderPath)) continue;
SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath);
}
// Variables: NodeId is "." so it matches the MXAccess ref the
// Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical
// resolution; flat NodeId keeps the address space lookup cheap.
foreach (var tag in composition.GalaxyTags)
{
var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef;
var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath;
SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType);
}
_logger.LogInformation(
"Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})",
composition.GalaxyTags.Count, foldersCreated.Count);
}
///
/// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal
/// analogue of . For each ,
/// ensure its optional FolderPath sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = FullName, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent.
///
/// Task 0 architecture decisions (recorded per the equipment-namespace-structure
/// plan). Decision #1 = A — a sink-based pass, NOT a reuse of
/// EquipmentNodeWalker: no sink-backed IAddressSpaceBuilder adapter exists
/// (GenericDriverNodeManager.CapturingBuilder 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); owns
/// the equipment folders and this pass never re-creates them. The sink's
/// EnsureVariable takes a plain string dataType (not a DriverAttributeInfo).
///
///
/// The composition result containing the equipment tags to materialise.
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(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 ("/"), 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());
}
/// Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment
/// folder so two equipments' identically-named sub-folders never collide.
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); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
}
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType)
{
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
}
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
{
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); }
}
}
/// Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
public sealed record Phase7ApplyOutcome(
int RemovedNodes,
int AddedNodes,
int ChangedNodes,
bool RebuildCalled);