using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
///
/// subclass that wires in the v2 .
/// Exposes the live node manager after start so callers (,
/// the fused Host's DI binding) can wrap it in a and hand
/// it to OpcUaPublishActor.
///
public sealed class OtOpcUaSdkServer : StandardServer
{
private readonly ILogger? _nodeManagerLogger;
private OtOpcUaNodeManager? _otOpcUaNodeManager;
/// Initializes a new instance of the class.
/// Logger handed to the created ;
/// null leaves it on a no-op logger.
public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
/// The custom node manager once StartAsync has called
/// . Null until the SDK has bootstrapped.
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
///
/// Wire the reverse-path sink for inbound Part 9 alarm method calls onto the created
/// . The host calls this after start with a non-blocking
/// mediator.Tell that publishes each onto the
/// alarm-commands DistributedPubSub topic. No-op (returns false) when the node
/// manager has not been created yet, so the caller can detect a too-early call.
///
/// The router invoked by the condition handlers once the AlarmAck
/// gate passes; may be null to clear it.
/// true when the router was set on a live node manager; false when no node
/// manager exists yet.
public bool SetAlarmCommandRouter(Action? router)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.AlarmCommandRouter = router;
return true;
}
///
/// Wire the reverse-path router for inbound Part 9 Acknowledge of a NATIVE (driver-fed, e.g. Galaxy)
/// condition onto the created . The host calls this after start with a
/// non-blocking router that Tells a DriverHostActor.RouteNativeAlarmAck into the local
/// DriverHostActor, which (Primary-gated) routes the acknowledge to the owning driver child.
/// Native conditions are not owned by the scripted-alarm engine, so the node manager branches their
/// inbound Acknowledge to this seam instead of the scripted path.
/// Passing null clears it. No-op (returns false) when the node manager has not been
/// created yet, so the caller can detect a too-early call (mirrors ).
///
/// The router invoked by the native condition's Acknowledge handler once the
/// AlarmAck gate passes; may be null to clear it.
/// true when the router was set on a live node manager; false when no node
/// manager exists yet.
public bool SetNativeAlarmAckRouter(Action? router)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.NativeAlarmAckRouter = router;
return true;
}
///
/// Gitea #597 — wire the redundancy verdict that decides whether this node accepts an OPC UA Acknowledge
/// of a NATIVE condition (); on any other node
/// the ack is refused with BadInvalidState instead of being applied locally and reverted by the
/// driver's next transition. Passing null clears it (allow). No-op (returns false) when the
/// node manager has not been created yet (mirrors ).
///
/// A cheap, non-blocking read of the data-plane Primary verdict; null to allow.
/// true when set on a live node manager; false when no node manager exists yet.
public bool SetNativeAlarmAckPrimaryGate(Func? isPrimary)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.NativeAlarmAckPrimaryGate = isPrimary;
return true;
}
///
/// Gitea #674 — wire the redundancy verdict that decides whether this node serves an OPC UA HistoryUpdate
/// (); on any other node every authorized node of a
/// HistoryUpdate is refused BadInvalidState. Passing null clears it (allow). No-op (returns
/// false) when the node manager has not been created yet (mirrors ).
///
/// A cheap, non-blocking read of the data-plane Primary verdict; null to allow.
/// true when set on a live node manager; false when no node manager exists yet.
public bool SetHistoryUpdatePrimaryGate(Func? isPrimary)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.HistoryUpdatePrimaryGate = isPrimary;
return true;
}
///
/// Wire the reverse-path gateway for inbound operator writes to writable equipment-tag nodes onto the
/// created . The host calls this after start with an
/// ActorNodeWriteGateway whose WriteAsync kicks off a bounded Ask of the local
/// DriverHostActor (RouteNodeWrite); the node manager calls it fire-and-forget and uses
/// the resolved NodeWriteOutcome to self-correct the node on a failed device write (mirrors
/// ). Passing null restores the
/// NullOpcUaNodeWriteGateway default (writes unavailable). No-op (returns false) when the
/// node manager has not been created yet, so the caller can detect a too-early call.
///
/// The gateway invoked by the writable node's OnWriteValue handler once the
/// WriteOperate gate passes; may be null to restore the Null default.
/// true when the gateway was set on a live node manager; false when no node
/// manager exists yet.
public bool SetNodeWriteGateway(IOpcUaNodeWriteGateway? gateway)
{
if (_otOpcUaNodeManager is null) return false;
// The NodeWriteGateway setter null-coalesces to the Null default, so a null gateway is intentional
// (restores "writes unavailable"); forgive the nullable-in here.
_otOpcUaNodeManager.NodeWriteGateway = gateway!;
return true;
}
///
/// Wire the server-side HistoryRead backend (the the node
/// manager's HistoryRead overrides block-bridge to) onto the created
/// . The host calls this after start with the DI-resolved source —
/// the NullHistorianDataSource default (GoodNoData-empty reads) or the configured HistorianGateway
/// read client. Passing null restores the Null default (the property setter null-coalesces),
/// i.e. "no historian". No-op (returns false) when the node manager has not been created yet,
/// so the caller can detect a too-early call (mirrors ).
///
/// The read backend invoked by the node manager's HistoryRead overrides; may be
/// null to restore the Null default (GoodNoData-empty reads).
/// true when the source was set on a live node manager; false when no node
/// manager exists yet.
public bool SetHistorianDataSource(IHistorianDataSource? source)
{
if (_otOpcUaNodeManager is null) return false;
// The HistorianDataSource setter null-coalesces to the Null default, so a null source is intentional
// (restores GoodNoData-empty reads); forgive the nullable-in here.
_otOpcUaNodeManager.HistorianDataSource = source!;
return true;
}
///
/// Wire the optional HistoryUpdate backend (the the node manager's
/// HistoryUpdate overrides block-bridge to) onto the created . Mirrors
/// ; null means "this backend cannot modify history".
///
/// ⚠️ Call this BEFORE any node materialises: whether an updater is wired decides the
/// HistoryWrite AccessLevel bit at materialise time, and the SDK base admits a node to
/// HistoryUpdate only when that bit is set. Nodes materialised earlier are not retro-updated.
///
///
/// The update backend, or null for none.
/// true when set on a live node manager; false when no node manager exists yet.
public bool SetHistorianDataUpdater(IHistorianDataUpdater? updater)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.HistorianDataUpdater = updater;
return true;
}
///
/// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created
/// — the cluster it serves, the current auth generation, and the
/// ACL grants. The host calls this after start with the DI-resolved
/// , which the artifact-apply path refreshes on every
/// deploy. Passing null restores the empty source (authorizes nothing). No-op (returns
/// false) when the node manager has not been created yet, so the caller can detect a
/// too-early call (mirrors ).
///
/// The node's data-plane authorization inputs; may be null to restore the
/// empty default.
/// true when the source was set on a live node manager; false when no node
/// manager exists yet.
public bool SetDataPlaneAuthority(IDataPlaneAuthoritySource? source)
{
if (_otOpcUaNodeManager is null) return false;
// The DataPlaneAuthority setter null-coalesces to the empty source, so a null is intentional;
// forgive the nullable-in here.
_otOpcUaNodeManager.DataPlaneAuthority = source!;
return true;
}
///
/// Wire the node-scoped ACL gate (#520 stage 5) onto the created .
/// The host calls this after start; passing null detaches it, which returns the write and
/// alarm seams to their server-wide role checks and leaves HistoryRead ungated. No-op (returns
/// false) when the node manager has not been created yet, mirroring
/// .
///
/// The gate, or null to detach.
/// true when set on a live node manager; false when none exists yet.
public bool SetDataPlaneAuthorizer(DataPlaneAuthorizer? authorizer)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.Authorizer = authorizer;
return true;
}
///
/// Gitea #682 — keep the SDK's full set of calculator factories (live-data AggregateFilter subscriptions
/// need them, and ServerCapabilities/AggregateFunctions keeps listing all of them), then trim
/// HistoryServerCapabilities/AggregateFunctions to the node manager's current historian source. At this
/// point that is the Null default (no history aggregates); the Host's
/// re-syncs to the configured backend's set right after start.
///
///
protected override AggregateManager CreateAggregateManager(
IServerInternal server, ApplicationConfiguration configuration)
{
var manager = base.CreateAggregateManager(server, configuration);
_otOpcUaNodeManager?.SyncAdvertisedAggregates();
return manager;
}
///
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
{
_otOpcUaNodeManager = new OtOpcUaNodeManager(server, configuration, _nodeManagerLogger);
return new MasterNodeManager(server, configuration, dynamicNamespaceUri: null, _otOpcUaNodeManager);
}
}