2da58d0360
v2-ci / unit-tests (push) Has been cancelled
Behaviour-neutral hop: pin the SDK AggregateManager default to our TreatUncertainAsBad=false (SDK .182 flipped it to true), parity guard now an explicit deliberate-divergence assertion plus a booted-server check; adapt to obsolete FindPredefinedNode(Type)/TraceConfiguration; Microsoft.Extensions. Logging.Abstractions 10.0.7 -> 10.0.8 (SDK floor); stall wire test's late-flush positive control is best-effort (.176 preserves publish queue ordering). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
259 lines
16 KiB
C#
259 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// <see cref="StandardServer"/> subclass that wires in the v2 <see cref="OtOpcUaNodeManager"/>.
|
|
/// Exposes the live node manager after start so callers (<see cref="OpcUaApplicationHost"/>,
|
|
/// the fused Host's DI binding) can wrap it in a <see cref="SdkAddressSpaceSink"/> and hand
|
|
/// it to <c>OpcUaPublishActor</c>.
|
|
/// </summary>
|
|
public sealed class OtOpcUaSdkServer : StandardServer
|
|
{
|
|
private readonly ILogger<OtOpcUaNodeManager>? _nodeManagerLogger;
|
|
private OtOpcUaNodeManager? _otOpcUaNodeManager;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="OtOpcUaSdkServer"/> class.</summary>
|
|
/// <param name="nodeManagerLogger">Logger handed to the created <see cref="OtOpcUaNodeManager"/>;
|
|
/// <c>null</c> leaves it on a no-op logger.</param>
|
|
public OtOpcUaSdkServer(ILogger<OtOpcUaNodeManager>? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
|
|
|
|
/// <summary>
|
|
/// The client's remote IP endpoint for the secure channel <paramref name="secureChannelId"/>, or null when it
|
|
/// cannot be resolved (lmxopcua#751). Best effort — see <see cref="SecureChannelRemoteEndpoint"/>; the SDK
|
|
/// exposes the listeners only to a subclass, which is why this lives here.
|
|
/// </summary>
|
|
/// <param name="secureChannelId">The global secure channel id a request arrived on.</param>
|
|
/// <returns>The remote endpoint, or null.</returns>
|
|
public System.Net.IPEndPoint? TryResolveRemoteEndpoint(string? secureChannelId) =>
|
|
SecureChannelRemoteEndpoint.Resolve(TransportListeners, secureChannelId);
|
|
|
|
/// <summary>The custom node manager once <c>StartAsync</c> has called
|
|
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
|
|
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
|
|
|
|
/// <summary>
|
|
/// Wire the reverse-path sink for inbound Part 9 alarm method calls onto the created
|
|
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with a non-blocking
|
|
/// <c>mediator.Tell</c> that publishes each <see cref="AlarmCommand"/> onto the
|
|
/// <c>alarm-commands</c> DistributedPubSub topic. No-op (returns <c>false</c>) when the node
|
|
/// manager has not been created yet, so the caller can detect a too-early call.
|
|
/// </summary>
|
|
/// <param name="router">The router invoked by the condition handlers once the <c>AlarmAck</c>
|
|
/// gate passes; may be <c>null</c> to clear it.</param>
|
|
/// <returns><c>true</c> when the router was set on a live node manager; <c>false</c> when no node
|
|
/// manager exists yet.</returns>
|
|
public bool SetAlarmCommandRouter(Action<AlarmCommand>? router)
|
|
{
|
|
if (_otOpcUaNodeManager is null) return false;
|
|
_otOpcUaNodeManager.AlarmCommandRouter = router;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the reverse-path router for inbound Part 9 Acknowledge of a NATIVE (driver-fed, e.g. Galaxy)
|
|
/// condition onto the created <see cref="OtOpcUaNodeManager"/>. The host calls this after start with a
|
|
/// non-blocking router that Tells a <c>DriverHostActor.RouteNativeAlarmAck</c> into the local
|
|
/// <c>DriverHostActor</c>, 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 <see cref="SetAlarmCommandRouter"/> path.
|
|
/// Passing <c>null</c> clears it. No-op (returns <c>false</c>) when the node manager has not been
|
|
/// created yet, so the caller can detect a too-early call (mirrors <see cref="SetAlarmCommandRouter"/>).
|
|
/// </summary>
|
|
/// <param name="router">The router invoked by the native condition's Acknowledge handler once the
|
|
/// <c>AlarmAck</c> gate passes; may be <c>null</c> to clear it.</param>
|
|
/// <returns><c>true</c> when the router was set on a live node manager; <c>false</c> when no node
|
|
/// manager exists yet.</returns>
|
|
public bool SetNativeAlarmAckRouter(Action<NativeAlarmAck>? router)
|
|
{
|
|
if (_otOpcUaNodeManager is null) return false;
|
|
_otOpcUaNodeManager.NativeAlarmAckRouter = router;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the redundancy verdict that decides whether this node accepts an OPC UA Acknowledge
|
|
/// of a NATIVE condition (<see cref="OtOpcUaNodeManager.NativeAlarmAckPrimaryGate"/>); on any other node
|
|
/// the ack is refused with <c>BadInvalidState</c> instead of being applied locally and reverted by the
|
|
/// driver's next transition. Passing <c>null</c> clears it (allow). No-op (returns <c>false</c>) when the
|
|
/// node manager has not been created yet (mirrors <see cref="SetNativeAlarmAckRouter"/>).
|
|
/// </summary>
|
|
/// <param name="isPrimary">A cheap, non-blocking read of the data-plane Primary verdict; null to allow.</param>
|
|
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
|
|
public bool SetNativeAlarmAckPrimaryGate(Func<bool>? isPrimary)
|
|
{
|
|
if (_otOpcUaNodeManager is null) return false;
|
|
_otOpcUaNodeManager.NativeAlarmAckPrimaryGate = isPrimary;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the synchronous native-ack pre-check (<see cref="OtOpcUaNodeManager.NativeAlarmAckPreflight"/>,
|
|
/// lmxopcua#760). Passing <c>null</c> clears it. No-op (returns <c>false</c>) when the node manager has not
|
|
/// been created yet (mirrors <see cref="SetNativeAlarmAckRouter"/>).
|
|
/// </summary>
|
|
/// <param name="preflight">A lock-free read returning the refusal reason, or null to proceed.</param>
|
|
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
|
|
public bool SetNativeAlarmAckPreflight(Func<NativeAlarmAck, string?>? preflight)
|
|
{
|
|
if (_otOpcUaNodeManager is null) return false;
|
|
_otOpcUaNodeManager.NativeAlarmAckPreflight = preflight;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the redundancy verdict that decides whether this node serves an OPC UA HistoryUpdate
|
|
/// (<see cref="OtOpcUaNodeManager.HistoryUpdatePrimaryGate"/>); on any other node every authorized node of a
|
|
/// HistoryUpdate is refused <c>BadInvalidState</c>. Passing <c>null</c> clears it (allow). No-op (returns
|
|
/// <c>false</c>) when the node manager has not been created yet (mirrors <see cref="SetNativeAlarmAckPrimaryGate"/>).
|
|
/// </summary>
|
|
/// <param name="isPrimary">A cheap, non-blocking read of the data-plane Primary verdict; null to allow.</param>
|
|
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
|
|
public bool SetHistoryUpdatePrimaryGate(Func<bool>? isPrimary)
|
|
{
|
|
if (_otOpcUaNodeManager is null) return false;
|
|
_otOpcUaNodeManager.HistoryUpdatePrimaryGate = isPrimary;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the reverse-path gateway for inbound operator writes to writable equipment-tag nodes onto the
|
|
/// created <see cref="OtOpcUaNodeManager"/>. The host calls this after start with an
|
|
/// <c>ActorNodeWriteGateway</c> whose <c>WriteAsync</c> kicks off a bounded <c>Ask</c> of the local
|
|
/// <c>DriverHostActor</c> (<c>RouteNodeWrite</c>); the node manager calls it fire-and-forget and uses
|
|
/// the resolved <c>NodeWriteOutcome</c> to self-correct the node on a failed device write (mirrors
|
|
/// <see cref="SetAlarmCommandRouter"/>). Passing <c>null</c> restores the
|
|
/// <c>NullOpcUaNodeWriteGateway</c> default (writes unavailable). No-op (returns <c>false</c>) when the
|
|
/// node manager has not been created yet, so the caller can detect a too-early call.
|
|
/// </summary>
|
|
/// <param name="gateway">The gateway invoked by the writable node's <c>OnWriteValue</c> handler once the
|
|
/// <c>WriteOperate</c> gate passes; may be <c>null</c> to restore the Null default.</param>
|
|
/// <returns><c>true</c> when the gateway was set on a live node manager; <c>false</c> when no node
|
|
/// manager exists yet.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the server-side HistoryRead backend (the <see cref="IHistorianDataSource"/> the node
|
|
/// manager's HistoryRead overrides block-bridge to) onto the created
|
|
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with the DI-resolved source —
|
|
/// the <c>NullHistorianDataSource</c> default (GoodNoData-empty reads) or the configured HistorianGateway
|
|
/// read client. Passing <c>null</c> restores the Null default (the property setter null-coalesces),
|
|
/// i.e. "no historian". No-op (returns <c>false</c>) when the node manager has not been created yet,
|
|
/// so the caller can detect a too-early call (mirrors <see cref="SetNodeWriteGateway"/>).
|
|
/// </summary>
|
|
/// <param name="source">The read backend invoked by the node manager's HistoryRead overrides; may be
|
|
/// <c>null</c> to restore the Null default (GoodNoData-empty reads).</param>
|
|
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
|
|
/// manager exists yet.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the optional HistoryUpdate backend (the <see cref="IHistorianDataUpdater"/> the node manager's
|
|
/// HistoryUpdate overrides block-bridge to) onto the created <see cref="OtOpcUaNodeManager"/>. Mirrors
|
|
/// <see cref="SetHistorianDataSource"/>; <c>null</c> means "this backend cannot modify history".
|
|
/// <para>
|
|
/// ⚠️ Call this BEFORE any node materialises: whether an updater is wired decides the
|
|
/// <c>HistoryWrite</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="updater">The update backend, or <c>null</c> for none.</param>
|
|
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
|
|
public bool SetHistorianDataUpdater(IHistorianDataUpdater? updater)
|
|
{
|
|
if (_otOpcUaNodeManager is null) return false;
|
|
_otOpcUaNodeManager.HistorianDataUpdater = updater;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the node's data-plane authorization inputs onto the created
|
|
/// <see cref="OtOpcUaNodeManager"/> — the cluster it serves, the current auth generation, and the
|
|
/// ACL grants. The host calls this after start with the DI-resolved
|
|
/// <see cref="IDataPlaneAuthoritySource"/>, which the artifact-apply path refreshes on every
|
|
/// deploy. Passing <c>null</c> restores the empty source (authorizes nothing). No-op (returns
|
|
/// <c>false</c>) when the node manager has not been created yet, so the caller can detect a
|
|
/// too-early call (mirrors <see cref="SetHistorianDataSource"/>).
|
|
/// </summary>
|
|
/// <param name="source">The node's data-plane authorization inputs; may be <c>null</c> to restore the
|
|
/// empty default.</param>
|
|
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
|
|
/// manager exists yet.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire the node-scoped ACL gate onto the created <see cref="OtOpcUaNodeManager"/>.
|
|
/// The host calls this after start; passing <c>null</c> detaches it, which returns the write and
|
|
/// alarm seams to their server-wide role checks and leaves HistoryRead ungated. No-op (returns
|
|
/// <c>false</c>) when the node manager has not been created yet, mirroring
|
|
/// <see cref="SetDataPlaneAuthority"/>.
|
|
/// </summary>
|
|
/// <param name="authorizer">The gate, or <c>null</c> to detach.</param>
|
|
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when none exists yet.</returns>
|
|
public bool SetDataPlaneAuthorizer(DataPlaneAuthorizer? authorizer)
|
|
{
|
|
if (_otOpcUaNodeManager is null) return false;
|
|
_otOpcUaNodeManager.Authorizer = authorizer;
|
|
return true;
|
|
}
|
|
|
|
// 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 SetHistorianDataSource
|
|
// re-syncs to the configured backend's set right after start.
|
|
/// <inheritdoc />
|
|
protected override AggregateManager CreateAggregateManager(
|
|
IServerInternal server, ApplicationConfiguration configuration)
|
|
{
|
|
var manager = base.CreateAggregateManager(server, configuration);
|
|
// Pin the SDK manager's default AggregateConfiguration (what live AggregateFilter subscriptions compute under
|
|
// and what the server advertises) to OUR defaults. Opc.Ua.Server 1.5.378.182 flipped the SDK default of
|
|
// TreatUncertainAsBad to true; history-read aggregates (Sql.Common evaluators) are fixed at false, so the live
|
|
// and historical paths must agree and stay behaviour-neutral across the SDK hop (lmxopcua#782).
|
|
var defaults = manager.GetDefaultConfiguration(null) ?? new AggregateConfiguration();
|
|
defaults.TreatUncertainAsBad = ServerAggregateConfiguration.TreatUncertainAsBad;
|
|
defaults.PercentDataBad = ServerAggregateConfiguration.PercentDataBad;
|
|
defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood;
|
|
defaults.UseSlopedExtrapolation = ServerAggregateConfiguration.UseSlopedExtrapolation;
|
|
manager.SetDefaultConfiguration(defaults);
|
|
_otOpcUaNodeManager?.SyncAdvertisedAggregates();
|
|
return manager;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override MasterNodeManager CreateMasterNodeManager(
|
|
IServerInternal server, ApplicationConfiguration configuration)
|
|
{
|
|
_otOpcUaNodeManager = new OtOpcUaNodeManager(server, configuration, _nodeManagerLogger);
|
|
return new MasterNodeManager(server, configuration, dynamicNamespaceUri: null, _otOpcUaNodeManager);
|
|
}
|
|
}
|