Merge branch 'master' into b626-626

This commit is contained in:
Joseph Doherty
2026-10-08 15:32:51 -04:00
23 changed files with 1747 additions and 48 deletions
@@ -185,6 +185,61 @@
</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.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 after the failover list.</div>
</div>
<div class="col-md-6">
<label class="form-label">Discovery server ApplicationUri</label>
<InputText @bind-Value="_form.OpcUa.DiscoveryServerUri" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
<div class="form-text">Required when the discovery server lists more than one server.</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
@@ -318,6 +373,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 RejectUnknownRevocationStatus { get; set; }
public string? IssuerStorePath { get; set; }
public string? DiscoveryUrl { get; set; }
public string? DiscoveryServerUri { get; set; }
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
@@ -343,6 +410,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,
RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
DiscoveryUrl = r.DiscoveryUrl,
DiscoveryServerUri = r.DiscoveryServerUri,
_unsMappingTable = r.UnsMappingTable,
};
@@ -371,6 +447,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),
CertificateValidation = new OpcUaCertificateValidationOptions
{
RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
},
DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri,
};
}
}
@@ -1243,7 +1243,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
UnwireAlarmNotifiers(conditionKey);
existing.Parent?.RemoveChild(existing);
PredefinedNodes?.Remove(existing.NodeId);
ForgetPredefinedSubtree(existing);
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
// dropped instance's back here rather than leave it tracked until the next full reconcile.
if (_allEventsMonitoredConditions.Remove(existing))
@@ -2499,6 +2499,89 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_ => new AlarmConditionState(parent),
};
/// <summary>
/// Gitea #629 — the <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
/// the shelve methods by. <see cref="New"/> records them as the methods' <c>MethodDeclarationId</c>
/// (the SDK's own is the <c>ShelvedStateMachineType</c>'s), so calls by these ids keep resolving after
/// the methods move to instance-scoped NodeIds. Calls by the <c>ShelvedStateMachineType_*</c> ids are
/// not supported (pinned by <c>ConditionChildNodeIdTests</c>).
/// </summary>
private static readonly HashSet<NodeId> ShelvingMethodDeclarationIds =
[
MethodIds.AlarmConditionType_ShelvingState_TimedShelve,
MethodIds.AlarmConditionType_ShelvingState_OneShotShelve,
MethodIds.AlarmConditionType_ShelvingState_Unshelve,
];
/// <summary>
/// Gitea #629 — the <see cref="INodeIdFactory"/> hook <c>NodeState.Create(…, assignNodeIds: true)</c> calls
/// for the node being created and then, top-down, for every child it built from the type's definition.
/// A node that already carries an id in one of our namespaces (the condition root, whose id
/// <see cref="MaterialiseAlarmCondition"/> passes to <c>Create</c>) keeps it; a child is given
/// <c>{parent id}/{BrowseName}</c> in its parent's namespace.
/// </summary>
/// <remarks>
/// <para>
/// The SDK default (<c>CustomNodeManager2.New</c>) returns the node's CURRENT id, which for a child is
/// the condition type's instance-declaration id (<c>i=9189</c> for <c>ShelvingState/UnshelveTime</c>) or
/// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a
/// TranslateBrowsePathsToNodeIds of any child path returned <c>BadNoMatch</c>, and a Read of a browsed
/// child id either hit <c>BadNodeIdUnknown</c> or was served by the core node manager from the TYPE
/// (UnshelveTime read <c>0</c> on a condition timed-shelved for a minute). Event delivery and the Part 9
/// methods were unaffected — events copy the instance's fields and the SDK resolves a method by its
/// declaration id.
/// </para>
/// <para>
/// The derived id is deterministic and unique per condition: the parent part is the condition's own
/// id (a RawPath in the raw namespace, a ScriptedAlarmId in the UNS namespace) and sibling BrowseNames
/// are unique. It cannot collide with a raw node — a condition's RawPath is a tag, a leaf, so nothing
/// else is ever authored beneath it — and the namespace keeps the two realms apart. It is also
/// slash-joined on purpose: <c>DataPlaneAuthorizer.BuildScope</c> splits the identifier on <c>/</c>,
/// so a child's scope is its condition's scope plus the browse path, and the permission trie's
/// downward inheritance governs a child exactly as it governs its condition.
/// </para>
/// </remarks>
/// <param name="context">The system context of the create.</param>
/// <param name="node">The node being assigned an id.</param>
/// <returns>The id the node is to carry.</returns>
public override NodeId New(ISystemContext context, NodeState node)
{
if (OwnsNamespace(node.NodeId)) return node.NodeId;
if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId }
&& OwnsNamespace(parentId)
&& !QualifiedName.IsNull(node.BrowseName))
{
// A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId =
// e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR
// MethodDeclarationId. The condition's own methods (Acknowledge, …) carry the right declaration id
// already, over an SDK-generated ns=0 NodeId. The ShelvingState methods do NOT: their declaration id
// is the ShelvedStateMachineType's (i=2948), and they were found only because their NodeId WAS the
// AlarmConditionType instance declaration every client uses. Adopt that id as the declaration before
// it is replaced, or every shelve/unshelve call fails BadMethodInvalid. A fixed set, deliberately: a
// lookup in the standard address space would take other node managers' locks while we hold Lock.
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
method.MethodDeclarationId = method.NodeId;
return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
}
return node.NodeId;
}
/// <summary>
/// Gitea #629 — drops a removed condition AND every descendant from <c>PredefinedNodes</c>.
/// <c>AddPredefinedNode</c> indexes the whole subtree, so removing only the root would leave the
/// condition's (now instance-scoped, hence reachable) children answering a direct Read with the dead
/// condition's last value. MUST be called under <c>Lock</c>.
/// </summary>
/// <param name="node">The node whose subtree is forgotten.</param>
private void ForgetPredefinedSubtree(NodeState node)
{
PredefinedNodes?.Remove(node.NodeId);
var children = new List<BaseInstanceState>();
node.GetChildren(SystemContext, children);
foreach (var child in children) ForgetPredefinedSubtree(child);
}
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
/// folder is a subscribable event source linked under the Server object (Server-object delivery itself is
@@ -3123,7 +3206,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
PredefinedNodes?.Remove(alarm.NodeId);
ForgetPredefinedSubtree(alarm);
}
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
@@ -3249,7 +3332,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// this they would keep a dangling inverse-notifier entry to the removed condition.
UnwireAlarmNotifiers(key);
condition.Parent?.RemoveChild(condition);
PredefinedNodes?.Remove(condition.NodeId);
ForgetPredefinedSubtree(condition);
_nativeAlarmNodeIds.Remove(key);
ReconcileAllEventsMonitoring();
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
@@ -3302,7 +3385,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (_alarmConditions.TryRemove(id, out var c))
{
c.Parent?.RemoveChild(c);
PredefinedNodes?.Remove(c.NodeId);
ForgetPredefinedSubtree(c);
}
_nativeAlarmNodeIds.Remove(id);
}
@@ -5169,6 +5252,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (HistoryAggregateNotSupportedException ex)
{
// The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
// not a logged server fault.
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
}
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
@@ -5424,6 +5515,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
if (aggregateNodeId == ObjectIds.AggregateFunction_Interpolative) return HistoryAggregateType.Interpolative;
if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage) return HistoryAggregateType.TimeAverage;
if (aggregateNodeId == ObjectIds.AggregateFunction_Start) return HistoryAggregateType.Start;
if (aggregateNodeId == ObjectIds.AggregateFunction_End) return HistoryAggregateType.End;
if (aggregateNodeId == ObjectIds.AggregateFunction_Delta) return HistoryAggregateType.Delta;
if (aggregateNodeId == ObjectIds.AggregateFunction_Range) return HistoryAggregateType.Range;
if (aggregateNodeId == ObjectIds.AggregateFunction_MinimumActualTime) return HistoryAggregateType.MinimumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_MaximumActualTime) return HistoryAggregateType.MaximumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_StartBound) return HistoryAggregateType.StartBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_EndBound) return HistoryAggregateType.EndBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_DeltaBounds) return HistoryAggregateType.DeltaBounds;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationGood) return HistoryAggregateType.DurationGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationBad) return HistoryAggregateType.DurationBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentGood) return HistoryAggregateType.PercentGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentBad) return HistoryAggregateType.PercentBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_WorstQuality) return HistoryAggregateType.WorstQuality;
if (aggregateNodeId == ObjectIds.AggregateFunction_NumberOfTransitions) return HistoryAggregateType.NumberOfTransitions;
if (aggregateNodeId == ObjectIds.AggregateFunction_AnnotationCount) return HistoryAggregateType.AnnotationCount;
if (aggregateNodeId == ObjectIds.AggregateFunction_StandardDeviationPopulation) return HistoryAggregateType.StandardDeviationPopulation;
if (aggregateNodeId == ObjectIds.AggregateFunction_VariancePopulation) return HistoryAggregateType.VariancePopulation;
return null;
}