Merge branch 'master' into b626-626
This commit is contained in:
+89
@@ -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 & 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user