fix(opcua): preserve repeated alarm methods and owned write reverts
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
/// <summary>Preserves the application's repeat-method contract at the SDK's supported validation seam.</summary>
|
||||||
|
internal static class ApplicationAlarmConditions
|
||||||
|
{
|
||||||
|
internal static AlarmConditionState Create(string type, NodeState parent) => type switch
|
||||||
|
{
|
||||||
|
"OffNormalAlarm" => new OffNormal(parent),
|
||||||
|
"DiscreteAlarm" => new Discrete(parent),
|
||||||
|
_ => new Alarm(parent),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ServiceResult CurrentTrunkRepeat(ServiceResult result, AcknowledgeableConditionState condition,
|
||||||
|
ISystemContext context, ByteString eventId, LocalizedText comment, bool acknowledge)
|
||||||
|
{
|
||||||
|
var repeated = acknowledge ? StatusCodes.BadConditionBranchAlreadyAcked : StatusCodes.BadConditionBranchAlreadyConfirmed;
|
||||||
|
var callback = acknowledge ? condition.OnAcknowledge : condition.OnConfirm;
|
||||||
|
if (result.StatusCode != repeated || callback is null || eventId.IsEmpty
|
||||||
|
|| condition.BranchId is not { Value.IsNull: true }
|
||||||
|
|| condition.EventId is not { } current || current.Value != eventId
|
||||||
|
|| !ReferenceEquals(condition.GetEventByEventId(eventId), condition)
|
||||||
|
|| (acknowledge ? condition.AckedState?.Id?.Value : condition.ConfirmedState?.Id?.Value) != true)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// In exact SDK 2.0.1, the Already* guards precede the application callback. Invoke it once
|
||||||
|
// here; every other SDK validation result, superseded EventId and branch retains the base path.
|
||||||
|
try { return callback(context, condition, eventId, comment); }
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
return ServiceResult.Create(error, StatusCodes.BadUnexpectedError,
|
||||||
|
acknowledge ? "Unexpected error acknowledging a Condition." : "Unexpected error confirming a Condition.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OffNormal(NodeState parent) : OffNormalAlarmState(parent)
|
||||||
|
{
|
||||||
|
protected override ServiceResult ProcessBeforeAcknowledge(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||||
|
CurrentTrunkRepeat(base.ProcessBeforeAcknowledge(context, id, comment), this, context, id, comment, true);
|
||||||
|
protected override ServiceResult ProcessBeforeConfirm(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||||
|
CurrentTrunkRepeat(base.ProcessBeforeConfirm(context, id, comment), this, context, id, comment, false);
|
||||||
|
protected override ConditionState CreateBranchInstance() => new OffNormal(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Discrete(NodeState parent) : DiscreteAlarmState(parent)
|
||||||
|
{
|
||||||
|
protected override ServiceResult ProcessBeforeAcknowledge(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||||
|
CurrentTrunkRepeat(base.ProcessBeforeAcknowledge(context, id, comment), this, context, id, comment, true);
|
||||||
|
protected override ServiceResult ProcessBeforeConfirm(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||||
|
CurrentTrunkRepeat(base.ProcessBeforeConfirm(context, id, comment), this, context, id, comment, false);
|
||||||
|
protected override ConditionState CreateBranchInstance() => new Discrete(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Alarm(NodeState parent) : AlarmConditionState(parent)
|
||||||
|
{
|
||||||
|
protected override ServiceResult ProcessBeforeAcknowledge(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||||
|
CurrentTrunkRepeat(base.ProcessBeforeAcknowledge(context, id, comment), this, context, id, comment, true);
|
||||||
|
protected override ServiceResult ProcessBeforeConfirm(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||||
|
CurrentTrunkRepeat(base.ProcessBeforeConfirm(context, id, comment), this, context, id, comment, false);
|
||||||
|
protected override ConditionState CreateBranchInstance() => new Alarm(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,32 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class EquipmentVariableState(NodeState? parent, object ownerLock) : BaseDataVariableState(parent)
|
internal sealed class EquipmentVariableState(NodeState? parent, object ownerLock) : BaseDataVariableState(parent)
|
||||||
{
|
{
|
||||||
|
// Every field below is accessed under ownerLock. Tokens identify client attempts; the mutation
|
||||||
|
// revision detects a driver publication that re-enters the callback before its optimistic commit.
|
||||||
|
private long _nextWriteToken;
|
||||||
|
private long _mutationRevision;
|
||||||
|
private long? _activeWriteToken;
|
||||||
|
private long? _committedWriteToken;
|
||||||
|
|
||||||
|
internal readonly record struct WriteStamp(EquipmentVariableState Owner, long Token);
|
||||||
|
internal WriteStamp? ActiveWriteStamp => _activeWriteToken is { } token ? new(this, token) : null;
|
||||||
|
internal bool OwnsWrite(WriteStamp stamp) => ReferenceEquals(this, stamp.Owner) && _committedWriteToken == stamp.Token;
|
||||||
|
|
||||||
|
internal void InvalidateWriteOwnership()
|
||||||
|
{
|
||||||
|
_mutationRevision++;
|
||||||
|
_committedWriteToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void PublishValue(Variant value, StatusCode status, DateTimeUtc timestamp)
|
||||||
|
{
|
||||||
|
InvalidateWriteOwnership();
|
||||||
|
Value = value;
|
||||||
|
StatusCode = status;
|
||||||
|
Timestamp = timestamp;
|
||||||
|
ChangeMasks |= NodeStateChangeMasks.Value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override ServiceResult WriteValueAttribute(ISystemContext context, NumericRange indexRange,
|
protected override ServiceResult WriteValueAttribute(ISystemContext context, NumericRange indexRange,
|
||||||
Variant value, StatusCode statusCode, DateTimeUtc sourceTimestamp)
|
Variant value, StatusCode statusCode, DateTimeUtc sourceTimestamp)
|
||||||
@@ -33,14 +59,20 @@ internal sealed class EquipmentVariableState(NodeState? parent, object ownerLock
|
|||||||
if ((userAccess & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
if ((userAccess & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = write(context, this, indexRange, default, ref value, ref statusCode, ref sourceTimestamp);
|
var mutationBefore = _mutationRevision;
|
||||||
|
var token = ++_nextWriteToken;
|
||||||
|
var previousAttempt = _activeWriteToken;
|
||||||
|
_activeWriteToken = token;
|
||||||
|
ServiceResult result;
|
||||||
|
try { result = write(context, this, indexRange, default, ref value, ref statusCode, ref sourceTimestamp); }
|
||||||
|
finally { _activeWriteToken = previousAttempt; }
|
||||||
if (ServiceResult.IsBad(result)) return result;
|
if (ServiceResult.IsBad(result)) return result;
|
||||||
|
|
||||||
|
// A reentrant driver update is newer than this attempt. Do not overwrite its tuple.
|
||||||
|
if (_mutationRevision != mutationBefore) return result;
|
||||||
// Value's first-touch setter can reset status. Commit in this order, under the shared lock.
|
// Value's first-touch setter can reset status. Commit in this order, under the shared lock.
|
||||||
Value = value;
|
PublishValue(value, statusCode, sourceTimestamp == DateTimeUtc.MinValue ? DateTimeUtc.Now : sourceTimestamp);
|
||||||
StatusCode = statusCode;
|
_committedWriteToken = token;
|
||||||
Timestamp = sourceTimestamp == DateTimeUtc.MinValue ? DateTimeUtc.Now : sourceTimestamp;
|
|
||||||
ChangeMasks |= NodeStateChangeMasks.Value;
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1046,6 +1046,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
|
|
||||||
// Value deliberately untouched — that IS the feature: the node keeps its last known value under a
|
// Value deliberately untouched — that IS the feature: the node keeps its last known value under a
|
||||||
// Bad status (OPC UA Part 8's last-known-value convention).
|
// Bad status (OPC UA Part 8's last-known-value convention).
|
||||||
|
if (variable is EquipmentVariableState equipment) equipment.InvalidateWriteOwnership();
|
||||||
variable.StatusCode = new StatusCode(statusCode);
|
variable.StatusCode = new StatusCode(statusCode);
|
||||||
variable.Timestamp = sourceTimestampUtc;
|
variable.Timestamp = sourceTimestampUtc;
|
||||||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||||
@@ -1076,7 +1077,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
{
|
{
|
||||||
if (!_variables.TryGetValue(key, out var variable)) return false;
|
if (!_variables.TryGetValue(key, out var variable)) return false;
|
||||||
|
|
||||||
variable.Value = SdkValueBoundary.FromClr(value, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension);
|
var payload = SdkValueBoundary.FromClr(value, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension);
|
||||||
|
if (variable is EquipmentVariableState equipment) equipment.InvalidateWriteOwnership();
|
||||||
|
variable.Value = payload;
|
||||||
// The caller's status code reaches the SDK verbatim, sub-code included. This used to
|
// The caller's status code reaches the SDK verbatim, sub-code included. This used to
|
||||||
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
|
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
|
||||||
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
|
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
|
||||||
@@ -2347,7 +2350,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart.
|
// presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart.
|
||||||
var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName;
|
var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName;
|
||||||
|
|
||||||
return DispatchDeviceWrite(gateway, nodeKey, nodeBareId, nodeRealm, optimisticValue, priorValue, priorStatus, clientUserId);
|
return DispatchDeviceWrite(gateway, nodeKey, nodeBareId, nodeRealm, optimisticValue, priorValue, priorStatus, clientUserId,
|
||||||
|
(node as EquipmentVariableState)?.ActiveWriteStamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2358,16 +2362,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// here (review 01 L3 — it used to surface only as an <c>UnobservedTaskException</c> at finalization, with
|
/// here (review 01 L3 — it used to surface only as an <c>UnobservedTaskException</c> at finalization, with
|
||||||
/// the constant reason "write dispatch faulted" in the audit event). A gateway that throws SYNCHRONOUSLY
|
/// the constant reason "write dispatch faulted" in the audit event). A gateway that throws SYNCHRONOUSLY
|
||||||
/// (review 01 H5) is refused inline with <c>BadDeviceFailure</c> before the SDK applies anything, rather
|
/// (review 01 H5) is refused inline with <c>BadDeviceFailure</c> before the SDK applies anything, rather
|
||||||
/// than escaping the handler under <c>Lock</c>. <c>RunContinuationsAsynchronously</c> guarantees the revert
|
/// than escaping the handler under <c>Lock</c>. The default-scheduler continuation is queued without
|
||||||
/// never runs inline on the SDK write thread (the gateway can return a synchronously-completed task — e.g.
|
/// <c>ExecuteSynchronously</c>; its manager-lock acquisition follows the optimistic commit. Production
|
||||||
/// its boot-window "no DriverHostActor yet" branch), so <see cref="RevertOptimisticWriteIfNeeded"/> never
|
/// corrections additionally require the exact node instance and committed write token, so a newer
|
||||||
/// re-enters <c>Lock</c> while <c>CustomNodeManager2.Write</c> still holds it.
|
/// equal-valued write or publication cannot be mistaken for this attempt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns><see cref="ServiceResult.Good"/> once the write is in flight (the SDK then applies the value
|
/// <returns><see cref="ServiceResult.Good"/> once the write is in flight (the SDK then applies the value
|
||||||
/// optimistically); a Bad result when the dispatch itself threw.</returns>
|
/// optimistically); a Bad result when the dispatch itself threw.</returns>
|
||||||
internal ServiceResult DispatchDeviceWrite(
|
internal ServiceResult DispatchDeviceWrite(
|
||||||
IOpcUaNodeWriteGateway gateway, string nodeKey, string nodeBareId, AddressSpaceRealm nodeRealm,
|
IOpcUaNodeWriteGateway gateway, string nodeKey, string nodeBareId, AddressSpaceRealm nodeRealm,
|
||||||
object? optimisticValue, object? priorValue, StatusCode priorStatus, string? clientUserId)
|
object? optimisticValue, object? priorValue, StatusCode priorStatus, string? clientUserId,
|
||||||
|
EquipmentVariableState.WriteStamp? writeStamp = null)
|
||||||
{
|
{
|
||||||
Task<NodeWriteOutcome> dispatch;
|
Task<NodeWriteOutcome> dispatch;
|
||||||
try
|
try
|
||||||
@@ -2398,7 +2403,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
outcome = new NodeWriteOutcome(false, reason);
|
outcome = new NodeWriteOutcome(false, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm);
|
RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm, writeStamp);
|
||||||
},
|
},
|
||||||
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
|
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
|
||||||
|
|
||||||
@@ -2774,8 +2779,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
|
/// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
|
||||||
/// </item>
|
/// </item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure
|
/// A superseded production outcome is logged at Debug; the gateway records the underlying device failure
|
||||||
/// and the SDK trace captures any audit-report failure.
|
/// and audit construction/reporting failures are logged here.
|
||||||
/// <para><c>internal</c> (not private) so the behavioural test drives the blip-then-settle continuation
|
/// <para><c>internal</c> (not private) so the behavioural test drives the blip-then-settle continuation
|
||||||
/// against a booted node manager — see <c>NodeManagerWriteRevertTests</c>.</para>
|
/// against a booted node manager — see <c>NodeManagerWriteRevertTests</c>.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2787,9 +2792,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// <param name="clientUserId">The writing principal's user-id string (the identity's DisplayName), threaded
|
/// <param name="clientUserId">The writing principal's user-id string (the identity's DisplayName), threaded
|
||||||
/// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
|
/// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
|
||||||
/// <param name="realm">The address-space realm (Raw or Uns) the written node lives in.</param>
|
/// <param name="realm">The address-space realm (Raw or Uns) the written node lives in.</param>
|
||||||
|
/// <param name="writeStamp">The actual production node and accepted write attempt; absent only for legacy direct tests.</param>
|
||||||
internal void RevertOptimisticWriteIfNeeded(
|
internal void RevertOptimisticWriteIfNeeded(
|
||||||
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
|
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
|
||||||
string? clientUserId, AddressSpaceRealm realm)
|
string? clientUserId, AddressSpaceRealm realm, EquipmentVariableState.WriteStamp? writeStamp = null)
|
||||||
{
|
{
|
||||||
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
|
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
|
||||||
AuditWriteUpdateEventState? auditEvent = null;
|
AuditWriteUpdateEventState? auditEvent = null;
|
||||||
@@ -2798,7 +2804,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
lock (Lock)
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op
|
if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op
|
||||||
if (!ShouldRevert(outcome, SdkValueBoundary.ToClr(variable.Value), optimisticValue)) return; // success, or poll moved it on
|
if (outcome.Success) return;
|
||||||
|
if (writeStamp is { } stamp)
|
||||||
|
{
|
||||||
|
if (variable is not EquipmentVariableState equipment || !equipment.OwnsWrite(stamp))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("OtOpcUaNodeManager: ignored superseded write outcome for {NodeId}", nodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!ShouldRevert(outcome, SdkValueBoundary.ToClr(variable.Value), optimisticValue)) return;
|
||||||
|
// The value guard above is retained only for legacy direct unit calls. Production writes
|
||||||
|
// carry exact node-instance/token ownership, so copied arrays and equal newer values are safe.
|
||||||
|
|
||||||
// Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore
|
// Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore
|
||||||
// the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's
|
// the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's
|
||||||
@@ -2808,7 +2825,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
variable.Timestamp = now;
|
variable.Timestamp = now;
|
||||||
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip
|
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip
|
||||||
|
|
||||||
variable.Value = SdkValueBoundary.FromClr(priorValue, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension);
|
var prior = SdkValueBoundary.FromClr(priorValue, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension);
|
||||||
|
if (variable is EquipmentVariableState restored) restored.InvalidateWriteOwnership();
|
||||||
|
variable.Value = prior;
|
||||||
variable.StatusCode = priorStatus;
|
variable.StatusCode = priorStatus;
|
||||||
variable.Timestamp = now;
|
variable.Timestamp = now;
|
||||||
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior
|
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior
|
||||||
@@ -2913,14 +2932,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
|
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
|
||||||
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
|
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
|
||||||
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
|
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
|
||||||
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
|
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) =>
|
||||||
{
|
ApplicationAlarmConditions.Create(alarmType, parent);
|
||||||
"OffNormalAlarm" => new OffNormalAlarmState(parent),
|
|
||||||
"DiscreteAlarm" => new DiscreteAlarmState(parent),
|
|
||||||
// "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
|
|
||||||
// to populate, so the limit subtypes would carry unset High/Low children.
|
|
||||||
_ => new AlarmConditionState(parent),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
|
/// The <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
|
||||||
@@ -3323,6 +3336,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
: default;
|
: default;
|
||||||
// Drop the stale (old-typed) value — mirrors EnsureVariable's fresh-node state so a client
|
// Drop the stale (old-typed) value — mirrors EnsureVariable's fresh-node state so a client
|
||||||
// never reads a value whose runtime type contradicts the just-changed DataType.
|
// never reads a value whose runtime type contradicts the just-changed DataType.
|
||||||
|
if (v is EquipmentVariableState equipment) equipment.InvalidateWriteOwnership();
|
||||||
v.Value = Variant.Null;
|
v.Value = Variant.Null;
|
||||||
v.StatusCode = StatusCodes.BadWaitingForInitialData;
|
v.StatusCode = StatusCodes.BadWaitingForInitialData;
|
||||||
v.Timestamp = DateTime.MinValue;
|
v.Timestamp = DateTime.MinValue;
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Server;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
public sealed class Sdk2ConditionMethodCompatibilityTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-methods-{Guid.NewGuid():N}");
|
||||||
|
private OpcUaApplicationHost _host = null!;
|
||||||
|
private OtOpcUaSdkServer _server = null!;
|
||||||
|
private OtOpcUaNodeManager _manager = null!;
|
||||||
|
private readonly List<AlarmCommand> _scripted = [];
|
||||||
|
private readonly List<NativeAlarmAck> _native = [];
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = "SDK2.MethodContracts", ApplicationUri = "urn:SDK2.MethodContracts",
|
||||||
|
OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pki,
|
||||||
|
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), TestContext.Current.CancellationToken);
|
||||||
|
_host = started.Host;
|
||||||
|
_server = started.Server;
|
||||||
|
_manager = _server.NodeManager!;
|
||||||
|
_manager.AlarmCommandRouter = _scripted.Add;
|
||||||
|
_manager.NativeAlarmAckRouter = _native.Add;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _host.DisposeAsync();
|
||||||
|
Directory.Delete(_pki, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, null)]
|
||||||
|
[InlineData(false, "again")]
|
||||||
|
[InlineData(true, null)]
|
||||||
|
[InlineData(true, "again")]
|
||||||
|
public void Actual_SDK_method_preserves_current_trunk_reack_and_comment(bool native, string? comment)
|
||||||
|
{
|
||||||
|
var condition = Condition(native);
|
||||||
|
Call(condition, condition.Acknowledge!, condition.EventId!.Value, "first").ShouldBe(ServiceResult.Good);
|
||||||
|
if (!native) Call(condition, condition.Confirm!, condition.EventId.Value, "confirmed").ShouldBe(ServiceResult.Good);
|
||||||
|
var before = condition.EventId.Value;
|
||||||
|
Call(condition, condition.Acknowledge!, before, comment).ShouldBe(ServiceResult.Good);
|
||||||
|
condition.AckedState!.Id!.Value.ShouldBeTrue();
|
||||||
|
condition.ConfirmedState!.Id!.Value.ShouldBeTrue();
|
||||||
|
if (comment is null) condition.EventId.Value.ShouldBe(before);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
condition.EventId.Value.ShouldNotBe(before);
|
||||||
|
condition.Comment!.Value.Text.ShouldBe(comment);
|
||||||
|
}
|
||||||
|
if (native) _native.Count.ShouldBe(2, "a native re-ack is an upstream retry");
|
||||||
|
else _scripted.Count(x => x.Operation == "Acknowledge").ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Actual_SDK_native_confirm_keeps_the_application_unsupported_status()
|
||||||
|
{
|
||||||
|
var condition = Condition(native: true);
|
||||||
|
var before = condition.EventId!.Value;
|
||||||
|
Call(condition, condition.Confirm!, before, null).StatusCode.ShouldBe(StatusCodes.BadNotSupported);
|
||||||
|
condition.ConfirmedState!.Id!.Value.ShouldBeTrue();
|
||||||
|
condition.EventId.Value.ShouldBe(before);
|
||||||
|
_native.ShouldBeEmpty();
|
||||||
|
_scripted.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Current_trunk_reack_still_requires_app_authority_primary_and_native_preflight()
|
||||||
|
{
|
||||||
|
var condition = Condition(native: true);
|
||||||
|
Call(condition, condition.Acknowledge!, condition.EventId!.Value, null).ShouldBe(ServiceResult.Good);
|
||||||
|
var before = condition.EventId.Value;
|
||||||
|
Call(condition, condition.Acknowledge!, before, null, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||||
|
_manager.NativeAlarmAckPrimaryGate = () => false;
|
||||||
|
Call(condition, condition.Acknowledge!, before, null).StatusCode.ShouldBe(StatusCodes.BadInvalidState);
|
||||||
|
_manager.NativeAlarmAckPrimaryGate = () => true;
|
||||||
|
_manager.NativeAlarmAckPreflight = _ => "driver unavailable";
|
||||||
|
Call(condition, condition.Acknowledge!, before, null).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||||
|
condition.EventId.Value.ShouldBe(before);
|
||||||
|
_native.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("empty")]
|
||||||
|
[InlineData("foreign")]
|
||||||
|
[InlineData("superseded")]
|
||||||
|
[InlineData("disabled")]
|
||||||
|
public void Actual_SDK_method_keeps_event_and_enabled_guards(string invalid)
|
||||||
|
{
|
||||||
|
var condition = Condition(native: false);
|
||||||
|
var original = condition.EventId!.Value;
|
||||||
|
Call(condition, condition.Acknowledge!, original, null).ShouldBe(ServiceResult.Good);
|
||||||
|
var current = condition.EventId.Value;
|
||||||
|
var token = invalid switch
|
||||||
|
{
|
||||||
|
"empty" => default,
|
||||||
|
"foreign" => ByteString.From(Guid.NewGuid().ToByteArray()),
|
||||||
|
"superseded" => original,
|
||||||
|
_ => current,
|
||||||
|
};
|
||||||
|
if (invalid == "disabled") condition.SetEnableState(Context(), false);
|
||||||
|
var routes = _scripted.Count;
|
||||||
|
ServiceResult.IsBad(Call(condition, condition.Acknowledge!, token, null)).ShouldBeTrue();
|
||||||
|
_scripted.Count.ShouldBe(routes);
|
||||||
|
condition.EventId.Value.ShouldBe(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("OffNormalAlarm")]
|
||||||
|
[InlineData("DiscreteAlarm")]
|
||||||
|
[InlineData("AlarmCondition")]
|
||||||
|
public void An_acknowledged_branch_is_constructible_and_does_not_get_the_trunk_repeat_waiver(string type)
|
||||||
|
{
|
||||||
|
var condition = Condition(native: false, type);
|
||||||
|
var branch = condition.CreateBranch(Context(), new NodeId(Guid.NewGuid())).ShouldBeAssignableTo<AlarmConditionState>()!;
|
||||||
|
branch.EventId!.Value = ByteString.From(Guid.NewGuid().ToByteArray());
|
||||||
|
branch.SetAcknowledgedState(Context(), true);
|
||||||
|
var routes = _scripted.Count;
|
||||||
|
Call(condition, condition.Acknowledge!, branch.EventId!.Value, null).StatusCode.ShouldBe(StatusCodes.BadConditionBranchAlreadyAcked);
|
||||||
|
_scripted.Count.ShouldBe(routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AlarmConditionState Condition(bool native, string type = "OffNormalAlarm")
|
||||||
|
{
|
||||||
|
var realm = native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
|
||||||
|
_manager.EnsureFolder("eq", null, "Equipment", realm);
|
||||||
|
_manager.MaterialiseAlarmCondition("alarm", "eq", "Alarm", type, 700, realm, isNative: native);
|
||||||
|
_manager.WriteAlarmCondition("alarm", new AlarmConditionSnapshot(true, false, true, true,
|
||||||
|
AlarmShelvingKind.Unshelved, 700, "active"), DateTime.UtcNow, realm);
|
||||||
|
return _manager.TryGetAlarmCondition("alarm", realm)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ServerSystemContext Context(bool authorized = true) => new(_server.CurrentInstance)
|
||||||
|
{
|
||||||
|
UserIdentity = new RoleCarryingUserIdentity(new AnonymousIdentityToken(),
|
||||||
|
authorized ? [OpcUaDataPlaneRoles.AlarmAck] : [], "operator", null),
|
||||||
|
};
|
||||||
|
|
||||||
|
private ServiceResult Call(AlarmConditionState condition, AddCommentMethodState method,
|
||||||
|
ByteString eventId, string? comment, bool authorized = true)
|
||||||
|
{
|
||||||
|
var argumentErrors = new List<ServiceResult>();
|
||||||
|
var result = method.Call(Context(authorized), condition.NodeId,
|
||||||
|
[Variant.From(eventId), Variant.From(comment is null ? default(LocalizedText) : new LocalizedText(comment))],
|
||||||
|
argumentErrors, new List<Variant>());
|
||||||
|
argumentErrors.All(ServiceResult.IsGood).ShouldBeTrue();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
@@ -15,6 +16,7 @@ public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime
|
|||||||
private OtOpcUaSdkServer _server = null!;
|
private OtOpcUaSdkServer _server = null!;
|
||||||
private OtOpcUaNodeManager _manager = null!;
|
private OtOpcUaNodeManager _manager = null!;
|
||||||
private readonly RecordingGateway _gateway = new();
|
private readonly RecordingGateway _gateway = new();
|
||||||
|
private readonly OutcomeLogger _outcomeLogger = new();
|
||||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
public async ValueTask InitializeAsync()
|
||||||
@@ -24,7 +26,7 @@ public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
ApplicationName = "SDK2.ValueContracts", ApplicationUri = "urn:SDK2.ValueContracts",
|
ApplicationName = "SDK2.ValueContracts", ApplicationUri = "urn:SDK2.ValueContracts",
|
||||||
PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki,
|
PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki,
|
||||||
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), Ct);
|
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(_outcomeLogger), Ct);
|
||||||
_host = started.Host;
|
_host = started.Host;
|
||||||
_server = started.Server;
|
_server = started.Server;
|
||||||
_manager = _server.NodeManager!;
|
_manager = _server.NodeManager!;
|
||||||
@@ -214,6 +216,123 @@ public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime
|
|||||||
scalarBytes.TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar);
|
scalarBytes.TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AddressSpaceRealm.Raw)]
|
||||||
|
[InlineData(AddressSpaceRealm.Uns)]
|
||||||
|
public async Task An_immediate_failed_array_outcome_reverts_an_actual_node_write(AddressSpaceRealm realm)
|
||||||
|
{
|
||||||
|
var node = Node("Int16", array: true, realm: realm);
|
||||||
|
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, realm);
|
||||||
|
_manager.NodeWriteGateway = new OutcomeGateway(_ => Task.FromResult(new NodeWriteOutcome(false, "refused")));
|
||||||
|
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
|
while (DateTime.UtcNow < deadline && !SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().SequenceEqual(new short[] { 7, 8 }))
|
||||||
|
await Task.Delay(10, Ct);
|
||||||
|
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([7, 8]);
|
||||||
|
node.StatusCode.ShouldBe(StatusCodes.Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("equal-client-write")]
|
||||||
|
[InlineData("equal-driver-poll")]
|
||||||
|
[InlineData("status-only")]
|
||||||
|
[InlineData("same-id-recreated")]
|
||||||
|
[InlineData("shape-update")]
|
||||||
|
public async Task An_old_failed_outcome_cannot_replace_a_newer_actual_node_tuple(string change)
|
||||||
|
{
|
||||||
|
var node = Node("Int16", array: true);
|
||||||
|
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
var first = new TaskCompletionSource<NodeWriteOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var calls = 0;
|
||||||
|
_manager.NodeWriteGateway = new OutcomeGateway(_ => ++calls == 1
|
||||||
|
? first.Task : Task.FromResult(new NodeWriteOutcome(true, null)));
|
||||||
|
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||||
|
var stamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
switch (change)
|
||||||
|
{
|
||||||
|
case "equal-client-write": Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); break;
|
||||||
|
case "equal-driver-poll":
|
||||||
|
_manager.WriteValue("v", new short[] { 1, 2 }, StatusCodes.UncertainLastUsableValue.Code, stamp, AddressSpaceRealm.Raw); break;
|
||||||
|
case "status-only":
|
||||||
|
_manager.WriteStatuses([new StatusWrite("v", StatusCodes.BadNoCommunication.Code, stamp, AddressSpaceRealm.Raw)]); break;
|
||||||
|
case "same-id-recreated":
|
||||||
|
_manager.RemoveVariableNode("v", AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||||
|
node = Node("Int16", array: true);
|
||||||
|
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good); break;
|
||||||
|
case "shape-update":
|
||||||
|
_manager.UpdateTagAttributes("v", true, null, "Int32", true, 2, AddressSpaceRealm.Raw).ShouldBeTrue(); break;
|
||||||
|
}
|
||||||
|
var expected = node.Value;
|
||||||
|
var status = node.StatusCode;
|
||||||
|
var time = node.Timestamp;
|
||||||
|
first.SetResult(new NodeWriteOutcome(false, "older write failed"));
|
||||||
|
await _outcomeLogger.Ignored.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
|
||||||
|
node.Value.ShouldBe(expected);
|
||||||
|
node.StatusCode.ShouldBe(status);
|
||||||
|
node.Timestamp.ShouldBe(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reentrant_driver_publication_wins_over_the_callback_optimistic_commit()
|
||||||
|
{
|
||||||
|
var node = Node("Int16", array: true);
|
||||||
|
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
var first = new TaskCompletionSource<NodeWriteOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var stamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
_manager.NodeWriteGateway = new OutcomeGateway(_ =>
|
||||||
|
{
|
||||||
|
_manager.WriteValue("v", new short[] { 1, 2 }, StatusCodes.UncertainLastUsableValue.Code, stamp, AddressSpaceRealm.Raw);
|
||||||
|
return first.Task;
|
||||||
|
});
|
||||||
|
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||||
|
node.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||||
|
node.Timestamp.ToDateTime().ShouldBe(stamp);
|
||||||
|
first.SetResult(new NodeWriteOutcome(false, "old write failed"));
|
||||||
|
await _outcomeLogger.Ignored.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
|
||||||
|
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([1, 2]);
|
||||||
|
node.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||||
|
node.Timestamp.ToDateTime().ShouldBe(stamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public async Task A_refused_attempt_does_not_disown_an_older_committed_write(bool synchronousThrow)
|
||||||
|
{
|
||||||
|
var node = Node("Int16", array: true);
|
||||||
|
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||||
|
var first = new TaskCompletionSource<NodeWriteOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
_manager.NodeWriteGateway = new OutcomeGateway(_ => first.Task);
|
||||||
|
Write(node, new short[] { 1, 2 }).ShouldBe(ServiceResult.Good);
|
||||||
|
if (synchronousThrow)
|
||||||
|
{
|
||||||
|
_manager.NodeWriteGateway = new OutcomeGateway(_ => throw new InvalidOperationException("gateway threw"));
|
||||||
|
Write(node, new short[] { 3, 4 }).StatusCode.ShouldBe(StatusCodes.BadDeviceFailure);
|
||||||
|
}
|
||||||
|
else Write(node, new short[] { 3, 4 }, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||||
|
first.SetResult(new NodeWriteOutcome(false, "first write failed"));
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
|
while (DateTime.UtcNow < deadline && !SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().SequenceEqual(new short[] { 7, 8 }))
|
||||||
|
await Task.Delay(10, Ct);
|
||||||
|
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([7, 8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OutcomeLogger : ILogger<OtOpcUaNodeManager>
|
||||||
|
{
|
||||||
|
internal TaskCompletionSource Ignored { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
public bool IsEnabled(LogLevel level) => true;
|
||||||
|
public void Log<TState>(LogLevel level, EventId id, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
if (formatter(state, exception).Contains("ignored superseded write outcome", StringComparison.Ordinal)) Ignored.TrySetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OutcomeGateway(Func<object?, Task<NodeWriteOutcome>> outcome) : IOpcUaNodeWriteGateway
|
||||||
|
{
|
||||||
|
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => outcome(value);
|
||||||
|
}
|
||||||
|
|
||||||
private BaseDataVariableState Node(string type, bool array = false, bool refuse = false,
|
private BaseDataVariableState Node(string type, bool array = false, bool refuse = false,
|
||||||
bool writable = true, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
|
bool writable = true, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
var condition = nm.TryGetAlarmCondition("alm-1");
|
var condition = nm.TryGetAlarmCondition("alm-1");
|
||||||
condition.ShouldNotBeNull();
|
condition.ShouldNotBeNull();
|
||||||
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
|
||||||
condition.ShouldBeOfType<OffNormalAlarmState>();
|
condition.ShouldBeAssignableTo<OffNormalAlarmState>();
|
||||||
// v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the
|
// v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the
|
||||||
// UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports.
|
// UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports.
|
||||||
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
|
||||||
|
|||||||
Reference in New Issue
Block a user