namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
///
/// Abstraction over the OPC UA SDK's address space. OpcUaPublishActor consumes this
/// so the Runtime project doesn't reference Opc.Ua.Server directly — production
/// binds a real SDK-backed sink in the fused Host's wiring, dev/Mac binds the
/// no-op.
///
public interface IOpcUaAddressSpaceSink
{
/// Write a Variable node's current value + status code + source timestamp.
/// The OPC UA node ID of the variable.
/// The value to write.
/// The full OPC UA status code, sub-code included. Passed to the SDK verbatim
/// (#519): a driver's reaches the client as
/// BadNoCommunication, not as a generic . Use
/// ' named constants rather than a literal.
/// The source timestamp in UTC.
/// Which of the two v3 namespaces ( device tree or
/// equipment tree) the lives in — the sink
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.
void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// Write a batch of Variable values. Semantically identical to calling
/// per item; an SDK-backed sink overrides this to take its internal
/// lock ONCE per batch instead of once per value (the hot-path win). The default implementation
/// loops so existing sinks and fakes behave unchanged.
/// A batching implementation MUST isolate per item — one bad item may not drop the tail of the
/// batch — and report the aggregate as a ; see that type for why.
/// The default loop above predates the contract and is only used by fakes / the no-op sink.
/// The value writes to apply, in order.
/// A batching implementation throws this when some — but not
/// necessarily all — items failed; every other item has been applied.
void WriteValues(IReadOnlyList values)
{
ArgumentNullException.ThrowIfNull(values);
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
WriteValue(v.NodeId, v.Value, v.StatusCode, v.SourceTimestampUtc, v.Realm);
}
}
/// Batched status-only write: each node keeps its CURRENT value and only its
/// StatusCode + SourceTimestamp change. This is the comms-loss annotation for value variables
/// (archreview 03/S15) — a driver that has stopped communicating leaves the node holding its last
/// known value (the Part 8 convention SCADA clients expect) under a
/// status, rather than nulling it.
/// A sink that HOLDS the node's value must retain it — the SDK-backed sink does, because the
/// node itself is where the value lives. This DEFAULT implementation cannot: the host holds no value
/// cache (v3's single-source design), so it degrades to with a null value,
/// which is the honest statement for a value-less sink and keeps every fake compiling unchanged.
/// Same per-item isolation contract as . An unknown or
/// unmaterialised NodeId is a no-op, never a create: a status carries nothing to create a node
/// from, and the caller's index can legitimately name a node a surgical remove has just dropped.
/// The status writes to apply, in order.
/// A batching implementation throws this when some — but not
/// necessarily all — items failed; every other item has been applied.
void WriteStatuses(IReadOnlyList statuses)
{
ArgumentNullException.ThrowIfNull(statuses);
for (var i = 0; i < statuses.Count; i++)
{
var s = statuses[i];
WriteValue(s.NodeId, null, s.StatusCode, s.SourceTimestampUtc, s.Realm);
}
}
/// Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for via ,
/// this projects the whole
/// (Enabled/Active/Acked/Confirmed/Shelving/Severity/Message) onto it and recomputes Retain;
/// otherwise it falls back to the legacy two-element [Active, Acknowledged] placeholder
/// variable. A materialised condition also fires a Part 9 condition event on each transition (T16)
/// so subscribed clients receive the alarm event, not just the changed attributes.
/// The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).
/// The full condition state to project onto the node.
/// The source timestamp in UTC.
/// The namespace realm lives in (must match the realm
/// the condition was materialised under).
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// #477 — annotate a materialised condition's source-data quality OUT OF BAND from any alarm
/// transition (used by the driver-connectivity path: comms lost → ,
/// restored → ). Sets ONLY the condition's Quality — never
/// Active/Acked/Severity/Retain (a comms-lost active alarm stays active) — and fires one Part 9 event
/// only on a change to the status code itself — since #519 that includes a move between two Bad
/// sub-codes, which genuinely changes what a subscriber reads. A no-op for an unmaterialised /
/// non-condition node.
/// The condition node id (RawPath for a native alarm).
/// The source-data status code to annotate, sub-code included.
/// The connectivity transition timestamp in UTC.
/// The namespace realm the condition was materialised under.
void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
///
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
/// can browse it as a proper condition (with basic Active/Ack state). The node id equals the
/// alarm node id (the ScriptedAlarmId) so subsequent calls update
/// it. Used by AddressSpaceApplier.MaterialiseScriptedAlarms. Idempotent.
///
/// The alarm node ID (== ScriptedAlarmId); becomes the condition's NodeId.
/// The equipment folder node ID the condition parents under.
/// Human-readable condition name (BrowseName / DisplayName / Message).
/// Domain alarm type — mapped to the SDK condition subtype by the sink.
/// Domain severity (OPC UA 1..1000 scale); mapped to the SDK severity buckets.
/// When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.
/// The namespace realm the condition (and its parent folder )
/// live in.
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
///
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
/// (, materialised at the raw tag via
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
/// the condition's single ReportEvent fans one event to every referencing equipment root
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
/// (alarm.AddNotifier(isInverse:true, folder) + folder.AddNotifier(isInverse:false, alarm))
/// and promotes the folder to an event notifier. Idempotent (a re-wire of the same pair updates,
/// never duplicates); a missing endpoint is a no-op (logged, never thrown) so a mid-rebuild race
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
/// across redeploys).
///
/// The native alarm condition's node id (== the backing tag's RawPath).
/// The namespace realm the condition lives in (Raw for native alarms).
/// The equipment folder node ids (their s= ids) to wire as
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.
/// The namespace realm the notifier folders live in (Uns for equipment folders).
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
///
/// Ensure a folder node exists under the given parent. Used by AddressSpaceApplier to
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
/// is null the folder is parented under the namespace
/// root. Idempotent: calling twice with the same id is safe.
///
/// The OPC UA node ID for the folder.
/// The parent folder node ID, or null for namespace root.
/// The display name for the folder.
/// The namespace realm the folder (and its ) live in.
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm);
///
/// Ensure a Variable node exists at , parented under
/// (or the namespace root when null). Created with
/// Bad quality + null value; subsequent calls update both.
/// Used by AddressSpaceApplier to materialise equipment-namespace tags ahead of any
/// driver-side subscribe so OPC UA clients can browse them. Idempotent.
///
/// The OPC UA node ID for the variable.
/// The parent folder node ID, or null for namespace root.
/// The display name for the variable.
/// OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).
/// When true the node is created CurrentReadWrite (an authored
/// ReadWrite equipment tag); when false it stays CurrentRead (read-only). Non-equipment-tag
/// variables (folders' children, alarm placeholders) always pass false.
/// null ⇒ the variable is not historized; non-null ⇒ create it
/// Historizing with the HistoryRead access bit and register the (already default-resolved)
/// historian tagname.
/// When true the node is created as a 1-D array (ValueRank=OneDimension
/// with ArrayDimensions=[arrayLength]); when false (default) it stays scalar
/// (ValueRank=Scalar). Array elements share the scalar's base —
/// rank + dimensions carry the array-ness.
/// The declared length of the 1-D array when is
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).
/// The namespace realm the variable (and its ) live
/// in. A historized UNS reference node registers the SAME as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.
/// Gitea #610 review (R6): when true and the node is SCALAR, a client write
/// of an array value is refused inline (BadTypeMismatch, nothing dispatched) because the owning
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
/// it). Fixed at creation; an existing node keeps the value it was created with.
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
///
/// Tear down + repopulate the address space. Called by OpcUaPublishActor after a
/// successful deployment apply so the node manager reflects the new config. Idempotent.
///
void RebuildAddressSpace();
///
/// Gitea #641 — mark the END of one address-space apply pass (after , if
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
/// every notifier folder is wired) and DISCARDS any captured state no materialise claimed, so a later
/// deploy that re-adds the same id gets a fresh node rather than a resurrection. OpcUaPublishActor
/// calls it once at the end of every apply, rebuild or not. Idempotent; the default is a no-op.
///
void CompleteAddressSpaceApply()
{
}
///
/// Announce that nodes were added at runtime (discovered-node injection) under
/// so subscribed clients refresh their browse
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
///
/// The node under which discovered nodes were added.
/// The namespace realm lives in.
void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm);
///
/// Add an OPC UA reference from an already-materialised source node to an already-materialised
/// target node, both realm-qualified. Used by AddressSpaceApplier to link each UNS reference
/// Variable (source, ) to its backing Raw node (target,
/// ) with an Organizes edge, so the UNS variable
/// browse-resolves to its raw source (and the raw node back via the inverse). The edge is wired
/// bidirectionally (forward on the source, inverse on the target) so browse resolves both ways.
/// Idempotent (an existing edge is not duplicated); a missing endpoint is a no-op
/// (logged, never thrown) so a mid-rebuild race can't fault a deploy.
///
/// The source node's s= id (the referencing node — e.g. the UNS variable).
/// The namespace realm lives in.
/// The target node's s= id (the referenced node — e.g. the backing Raw node).
/// The namespace realm lives in.
/// The hierarchical reference type name — Organizes (default),
/// HasComponent, or HasProperty; unknown names fall back to Organizes.
void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
string targetNodeId, AddressSpaceRealm targetRealm,
string referenceType = "Organizes");
}
/// One value write within a batch.
/// Same semantics per field as (#519: status verbatim).
public readonly record struct ValueWrite(
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
/// One status-only write within a batch: the
/// node's current VALUE is retained and only +
/// change.
/// The OPC UA node id of the variable, within .
/// The full status code, sub-code included, passed to the SDK verbatim (#519).
/// The source timestamp in UTC.
/// Which of the two v3 namespaces lives in.
public readonly record struct StatusWrite(
string NodeId, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
/// Aggregate outcome of a partially-failed batch:
/// every non-failing item HAS been applied; this carries what the caller needs to log and meter honestly.
/// A batched sink must NOT abandon the tail of a batch when one item throws: the poll engine
/// updates its last-value cache BEFORE raising, so a dropped item is never re-raised until that tag's value
/// changes again — the node would serve a stale value indefinitely. Hence the sink isolates per item and
/// reports the aggregate here rather than letting the first failure escape.
/// The NodeId of the first item that failed (empty when that item's own
/// NodeId was null/empty — the validation failure this type most often reports).
/// How many items of the batch failed.
/// How many items of the batch were applied.
/// The exception the first failing item produced; the inner exception.
public sealed class ValueWriteBatchException(string firstFailedNodeId, int failedCount, int succeededCount, Exception firstFailure)
: Exception($"WriteValues batch: {failedCount} of {failedCount + succeededCount} items failed (first: '{firstFailedNodeId}')", firstFailure)
{
/// Gets the NodeId of the first item that failed.
public string FirstFailedNodeId { get; } = firstFailedNodeId;
/// Gets the number of items of the batch that failed.
public int FailedCount { get; } = failedCount;
/// Gets the number of items of the batch that were successfully applied.
public int SucceededCount { get; } = succeededCount;
}
/// No-op sink. Bound by default so the actors are safe to run in dev / Mac /
/// integration tests without a real SDK behind them.
public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
{
public static readonly NullOpcUaAddressSpaceSink Instance = new();
private NullOpcUaAddressSpaceSink() { }
///
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
///
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
///
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
///
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
///
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
///
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
///
public void RebuildAddressSpace() { }
///
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
///
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}