refactor: migrate client applications to OPC UA SDK 2.0
This commit is contained in:
@@ -210,7 +210,7 @@ public class HistoryReadCommand : CommandBase
|
||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||
: "Uncertain";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value?.ToString() ?? "(none)",-15} {status,-10} " +
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue)?.ToString() ?? "(none)",-15} {status,-10} " +
|
||||
$"{e.UpdateType,-8} {e.UserName ?? "",-20} {e.ModificationTime:O}");
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ public class HistoryReadCommand : CommandBase
|
||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||
: "Uncertain";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}");
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
@@ -276,23 +276,25 @@ public class HistoryReadCommand : CommandBase
|
||||
internal static EventFilter BuildEventFilter(IReadOnlyList<string> fields, NodeId? ofType)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
var clauses = new List<SimpleAttributeOperand>();
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var type = string.Equals(field, "ConditionName", StringComparison.Ordinal)
|
||||
? ObjectTypeIds.ConditionType
|
||||
: ObjectTypeIds.BaseEventType;
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||
clauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||
}
|
||||
|
||||
filter.SelectClauses = clauses.ToArrayOf();
|
||||
if (ofType is not null)
|
||||
filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
|
||||
filter.WhereClause.Push(FilterOperator.OfType, Variant.From(ofType.Value));
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as <c>-</c>.</summary>
|
||||
/// <param name="field">The field value.</param>
|
||||
/// <returns>Its display text.</returns>
|
||||
private static string FormatEventField(Variant field) => field.Value switch
|
||||
private static string FormatEventField(Variant field) => ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(field) switch
|
||||
{
|
||||
null => "-",
|
||||
byte[] bytes => Convert.ToHexString(bytes),
|
||||
|
||||
@@ -89,11 +89,11 @@ public class HistoryUpdateCommand : CommandBase
|
||||
throw new CommandException(
|
||||
$"--value and --time must be supplied in pairs ({values.Count} value(s), {times.Count} time(s)).");
|
||||
|
||||
var updates = new DataValueCollection();
|
||||
var updates = new List<DataValue>();
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var ts = ParseTime(times[i], "--time");
|
||||
updates.Add(new DataValue(new Variant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
||||
updates.Add(new DataValue(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToVariant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
||||
}
|
||||
|
||||
return new UpdateDataDetails
|
||||
@@ -105,7 +105,7 @@ public class HistoryUpdateCommand : CommandBase
|
||||
"replace" => PerformUpdateType.Replace,
|
||||
_ => PerformUpdateType.Update
|
||||
},
|
||||
UpdateValues = updates
|
||||
UpdateValues = updates.ToArrayOf()
|
||||
};
|
||||
}
|
||||
case "delete-raw":
|
||||
@@ -134,9 +134,9 @@ public class HistoryUpdateCommand : CommandBase
|
||||
throw new CommandException("--mode delete-at-time takes --time only; --value is not allowed.");
|
||||
if (times.Count == 0)
|
||||
throw new CommandException("--mode delete-at-time requires at least one --time.");
|
||||
var req = new DateTimeCollection();
|
||||
var req = new List<DateTimeUtc>();
|
||||
foreach (var t in times) req.Add(ParseTime(t, "--time"));
|
||||
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req };
|
||||
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req.ToArrayOf() };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,8 +238,8 @@ public class HistoryUpdateCommand : CommandBase
|
||||
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
|
||||
private static string StatusName(StatusCode code)
|
||||
{
|
||||
var name = StatusCodes.GetBrowseName(code.Code);
|
||||
if (string.IsNullOrEmpty(name)) name = StatusCodes.GetBrowseName(code.Code & 0xFFFF0000u);
|
||||
var name = code.SymbolicId;
|
||||
if (string.IsNullOrEmpty(name)) name = new StatusCode(code.Code & 0xFFFF0000u).SymbolicId;
|
||||
return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name;
|
||||
}
|
||||
|
||||
@@ -247,8 +247,8 @@ public class HistoryUpdateCommand : CommandBase
|
||||
{
|
||||
return details switch
|
||||
{
|
||||
UpdateDataDetails u => u.UpdateValues.Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
||||
DeleteAtTimeDetails d => d.ReqTimes.Select(t => t.ToString("O")).ToList(),
|
||||
UpdateDataDetails u => (u.UpdateValues.ToArray() ?? []).Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
||||
DeleteAtTimeDetails d => (d.ReqTimes.ToArray() ?? []).Select(t => t.ToString("O")).ToList(),
|
||||
DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"],
|
||||
_ => []
|
||||
};
|
||||
|
||||
@@ -59,12 +59,12 @@ public class ReadCommand : CommandBase
|
||||
// not crash with a stack trace — session-level failures still propagate.
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: ");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({ex.StatusCode.SymbolicId})");
|
||||
return;
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
||||
await console.Output.WriteLineAsync($"Value: {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(value.WrappedValue)}");
|
||||
await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
|
||||
await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
|
||||
await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
|
||||
|
||||
@@ -123,14 +123,14 @@ public class SubscribeCommand : CommandBase
|
||||
try
|
||||
{
|
||||
var key = e.NodeId.ToString();
|
||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
|
||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
|
||||
if (!StatusCode.IsGood(e.Value.StatusCode))
|
||||
everBad.TryAdd(key, 0);
|
||||
if (!Quiet)
|
||||
{
|
||||
var line =
|
||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})";
|
||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})";
|
||||
outputChannel.Writer.TryWrite(line);
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ public class SubscribeCommand : CommandBase
|
||||
var childPath = $"{parentPath}/{child.DisplayName}";
|
||||
if (child.NodeClass == "Variable")
|
||||
{
|
||||
into.Add((nodeId, childPath));
|
||||
into.Add((nodeId.Value, childPath));
|
||||
}
|
||||
if (child.HasChildren)
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ public class WriteCommand : CommandBase
|
||||
|
||||
// Read current value to determine type for conversion
|
||||
var currentValue = await service.ReadValueAsync(nodeId, ct);
|
||||
var typedValue = ValueConverter.ConvertValue(Value, currentValue.Value);
|
||||
var typedValue = ValueConverter.ConvertValue(Value, ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(currentValue.WrappedValue));
|
||||
|
||||
var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
|
||||
|
||||
|
||||
@@ -52,6 +52,6 @@ public static class NodeIdParser
|
||||
var result = Parse(nodeIdString);
|
||||
if (result == null)
|
||||
throw new ArgumentException("Node ID is required but was not provided.");
|
||||
return result;
|
||||
return result.Value;
|
||||
}
|
||||
}
|
||||
+18
-12
@@ -58,20 +58,26 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
}
|
||||
};
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
|
||||
if (settings.AutoAcceptCertificates)
|
||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
if (settings.SecurityMode != SecurityMode.None)
|
||||
try
|
||||
{
|
||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
|
||||
if (settings.SecurityMode != SecurityMode.None)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
|
||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(false, ct: ct);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { (config.CertificateManager as IDisposable)?.Dispose(); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after configuration failure"); }
|
||||
throw;
|
||||
}
|
||||
|
||||
Logger.Debug("ApplicationConfiguration created for {EndpointUrl}", settings.EndpointUrl);
|
||||
|
||||
@@ -19,9 +19,9 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
||||
// obsolete synchronous CoreClientUtils.SelectEndpoint, which does the same filter + rewrite.)
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
config, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var allEndpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
var allEndpoints = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||
|
||||
return EndpointSelector.SelectBest(allEndpoints, endpointUrl, requestedMode);
|
||||
return EndpointSelector.SelectBest(allEndpoints.ToArray() ?? [], endpointUrl, requestedMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ internal static class EndpointSelector
|
||||
// advertises its own host:port (docker-dev containers all say :4840); following that would
|
||||
// open the session on a different server and fail the secure-channel certificate check.
|
||||
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
|
||||
var serverUri = new Uri(best.EndpointUrl);
|
||||
var serverUri = new Uri(best.EndpointUrl ?? throw new InvalidOperationException("Endpoint has no URL."));
|
||||
var requestedUri = new Uri(endpointUrl);
|
||||
// A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
|
||||
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
|
||||
|
||||
@@ -12,21 +12,25 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultSessionAdapter>();
|
||||
private readonly Session _session;
|
||||
private readonly IDisposable? _certificateManager;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a live OPC UA session so the shared client can issue runtime operations through a testable adapter surface.
|
||||
/// </summary>
|
||||
/// <param name="session">The connected OPC UA session used for browsing, reads, writes, history, and subscriptions.</param>
|
||||
public DefaultSessionAdapter(Session session)
|
||||
/// <param name="certificateManager">The per-connection certificate manager owned until session disposal.</param>
|
||||
public DefaultSessionAdapter(Session session, IDisposable? certificateManager = null)
|
||||
{
|
||||
_session = session;
|
||||
_certificateManager = certificateManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Connected => _session.Connected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionId => _session.SessionId?.ToString() ?? string.Empty;
|
||||
public string SessionId => _session.SessionId.IsNull ? string.Empty : _session.SessionId.ToString();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionName => _session.SessionName ?? string.Empty;
|
||||
@@ -35,7 +39,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ServerName => _session.Endpoint?.Server?.ApplicationName?.Text ?? string.Empty;
|
||||
public string ServerName => _session.Endpoint?.Server?.ApplicationName.Text ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SecurityMode => _session.Endpoint?.SecurityMode.ToString() ?? string.Empty;
|
||||
@@ -72,13 +76,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
Value = value
|
||||
};
|
||||
|
||||
var writeCollection = new WriteValueCollection { writeValue };
|
||||
ArrayOf<WriteValue> writeCollection = [writeValue];
|
||||
var response = await _session.WriteAsync(null, writeCollection, ct);
|
||||
// A malformed or service-level-faulted response can come back with an empty
|
||||
// Results collection alongside a service fault. Surface the service result
|
||||
// (or BadUnexpectedError) rather than letting Results[0] throw
|
||||
// IndexOutOfRangeException upstream.
|
||||
if (response.Results == null || response.Results.Count == 0)
|
||||
if (response.Results.Count == 0)
|
||||
{
|
||||
var serviceResult = response.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||
throw new ServiceResultException(serviceResult,
|
||||
@@ -89,7 +93,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
||||
{
|
||||
var (_, continuationPoint, references) = await _session.BrowseAsync(
|
||||
@@ -100,19 +104,29 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
nodeClassMask);
|
||||
nodeClassMask, ct);
|
||||
|
||||
return (continuationPoint, references ?? []);
|
||||
return (continuationPoint.IsNull ? null : continuationPoint.ToArray(), references);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
// Pass the caller's token so a cancelled browse does not block on the continuation
|
||||
// round-trip.
|
||||
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint, ct);
|
||||
return (nextCp, nextRefs ?? []);
|
||||
var response = await _session.BrowseNextAsync(null, false, [ByteString.From(continuationPoint)], ct);
|
||||
if (response.Results.Count == 0)
|
||||
throw new ServiceResultException(response.ResponseHeader.ServiceResult, "BrowseNext response contained no results.");
|
||||
var result = response.Results[0];
|
||||
if (StatusCode.IsBad(result.StatusCode)) throw new ServiceResultException(result.StatusCode);
|
||||
return (result.ContinuationPoint.IsNull ? null : result.ContinuationPoint.ToArray(), result.References);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
await _session.BrowseNextAsync(null, true, [ByteString.From(continuationPoint)], ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -120,16 +134,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
var response = await _session.HistoryUpdateAsync(
|
||||
null,
|
||||
new ExtensionObjectCollection { new ExtensionObject(details) },
|
||||
[new ExtensionObject(details)],
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var statuses = new List<StatusCode>();
|
||||
var ops = new List<IReadOnlyList<StatusCode>>();
|
||||
if (response.Results != null)
|
||||
if (!response.Results.IsEmpty)
|
||||
foreach (var r in response.Results)
|
||||
{
|
||||
statuses.Add(r.StatusCode);
|
||||
ops.Add(r.OperationResults?.ToArray() ?? Array.Empty<StatusCode>());
|
||||
ops.Add(r.OperationResults.ToArray() ?? []);
|
||||
}
|
||||
|
||||
return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
|
||||
@@ -152,10 +166,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
// by the cap releases the server's cursor (HistoryReadPaging). This path used to pass
|
||||
// `releaseContinuationPoints: continuationPoint != null`, which RELEASED the second page instead of reading it,
|
||||
// silently truncating every raw read to its first server page.
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
return await HistoryReadPaging.ReadAllAsync<DataValue>(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
|
||||
? historyData.DataValues
|
||||
result => result.HistoryData.TryGetValue(out HistoryData? historyData)
|
||||
? historyData.DataValues.ToArray() ?? []
|
||||
: [],
|
||||
maxValues, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
@@ -193,21 +207,21 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryEvent historyEvent }
|
||||
? historyEvent.Events.Select(e => new Models.HistoryEventEntry(e.EventFields?.ToArray() ?? []))
|
||||
result => result.HistoryData.TryGetValue(out HistoryEvent? historyEvent)
|
||||
? (historyEvent.Events.ToArray() ?? []).Select(e => new Models.HistoryEventEntry(e.EventFields.ToArray() ?? []))
|
||||
: [],
|
||||
maxEvents, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
|
||||
{
|
||||
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
|
||||
if (!result.HistoryData.TryGetValue(out HistoryModifiedData? modified)) yield break;
|
||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||
{
|
||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||
yield return new Models.HistoryModifiedEntry(
|
||||
modified.DataValues[i],
|
||||
info?.ModificationTime ?? DateTime.MinValue,
|
||||
info?.ModificationTime.ToDateTime() ?? DateTime.MinValue,
|
||||
info?.UpdateType ?? default,
|
||||
info?.UserName);
|
||||
}
|
||||
@@ -218,10 +232,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
HistoryReadDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
|
||||
TimestampsToReturn timestamps, CancellationToken ct)
|
||||
{
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
|
||||
};
|
||||
ArrayOf<HistoryReadValueId> nodesToRead =
|
||||
[
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = ByteString.From(continuationPoint) }
|
||||
];
|
||||
|
||||
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
|
||||
// client.ui consumers).
|
||||
@@ -244,10 +258,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
AggregateType = [aggregateId]
|
||||
};
|
||||
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId }
|
||||
};
|
||||
ArrayOf<HistoryReadValueId> nodesToRead = [new HistoryReadValueId { NodeId = nodeId }];
|
||||
|
||||
// Use the async overload so the method honors the cancellation token and
|
||||
// does not block on a synchronous service round-trip.
|
||||
@@ -262,12 +273,11 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
var results = response.Results;
|
||||
var allValues = new List<DataValue>();
|
||||
|
||||
if (results != null && results.Count > 0)
|
||||
if (results.Count > 0)
|
||||
{
|
||||
var result = results[0];
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
if (result.HistoryData is ExtensionObject ext &&
|
||||
ext.Body is HistoryData historyData)
|
||||
if (result.HistoryData.TryGetValue(out HistoryData? historyData))
|
||||
allValues.AddRange(historyData.DataValues);
|
||||
}
|
||||
|
||||
@@ -322,6 +332,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
try
|
||||
{
|
||||
if (_session.Connected) DisposeBridge.Run(ct => _session.CloseAsync(ct));
|
||||
@@ -330,7 +341,8 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
}
|
||||
|
||||
_session.Dispose();
|
||||
try { _session.Dispose(); }
|
||||
finally { _certificateManager?.Dispose(); }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -339,20 +351,20 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
var result = await _session.CallAsync(
|
||||
null,
|
||||
new CallMethodRequestCollection
|
||||
new CallMethodRequest[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
ObjectId = objectId,
|
||||
MethodId = methodId,
|
||||
InputArguments = new VariantCollection(inputArguments.Select(a => new Variant(a)))
|
||||
InputArguments = inputArguments.Select(ValueConverter.ToVariant).ToArrayOf()
|
||||
}
|
||||
},
|
||||
ct);
|
||||
|
||||
// An empty Results collection paired with a service fault must surface as
|
||||
// a ServiceResultException, not an IndexOutOfRangeException from Results[0].
|
||||
if (result.Results == null || result.Results.Count == 0)
|
||||
if (result.Results.Count == 0)
|
||||
{
|
||||
var serviceResult = result.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||
throw new ServiceResultException(serviceResult,
|
||||
@@ -363,6 +375,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
if (StatusCode.IsBad(callResult.StatusCode))
|
||||
throw new ServiceResultException(callResult.StatusCode);
|
||||
|
||||
return callResult.OutputArguments?.Select(v => v.Value).ToList();
|
||||
return callResult.OutputArguments.IsNull ? null
|
||||
: (callResult.OutputArguments.ToArray() ?? []).Select(v => ValueConverter.ToClrObject(v)!).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
||||
sessionName,
|
||||
sessionTimeoutMs,
|
||||
identity,
|
||||
null,
|
||||
default,
|
||||
ct);
|
||||
var session = created as Session
|
||||
?? throw new InvalidOperationException(
|
||||
$"Session factory returned {created.GetType().Name}, expected {nameof(Session)}.");
|
||||
|
||||
Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
|
||||
return new DefaultSessionAdapter(session);
|
||||
return new DefaultSessionAdapter(session, config.CertificateManager as IDisposable);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="nodeClassMask">The node classes that should be returned to the caller.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse request.</param>
|
||||
/// <returns>A continuation point (or <c>null</c> when complete) and the references found.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -87,9 +87,15 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse-next request.</param>
|
||||
/// <returns>The next continuation point (or <c>null</c> when exhausted) and the additional references.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Releases a browse cursor that the caller stops following.</summary>
|
||||
/// <param name="continuationPoint">The cursor owned by this session.</param>
|
||||
/// <param name="ct">A bounded cleanup token independent of the cancelled browse.</param>
|
||||
/// <returns>The completion of the release request.</returns>
|
||||
Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reads raw historical data.
|
||||
/// </summary>
|
||||
|
||||
@@ -56,7 +56,7 @@ internal static class HistoryReadPaging
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
all.AddRange(extract(result));
|
||||
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
continuationPoint = result.ContinuationPoint.IsNull ? null : result.ContinuationPoint.ToArray();
|
||||
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
|
||||
if (all.Count >= maxValues) break;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ internal static class HistoryReadStatus
|
||||
{
|
||||
if (!StatusCode.IsBad(status) || status.Code == StatusCodes.BadNoData) return;
|
||||
throw new ServiceResultException(status.Code,
|
||||
$"HistoryRead of {nodeId} failed: {StatusCodes.GetBrowseName(status.Code)} (0x{status.Code:X8}).");
|
||||
$"HistoryRead of {nodeId} failed: {status.ToString()} (0x{status.Code:X8}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,6 +8,47 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
/// </summary>
|
||||
public static class ValueConverter
|
||||
{
|
||||
/// <summary>Converts a supported client payload to an SDK value, detaching caller-owned buffers.</summary>
|
||||
/// <param name="value">The existing CLR or SDK payload.</param>
|
||||
/// <returns>The corresponding OPC UA value.</returns>
|
||||
/// <exception cref="ServiceResultException">The payload shape is unsupported.</exception>
|
||||
public static Variant ToVariant(object? value)
|
||||
{
|
||||
// Historically a CLR byte[] supplied to a client write/method was a scalar ByteString.
|
||||
if (value is byte[] bytes) return Variant.From(ByteString.From(bytes.AsSpan()));
|
||||
if (value is ByteString byteString) return Variant.From(byteString.Copy());
|
||||
if (!VariantHelper.TryCastFrom(value, out Variant variant))
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client value type or shape.");
|
||||
if (variant.TypeInfo.IsMatrix)
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client matrix shape.");
|
||||
// ArrayOf is read-only, but can wrap externally owned ReadOnlyMemory. Detach it too.
|
||||
if (variant.TypeInfo.IsArray && variant.AsBoxedObject(Variant.BoxingBehavior.Legacy) is Array array)
|
||||
{
|
||||
if (!VariantHelper.TryCastFrom(array, out variant))
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client array type.");
|
||||
}
|
||||
else if (variant.TryGetValue(out ByteString retainedBytes))
|
||||
variant = Variant.From(retainedBytes.Copy());
|
||||
return variant;
|
||||
}
|
||||
|
||||
/// <summary>Returns the CLR representation expected by existing client object consumers.</summary>
|
||||
/// <param name="value">The SDK value to inspect.</param>
|
||||
/// <returns>A CLR scalar or array, preserving null versus empty values.</returns>
|
||||
public static object? ToClrObject(Variant value)
|
||||
{
|
||||
return value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
|
||||
{
|
||||
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
|
||||
DateTimeUtc timestamp => timestamp.ToDateTime(),
|
||||
Uuid guid => (Guid)guid,
|
||||
DateTimeUtc[] timestamps => timestamps.Select(t => t.ToDateTime()).ToArray(),
|
||||
Uuid[] guids => guids.Select(g => (Guid)g).ToArray(),
|
||||
ByteString[] buffers => buffers.Select(b => b.IsNull ? null : b.ToArray()).ToArray(),
|
||||
var payload => payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw string value into the runtime type expected by the target node.
|
||||
/// </summary>
|
||||
|
||||
@@ -221,13 +221,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
if (value is string rawString)
|
||||
{
|
||||
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null)
|
||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || ValueConverter.ToClrObject(currentDataValue.WrappedValue) == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string.");
|
||||
typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
|
||||
typedValue = ValueConverter.ConvertValue(rawString, ValueConverter.ToClrObject(currentDataValue.WrappedValue));
|
||||
}
|
||||
|
||||
var dataValue = new DataValue(new Variant(typedValue));
|
||||
var dataValue = new DataValue(ValueConverter.ToVariant(typedValue));
|
||||
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
|
||||
}
|
||||
|
||||
@@ -242,22 +242,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
|
||||
var results = new List<BrowseResult>();
|
||||
|
||||
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
|
||||
|
||||
while (references.Count > 0)
|
||||
// A cursor belongs to the session that issued it, including while failover swaps _session.
|
||||
var session = _session!;
|
||||
byte[]? continuationPoint = null;
|
||||
try
|
||||
{
|
||||
// Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
|
||||
// recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
|
||||
// round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
|
||||
foreach (var reference in references)
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName?.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
ArrayOf<ReferenceDescription> references;
|
||||
(continuationPoint, references) = await session.BrowseAsync(startNode, nodeClassMask, ct);
|
||||
while (references.Count > 0)
|
||||
{
|
||||
foreach (var reference in references)
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
(continuationPoint, references) = await session.BrowseNextAsync(pending, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (continuationPoint is { Length: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var release = new CancellationTokenSource(HistoryReadPaging.ReleaseTimeout);
|
||||
await session.ReleaseBrowseContinuationPointAsync(continuationPoint, release.Token)
|
||||
.WaitAsync(release.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) { Logger.Warning(ex, "Releasing browse continuation point failed"); }
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -683,15 +700,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var redundancySupportValue =
|
||||
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
|
||||
RedundancySupport redundancySupport;
|
||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
|
||||
redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
|
||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && ValueConverter.ToClrObject(redundancySupportValue.WrappedValue) != null)
|
||||
redundancySupport = (RedundancySupport)Convert.ToInt32(ValueConverter.ToClrObject(redundancySupportValue.WrappedValue));
|
||||
else
|
||||
redundancySupport = RedundancySupport.None;
|
||||
var redundancyMode = redundancySupport.ToString();
|
||||
|
||||
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
|
||||
? Convert.ToByte(serviceLevelValue.Value)
|
||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && ValueConverter.ToClrObject(serviceLevelValue.WrappedValue) != null
|
||||
? Convert.ToByte(ValueConverter.ToClrObject(serviceLevelValue.WrappedValue))
|
||||
: (byte)0;
|
||||
|
||||
string[] serverUris = [];
|
||||
@@ -699,7 +716,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
var serverUriArrayValue =
|
||||
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
|
||||
if (serverUriArrayValue.Value is string[] uris)
|
||||
if (ValueConverter.ToClrObject(serverUriArrayValue.WrappedValue) is string[] uris)
|
||||
serverUris = uris;
|
||||
}
|
||||
catch
|
||||
@@ -711,7 +728,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
try
|
||||
{
|
||||
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
|
||||
if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
|
||||
if (ValueConverter.ToClrObject(serverArrayValue.WrappedValue) is string[] serverArray && serverArray.Length > 0)
|
||||
applicationUri = serverArray[0];
|
||||
}
|
||||
catch
|
||||
@@ -766,16 +783,26 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
};
|
||||
|
||||
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
|
||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
||||
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
|
||||
try
|
||||
{
|
||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
||||
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
|
||||
|
||||
var identity = settings.Username != null
|
||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||
: new UserIdentity();
|
||||
var identity = settings.Username != null
|
||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||
: new UserIdentity();
|
||||
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The session adapter owns it after successful creation; a failed attempt has no adapter owner.
|
||||
try { (config.CertificateManager as IDisposable)?.Dispose(); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after connection failure"); }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
|
||||
@@ -1007,28 +1034,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
private void OnAlarmEventNotification(EventFieldList eventFields)
|
||||
{
|
||||
var fields = eventFields.EventFields;
|
||||
if (fields == null || fields.Count < 1)
|
||||
if (fields.Count < 1)
|
||||
return;
|
||||
|
||||
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
|
||||
var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
|
||||
var eventId = fields.Count > 0 ? ValueConverter.ToClrObject(fields[0]) as byte[] : null;
|
||||
var sourceName = fields.Count > 2 ? ValueConverter.ToClrObject(fields[2]) as string ?? string.Empty : string.Empty;
|
||||
var time = DateTime.MinValue;
|
||||
if (fields.Count > 3 && fields[3].Value != null)
|
||||
if (fields.Count > 3 && ValueConverter.ToClrObject(fields[3]) != null)
|
||||
{
|
||||
if (fields[3].Value is DateTime dt)
|
||||
if (ValueConverter.ToClrObject(fields[3]) is DateTime dt)
|
||||
time = dt;
|
||||
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
|
||||
else if (DateTime.TryParse(ValueConverter.ToClrObject(fields[3])?.ToString(), out var parsed))
|
||||
time = parsed;
|
||||
}
|
||||
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
|
||||
var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
|
||||
var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
|
||||
var retain = fields.Count > 7 && ParseBool(fields[7].Value);
|
||||
var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;
|
||||
var message = fields.Count > 4 ? (fields[4].TryGetValue(out LocalizedText text) ? text.Text : null) ?? string.Empty : string.Empty;
|
||||
var severity = fields.Count > 5 ? Convert.ToUInt16(ValueConverter.ToClrObject(fields[5])) : (ushort)0;
|
||||
var conditionName = fields.Count > 6 ? ValueConverter.ToClrObject(fields[6]) as string ?? string.Empty : string.Empty;
|
||||
var retain = fields.Count > 7 && ParseBool(ValueConverter.ToClrObject(fields[7]));
|
||||
var conditionNodeId = fields.Count > 12 ? ValueConverter.ToClrObject(fields[12])?.ToString() : null;
|
||||
|
||||
// Try standard OPC UA ActiveState/AckedState fields first
|
||||
bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
|
||||
bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;
|
||||
bool? ackedField = fields.Count > 8 && ValueConverter.ToClrObject(fields[8]) != null ? ParseBool(ValueConverter.ToClrObject(fields[8])) : null;
|
||||
bool? activeField = fields.Count > 9 && ValueConverter.ToClrObject(fields[9]) != null ? ParseBool(ValueConverter.ToClrObject(fields[9])) : null;
|
||||
|
||||
var ackedState = ackedField ?? false;
|
||||
var activeState = activeField ?? false;
|
||||
@@ -1053,39 +1080,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
/// <returns>The populated <see cref="EventFilter"/>.</returns>
|
||||
internal static EventFilter CreateAlarmEventFilter()
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
// 0: EventId
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
// 1: EventType
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
|
||||
// 2: SourceName
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
// 3: Time
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
||||
// 4: Message
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
// 5: Severity
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
// 6: ConditionName
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
|
||||
// 7: Retain
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
|
||||
// 8-10: the two-state /Id booleans. Each browse path is TWO QualifiedNames. Passing "AckedState/Id" as a
|
||||
// string to AddSelectClause does not split it — it selects one element NAMED "AckedState/Id", which no
|
||||
// event carries, so all three arrived null and every alarm read inactive + unacknowledged.
|
||||
// 8: AckedState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.AckedState, BrowseNames.Id]));
|
||||
// 9: ActiveState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AlarmConditionType, [BrowseNames.ActiveState, BrowseNames.Id]));
|
||||
// 10: EnabledState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.ConditionType, [BrowseNames.EnabledState, BrowseNames.Id]));
|
||||
// 11: SuppressedOrShelved
|
||||
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
|
||||
// 12: SourceNode (ConditionId for acknowledgment)
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode);
|
||||
var filter = new EventFilter
|
||||
{
|
||||
SelectClauses =
|
||||
[
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventId")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventType")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceName")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Time")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Message")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Severity")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ConditionName")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Retain")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("AckedState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ActiveState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EnabledState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SuppressedOrShelved")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceNode")] },
|
||||
]
|
||||
};
|
||||
return filter;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||
<PackageReference Include="Serilog"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -16,7 +16,7 @@ internal static class StatusCodeFormatter
|
||||
var hex = $"0x{code:X8}";
|
||||
|
||||
// Try the SDK's browse name lookup first
|
||||
var name = StatusCodes.GetBrowseName(code);
|
||||
var name = statusCode.SymbolicId;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
return $"{hex} ({name})";
|
||||
|
||||
@@ -24,7 +24,7 @@ internal static class StatusCodeFormatter
|
||||
var mainCode = code & 0xFFFF0000;
|
||||
if (mainCode != code)
|
||||
{
|
||||
name = StatusCodes.GetBrowseName(mainCode);
|
||||
name = new StatusCode(mainCode).SymbolicId;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
return $"{hex} ({name})";
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ public partial class AlarmsViewModel : ObservableObject
|
||||
{
|
||||
try
|
||||
{
|
||||
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||
NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||
? null
|
||||
: NodeId.Parse(MonitoredNodeIdText);
|
||||
|
||||
@@ -275,7 +275,7 @@ public partial class AlarmsViewModel : ObservableObject
|
||||
|
||||
try
|
||||
{
|
||||
var nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
||||
NodeId? nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
||||
? null
|
||||
: NodeId.Parse(sourceNodeId);
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ public partial class HistoryViewModel : ObservableObject
|
||||
{
|
||||
foreach (var dv in values)
|
||||
Results.Add(new HistoryValueViewModel(
|
||||
Helpers.ValueFormatter.Format(dv.Value),
|
||||
Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dv.WrappedValue)),
|
||||
Helpers.StatusCodeFormatter.Format(dv.StatusCode),
|
||||
dv.SourceTimestamp.ToString("O"),
|
||||
dv.ServerTimestamp.ToString("O")));
|
||||
|
||||
@@ -76,7 +76,7 @@ public partial class ReadWriteViewModel : ObservableObject
|
||||
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
CurrentValue = Helpers.ValueFormatter.Format(dataValue.Value);
|
||||
CurrentValue = Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(dataValue.WrappedValue));
|
||||
CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
|
||||
SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
|
||||
ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
|
||||
|
||||
@@ -75,7 +75,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
|
||||
foreach (var item in rows)
|
||||
{
|
||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
||||
item.Value = Helpers.ValueFormatter.Format(ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||
}
|
||||
@@ -281,7 +281,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
|
||||
// Read current value to determine target type
|
||||
var currentDataValue = await _service.ReadValueAsync(nodeId);
|
||||
var currentValue = currentDataValue.Value;
|
||||
var currentValue = ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(currentDataValue.WrappedValue);
|
||||
|
||||
// Try parsing to the target type before writing
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user