feat(opcuaclient): subscription tuning, OperationLimits chunking, diagnostics counters, revocation options, DiscoveryUrl (#273 #275 #276 #277 #278)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:40:20 -04:00
parent 55eac5f68b
commit ba62022e88
12 changed files with 1308 additions and 100 deletions
@@ -185,6 +185,63 @@
</div>
</section>
@* Subscriptions, limits, revocation, discovery (#273/#275/#277/#278) *@
<section class="panel rise mt-3" style="animation-delay:.13s">
<div class="panel-head">Subscriptions, limits &amp; discovery</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Publishing interval override (ms)</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPublishingIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = use the interval the runtime requests per subscription.</div>
</div>
<div class="col-md-3">
<label class="form-label">Keep-alive count</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionKeepAliveCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 10.</div>
</div>
<div class="col-md-3">
<label class="form-label">Lifetime count</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionLifetimeCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1000; at least 3x keep-alive count.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max notifications per publish</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionMaxNotificationsPerPublish" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">0 = server decides.</div>
</div>
<div class="col-md-3">
<label class="form-label">Subscription priority (0-255)</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPriority" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.OpcUa.HonorServerOperationLimits" @bind-Value:after="EmitAsync" class="form-check-input" id="honorOpLimits" />
<label class="form-check-label" for="honorOpLimits">Honor server OperationLimits</label>
</div>
<div class="form-text">Split oversized Read / Write / monitored-item calls.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.OpcUa.RejectUnknownRevocationStatus" @bind-Value:after="EmitAsync" class="form-check-input" id="rejectUnkRevocation" />
<label class="form-check-label" for="rejectUnkRevocation">Reject unknown revocation status</label>
</div>
<div class="form-text">Revoked certificates are always rejected.</div>
</div>
<div class="col-md-6">
<label class="form-label">Issuer store / CRL directory</label>
<InputText @bind-Value="_form.OpcUa.IssuerStorePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
<div class="form-text">CRLs are read from its <code>crl/</code> sub-folder. Blank = default PKI store.</div>
</div>
<div class="col-md-6">
<label class="form-label">Discovery URL (FindServers)</label>
<InputText @bind-Value="_form.OpcUa.DiscoveryUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://lds:4840" />
<div class="form-text">Optional. Discovered endpoints are tried before the failover list.</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
@@ -318,6 +375,18 @@
public int ProbeTimeoutSeconds { get; set; } = 15;
// #273 / #275 / #277 / #278 — without these the typed form silently drops the keys on save (#576).
public int? SubscriptionPublishingIntervalMs { get; set; }
// int, not uint/byte: InputNumber<byte> 500s the page (#505).
public int SubscriptionKeepAliveCount { get; set; } = 10;
public int SubscriptionLifetimeCount { get; set; } = 1000;
public int SubscriptionMaxNotificationsPerPublish { get; set; }
public int SubscriptionPriority { get; set; }
public bool HonorServerOperationLimits { get; set; } = true;
public bool RejectUnknownRevocationStatus { get; set; }
public string? IssuerStorePath { get; set; }
public string? DiscoveryUrl { get; set; }
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
@@ -343,6 +412,15 @@
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
SubscriptionPublishingIntervalMs = r.SubscriptionPublishingInterval is { } spi ? (int)spi.TotalMilliseconds : null,
SubscriptionKeepAliveCount = (int)Math.Min(r.SubscriptionKeepAliveCount, int.MaxValue),
SubscriptionLifetimeCount = (int)Math.Min(r.SubscriptionLifetimeCount, int.MaxValue),
SubscriptionMaxNotificationsPerPublish = (int)Math.Min(r.SubscriptionMaxNotificationsPerPublish, int.MaxValue),
SubscriptionPriority = r.SubscriptionPriority,
HonorServerOperationLimits = r.HonorServerOperationLimits,
RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
DiscoveryUrl = r.DiscoveryUrl,
_unsMappingTable = r.UnsMappingTable,
};
@@ -371,6 +449,19 @@
TargetNamespaceKind = TargetNamespaceKind,
UnsMappingTable = _unsMappingTable,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
SubscriptionPublishingInterval = SubscriptionPublishingIntervalMs is { } ms and > 0
? TimeSpan.FromMilliseconds(ms) : null,
SubscriptionKeepAliveCount = (uint)Math.Max(SubscriptionKeepAliveCount, 0),
SubscriptionLifetimeCount = (uint)Math.Max(SubscriptionLifetimeCount, 0),
SubscriptionMaxNotificationsPerPublish = (uint)Math.Max(SubscriptionMaxNotificationsPerPublish, 0),
SubscriptionPriority = (byte)Math.Clamp(SubscriptionPriority, 0, 255),
HonorServerOperationLimits = HonorServerOperationLimits,
CertificateValidation = new OpcUaCertificateValidationOptions
{
RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
},
DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
};
}
}