diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs index 7dd0cc56..54465e3b 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs @@ -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 fields, NodeId? ofType) { var filter = new EventFilter(); + var clauses = new List(); 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; } /// Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as -. /// The field value. /// Its display text. - 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), diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs index 9bf63263..2dcb4d85 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs @@ -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(); 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(); 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 /// The status name, or 0xXXXXXXXX. 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}"], _ => [] }; diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs index f9678a2a..93121dac 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs @@ -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}"); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs index d8c202b3..67876f65 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/SubscribeCommand.cs @@ -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) { diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs index 63f88933..9b288ad4 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/WriteCommand.cs @@ -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); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Helpers/NodeIdParser.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Helpers/NodeIdParser.cs index 8fdc4c91..e472ab6b 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Helpers/NodeIdParser.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Helpers/NodeIdParser.cs @@ -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; } } \ No newline at end of file diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs index ffdaffd6..0f7eedc0 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs @@ -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); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs index c16c020e..d2ade3d8 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs @@ -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; diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs index ddb5ed0b..e140a924 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs @@ -12,21 +12,25 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter { private static readonly ILogger Logger = Log.ForContext(); private readonly Session _session; + private readonly IDisposable? _certificateManager; + private int _disposed; /// /// Wraps a live OPC UA session so the shared client can issue runtime operations through a testable adapter surface. /// /// The connected OPC UA session used for browsing, reads, writes, history, and subscriptions. - public DefaultSessionAdapter(Session session) + /// The per-connection certificate manager owned until session disposal. + public DefaultSessionAdapter(Session session, IDisposable? certificateManager = null) { _session = session; + _certificateManager = certificateManager; } /// public bool Connected => _session.Connected; /// - public string SessionId => _session.SessionId?.ToString() ?? string.Empty; + public string SessionId => _session.SessionId.IsNull ? string.Empty : _session.SessionId.ToString(); /// public string SessionName => _session.SessionName ?? string.Empty; @@ -35,7 +39,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty; /// - public string ServerName => _session.Endpoint?.Server?.ApplicationName?.Text ?? string.Empty; + public string ServerName => _session.Endpoint?.Server?.ApplicationName.Text ?? string.Empty; /// 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 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 } /// - public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync( + public async Task<(byte[]? ContinuationPoint, ArrayOf 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); } /// - public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync( + public async Task<(byte[]? ContinuationPoint, ArrayOf 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); + } + + /// + public async Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct) + { + await _session.BrowseNextAsync(null, true, [ByteString.From(continuationPoint)], ct).ConfigureAwait(false); } /// @@ -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(); var ops = new List>(); - 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()); + 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( (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 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 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 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(); - 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 /// 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(); } } /// @@ -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(); } } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs index 2cab8d81..0619ec1e 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs @@ -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); } } \ No newline at end of file diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs index 1662a946..8b18e17d 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs @@ -78,7 +78,7 @@ internal interface ISessionAdapter : IDisposable /// The node classes that should be returned to the caller. /// The cancellation token that aborts the browse request. /// A continuation point (or null when complete) and the references found. - Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync( + Task<(byte[]? ContinuationPoint, ArrayOf References)> BrowseAsync( NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default); /// @@ -87,9 +87,15 @@ internal interface ISessionAdapter : IDisposable /// The continuation token returned by a prior browse result page. /// The cancellation token that aborts the browse-next request. /// The next continuation point (or null when exhausted) and the additional references. - Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync( + Task<(byte[]? ContinuationPoint, ArrayOf References)> BrowseNextAsync( byte[] continuationPoint, CancellationToken ct = default); + /// Releases a browse cursor that the caller stops following. + /// The cursor owned by this session. + /// A bounded cleanup token independent of the cancelled browse. + /// The completion of the release request. + Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct); + /// /// Reads raw historical data. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs index 16bb72b6..51405b13 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs @@ -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; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadStatus.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadStatus.cs index 4a43d596..675c4330 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadStatus.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadStatus.cs @@ -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})."); } } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs index 559188de..a2746d0f 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/ValueConverter.cs @@ -1,3 +1,5 @@ +using Opc.Ua; + namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; /// @@ -6,6 +8,47 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; /// public static class ValueConverter { + /// Converts a supported client payload to an SDK value, detaching caller-owned buffers. + /// The existing CLR or SDK payload. + /// The corresponding OPC UA value. + /// The payload shape is unsupported. + 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; + } + + /// Returns the CLR representation expected by existing client object consumers. + /// The SDK value to inspect. + /// A CLR scalar or array, preserving null versus empty values. + 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 + }; + } + /// /// Converts a raw string value into the runtime type expected by the target node. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index e0681337..d6a4c817 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -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(); - 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 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; + } } /// A bad keep-alive from the session of connection . @@ -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 /// The populated . 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; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj index e6dc5edf..4ad02026 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj @@ -8,7 +8,6 @@ - diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Helpers/StatusCodeFormatter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Helpers/StatusCodeFormatter.cs index 2a43d215..8d1846c7 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Helpers/StatusCodeFormatter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Helpers/StatusCodeFormatter.cs @@ -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})"; } diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs index 09baf3a6..b848dd9f 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs @@ -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); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs index b883be03..15ca66cb 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs @@ -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"))); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs index cbad31d0..e312f11f 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs @@ -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"); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs index 18d2bee4..4bfc2b4b 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs @@ -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