diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 7fc58ac..b010171 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -858,22 +858,24 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d var id = Interlocked.Increment(ref _nextSubscriptionId); var handle = new OpcUaSubscriptionHandle(id); - // Floor the publishing interval at 50ms — OPC UA servers routinely negotiate - // minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation - // bandwidth on every subscription create. - var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50) - ? 50 + // Floor the publishing interval — OPC UA servers routinely negotiate + // minimum-supported intervals up anyway, but sending sub-floor values wastes + // negotiation bandwidth on every subscription create. Floor is configurable via + // OpcUaSubscriptionDefaults.MinPublishingIntervalMs (default 50ms). + var subDefaults = _options.Subscriptions; + var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(subDefaults.MinPublishingIntervalMs) + ? subDefaults.MinPublishingIntervalMs : (int)publishingInterval.TotalMilliseconds; var subscription = new Subscription(telemetry: null!, new SubscriptionOptions { DisplayName = $"opcua-sub-{id}", PublishingInterval = intervalMs, - KeepAliveCount = 10, - LifetimeCount = 1000, - MaxNotificationsPerPublish = 0, + KeepAliveCount = (uint)subDefaults.KeepAliveCount, + LifetimeCount = subDefaults.LifetimeCount, + MaxNotificationsPerPublish = subDefaults.MaxNotificationsPerPublish, PublishingEnabled = true, - Priority = 0, + Priority = subDefaults.Priority, TimestampsToReturn = TimestampsToReturn.Both, }); @@ -975,15 +977,16 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d // match in O(1) without re-parsing on every event. var sourceFilter = new HashSet(sourceNodeIds, StringComparer.Ordinal); + var alarmDefaults = _options.Subscriptions; var subscription = new Subscription(telemetry: null!, new SubscriptionOptions { DisplayName = $"opcua-alarm-sub-{id}", PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes - KeepAliveCount = 10, - LifetimeCount = 1000, - MaxNotificationsPerPublish = 0, + KeepAliveCount = (uint)alarmDefaults.KeepAliveCount, + LifetimeCount = alarmDefaults.LifetimeCount, + MaxNotificationsPerPublish = alarmDefaults.MaxNotificationsPerPublish, PublishingEnabled = true, - Priority = 0, + Priority = alarmDefaults.AlarmsPriority, TimestampsToReturn = TimestampsToReturn.Both, }); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs index 877bd4e..ca0d7b1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs @@ -134,8 +134,58 @@ public sealed class OpcUaClientDriverOptions /// browse forever. /// public int MaxBrowseDepth { get; init; } = 10; + + /// + /// Per-subscription tuning knobs applied when the driver creates data + alarm + /// subscriptions on the upstream session. Defaults preserve the previous hard-coded + /// values so existing deployments see no behaviour change. + /// + public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new(); } +/// +/// Tuning surface for OPC UA subscriptions created by . +/// Lifted from the per-call hard-coded literals so operators can tune publish cadence, +/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver. +/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000, +/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms). +/// +/// +/// Number of consecutive empty publish cycles before the server sends a keep-alive +/// response. Default 10 — high enough to suppress idle traffic, low enough that the +/// client notices a stalled subscription within ~5x the publish interval. +/// +/// +/// Number of consecutive missed publish responses before the server tears down the +/// subscription. Must be ≥3× per OPC UA spec; default 1000 +/// gives ~100 keep-alives of slack which is conservative on flaky networks. +/// +/// +/// Cap on notifications returned per publish response. 0 = unlimited (the OPC UA +/// spec sentinel). Lower this to bound publish-message size on bursty servers. +/// +/// +/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of +/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions. +/// +/// +/// Floor (ms) applied to publishingInterval requests. Sub-floor values are +/// clamped up so wire-side negotiations don't waste round-trips on intervals the server +/// will only round up anyway. Default 50ms. +/// +/// +/// Subscription priority for the alarm subscription (0..255). Higher than +/// by default (1 vs 0) so alarm publishes aren't starved during +/// data-tag bursts. +/// +public sealed record OpcUaSubscriptionDefaults( + int KeepAliveCount = 10, + uint LifetimeCount = 1000, + uint MaxNotificationsPerPublish = 0, + byte Priority = 0, + int MinPublishingIntervalMs = 50, + byte AlarmsPriority = 1); + /// OPC UA message security mode. public enum OpcUaSecurityMode { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs index 02911af..4e08493 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs @@ -67,6 +67,45 @@ public sealed class OpcUaClientDriverScaffoldTests health.LastError.ShouldNotBeNull(); } + [Fact] + public void Default_subscription_tuning_matches_prior_hard_coded_values() + { + // PR #273: lifted hard-coded Subscription parameters into options; defaults MUST + // remain wire-identical so existing deployments see no behaviour change. + var subs = new OpcUaClientDriverOptions().Subscriptions; + subs.KeepAliveCount.ShouldBe(10); + subs.LifetimeCount.ShouldBe(1000u); + subs.MaxNotificationsPerPublish.ShouldBe(0u, "0 = unlimited per OPC UA spec"); + subs.Priority.ShouldBe((byte)0); + subs.MinPublishingIntervalMs.ShouldBe(50); + subs.AlarmsPriority.ShouldBe((byte)1, "alarms get a higher priority than data tags so they aren't starved during bursts"); + } + + [Fact] + public void Subscription_defaults_are_overridable_via_options() + { + // Operators tuning a flaky-network deployment should be able to bump LifetimeCount / + // lower MaxNotificationsPerPublish without recompiling the driver. Verify the record + // is overridable end-to-end. + var opts = new OpcUaClientDriverOptions + { + Subscriptions = new OpcUaSubscriptionDefaults( + KeepAliveCount: 25, + LifetimeCount: 5000u, + MaxNotificationsPerPublish: 200u, + Priority: 7, + MinPublishingIntervalMs: 100, + AlarmsPriority: 9), + }; + + opts.Subscriptions.KeepAliveCount.ShouldBe(25); + opts.Subscriptions.LifetimeCount.ShouldBe(5000u); + opts.Subscriptions.MaxNotificationsPerPublish.ShouldBe(200u); + opts.Subscriptions.Priority.ShouldBe((byte)7); + opts.Subscriptions.MinPublishingIntervalMs.ShouldBe(100); + opts.Subscriptions.AlarmsPriority.ShouldBe((byte)9); + } + [Fact] public async Task Reinitialize_against_unreachable_endpoint_re_throws() {