diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/CommandBase.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/CommandBase.cs index 95a4328..98cfbca 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/CommandBase.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/CommandBase.cs @@ -9,7 +9,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; namespace ZB.MOM.WW.LmxOpcUa.Client.CLI; /// -/// Abstract base class for all CLI commands providing common connection options and helpers. +/// Abstract base class for all CLI commands providing common connection options and helpers. /// public abstract class CommandBase : ICommand { @@ -31,7 +31,8 @@ public abstract class CommandBase : ICommand [CommandOption("password", 'P', Description = "Password for authentication")] public string? Password { get; init; } - [CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt, signandencrypt (default: none)")] + [CommandOption("security", 'S', + Description = "Transport security: none, sign, encrypt, signandencrypt (default: none)")] public string Security { get; init; } = "none"; [CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")] @@ -40,8 +41,10 @@ public abstract class CommandBase : ICommand [CommandOption("verbose", Description = "Enable verbose/debug logging")] public bool Verbose { get; init; } + public abstract ValueTask ExecuteAsync(IConsole console); + /// - /// Creates a from the common command options. + /// Creates a from the common command options. /// protected ConnectionSettings CreateConnectionSettings() { @@ -64,10 +67,11 @@ public abstract class CommandBase : ICommand } /// - /// Creates a new , connects it using the common options, - /// and returns both the service and the connection info. + /// Creates a new , connects it using the common options, + /// and returns both the service and the connection info. /// - protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(CancellationToken ct) + protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync( + CancellationToken ct) { var service = _factory.Create(); var settings = CreateConnectionSettings(); @@ -76,24 +80,18 @@ public abstract class CommandBase : ICommand } /// - /// Configures Serilog based on the verbose flag. + /// Configures Serilog based on the verbose flag. /// protected void ConfigureLogging() { var config = new LoggerConfiguration(); if (Verbose) - { config.MinimumLevel.Debug() - .WriteTo.Console(); - } + .WriteTo.Console(); else - { config.MinimumLevel.Warning() - .WriteTo.Console(); - } + .WriteTo.Console(); Log.Logger = config.CreateLogger(); } - - public abstract ValueTask ExecuteAsync(IConsole console); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/AlarmsCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/AlarmsCommand.cs index 42cd6a5..32f38fc 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/AlarmsCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/AlarmsCommand.cs @@ -8,6 +8,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("alarms", Description = "Subscribe to alarm events")] public class AlarmsCommand : CommandBase { + public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + [CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")] public string? NodeId { get; init; } @@ -17,8 +21,6 @@ public class AlarmsCommand : CommandBase [CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")] public bool Refresh { get; init; } - public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory) { } - public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); @@ -49,7 +51,6 @@ public class AlarmsCommand : CommandBase $"Subscribed to alarm events (interval: {Interval}ms). Press Ctrl+C to stop."); if (Refresh) - { try { await service.RequestConditionRefreshAsync(ct); @@ -59,7 +60,6 @@ public class AlarmsCommand : CommandBase { await console.Output.WriteLineAsync($"Condition refresh not supported: {ex.Message}"); } - } // Wait until cancellation try @@ -71,7 +71,7 @@ public class AlarmsCommand : CommandBase // Expected on Ctrl+C } - await service.UnsubscribeAlarmsAsync(default); + await service.UnsubscribeAlarmsAsync(); await console.Output.WriteLineAsync("Unsubscribed."); } finally @@ -83,4 +83,4 @@ public class AlarmsCommand : CommandBase } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/BrowseCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/BrowseCommand.cs index c241abe..9fbfbaf 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/BrowseCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/BrowseCommand.cs @@ -3,13 +3,16 @@ using CliFx.Infrastructure; using Opc.Ua; using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers; using ZB.MOM.WW.LmxOpcUa.Client.Shared; -using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult; namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("browse", Description = "Browse the OPC UA address space")] public class BrowseCommand : CommandBase { + public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + [CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")] public string? NodeId { get; init; } @@ -19,8 +22,6 @@ public class BrowseCommand : CommandBase [CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")] public bool Recursive { get; init; } - public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory) { } - public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); @@ -76,4 +77,4 @@ public class BrowseCommand : CommandBase } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ConnectCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ConnectCommand.cs index 249905b..20da987 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ConnectCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ConnectCommand.cs @@ -7,7 +7,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("connect", Description = "Test connection to an OPC UA server")] public class ConnectCommand : CommandBase { - public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory) { } + public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } public override async ValueTask ExecuteAsync(IConsole console) { @@ -32,4 +34,4 @@ public class ConnectCommand : CommandBase } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/HistoryReadCommand.cs index 30be22e..711638b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/HistoryReadCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/HistoryReadCommand.cs @@ -10,6 +10,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("historyread", Description = "Read historical data from a node")] public class HistoryReadCommand : CommandBase { + public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] public string NodeId { get; init; } = default!; @@ -28,8 +32,6 @@ public class HistoryReadCommand : CommandBase [CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")] public double IntervalMs { get; init; } = 3600000; - public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { } - public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); @@ -103,4 +105,4 @@ public class HistoryReadCommand : CommandBase $"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End") }; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ReadCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ReadCommand.cs index 273812e..42dccb4 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ReadCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/ReadCommand.cs @@ -8,11 +8,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("read", Description = "Read a value from a node")] public class ReadCommand : CommandBase { + public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] public string NodeId { get; init; } = default!; - public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { } - public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); @@ -40,4 +42,4 @@ public class ReadCommand : CommandBase } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/RedundancyCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/RedundancyCommand.cs index 8cb0590..0ce3661 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/RedundancyCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/RedundancyCommand.cs @@ -7,7 +7,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("redundancy", Description = "Read redundancy state from an OPC UA server")] public class RedundancyCommand : CommandBase { - public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory) { } + public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } public override async ValueTask ExecuteAsync(IConsole console) { @@ -26,10 +28,7 @@ public class RedundancyCommand : CommandBase if (info.ServerUris.Length > 0) { await console.Output.WriteLineAsync("Server URIs:"); - foreach (var uri in info.ServerUris) - { - await console.Output.WriteLineAsync($" - {uri}"); - } + foreach (var uri in info.ServerUris) await console.Output.WriteLineAsync($" - {uri}"); } await console.Output.WriteLineAsync($"Application URI: {info.ApplicationUri}"); @@ -43,4 +42,4 @@ public class RedundancyCommand : CommandBase } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/SubscribeCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/SubscribeCommand.cs index fe74439..90c11b8 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/SubscribeCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/SubscribeCommand.cs @@ -8,14 +8,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("subscribe", Description = "Monitor a node for value changes")] public class SubscribeCommand : CommandBase { + public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + [CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)] public string NodeId { get; init; } = default!; [CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")] public int Interval { get; init; } = 1000; - public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory) { } - public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); @@ -47,7 +49,7 @@ public class SubscribeCommand : CommandBase // Expected on Ctrl+C } - await service.UnsubscribeAsync(nodeId, default); + await service.UnsubscribeAsync(nodeId); await console.Output.WriteLineAsync("Unsubscribed."); } finally @@ -59,4 +61,4 @@ public class SubscribeCommand : CommandBase } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/WriteCommand.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/WriteCommand.cs index 55bac7b..a836dfd 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/WriteCommand.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Commands/WriteCommand.cs @@ -10,14 +10,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands; [Command("write", Description = "Write a value to a node")] public class WriteCommand : CommandBase { + public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] public string NodeId { get; init; } = default!; [CommandOption("value", 'v', Description = "Value to write", IsRequired = true)] public string Value { get; init; } = default!; - public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory) { } - public override async ValueTask ExecuteAsync(IConsole console) { ConfigureLogging(); @@ -49,4 +51,4 @@ public class WriteCommand : CommandBase } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Helpers/NodeIdParser.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Helpers/NodeIdParser.cs index ef45b5a..9a25333 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Helpers/NodeIdParser.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Helpers/NodeIdParser.cs @@ -3,16 +3,16 @@ using Opc.Ua; namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers; /// -/// Parses node ID strings into OPC UA objects. -/// Supports standard OPC UA format (e.g., "ns=2;s=MyNode", "i=85") and bare numeric IDs. +/// Parses node ID strings into OPC UA objects. +/// Supports standard OPC UA format (e.g., "ns=2;s=MyNode", "i=85") and bare numeric IDs. /// public static class NodeIdParser { /// - /// Parses a string into a . Returns null if the input is null or empty. + /// Parses a string into a . Returns null if the input is null or empty. /// /// The node ID string to parse. - /// A parsed , or null if input is null/empty. + /// A parsed , or null if input is null/empty. /// Thrown when the string cannot be parsed as a valid NodeId. public static NodeId? Parse(string? nodeIdString) { @@ -24,7 +24,6 @@ public static class NodeIdParser // Standard OPC UA format: ns=X;s=..., ns=X;i=..., ns=X;g=..., ns=X;b=... // Also: s=..., i=..., g=..., b=... (namespace 0 implied) if (trimmed.Contains('=')) - { try { return NodeId.Parse(trimmed); @@ -33,22 +32,19 @@ public static class NodeIdParser { throw new FormatException($"Invalid node ID format: '{nodeIdString}'", ex); } - } // Bare numeric: treat as namespace 0, numeric identifier - if (uint.TryParse(trimmed, out var numericId)) - { - return new NodeId(numericId); - } + if (uint.TryParse(trimmed, out var numericId)) return new NodeId(numericId); - throw new FormatException($"Invalid node ID format: '{nodeIdString}'. Expected format like 'ns=2;s=MyNode', 'i=85', or a numeric ID."); + throw new FormatException( + $"Invalid node ID format: '{nodeIdString}'. Expected format like 'ns=2;s=MyNode', 'i=85', or a numeric ID."); } /// - /// Parses a string into a , throwing if the input is null or empty. + /// Parses a string into a , throwing if the input is null or empty. /// /// The node ID string to parse. - /// A parsed . + /// A parsed . /// Thrown when the input is null or empty. /// Thrown when the string cannot be parsed as a valid NodeId. public static NodeId ParseRequired(string? nodeIdString) @@ -58,4 +54,4 @@ public static class NodeIdParser throw new ArgumentException("Node ID is required but was not provided."); return result; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Program.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Program.cs index b729419..ab53949 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Program.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/Program.cs @@ -6,13 +6,10 @@ return await new CliApplicationBuilder() .UseTypeActivator(type => { // Inject the default factory into commands that derive from CommandBase - if (type.IsSubclassOf(typeof(CommandBase))) - { - return Activator.CreateInstance(type, CommandBase.DefaultFactory)!; - } + if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!; return Activator.CreateInstance(type)!; }) .SetExecutableName("lmxopcua-cli") .SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server") .Build() - .RunAsync(args); + .RunAsync(args); \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj index cba342c..b1be215 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.CLI/ZB.MOM.WW.LmxOpcUa.Client.CLI.csproj @@ -1,21 +1,21 @@ - - Exe - net10.0 - enable - enable - ZB.MOM.WW.LmxOpcUa.Client.CLI - + + Exe + net10.0 + enable + enable + ZB.MOM.WW.LmxOpcUa.Client.CLI + - - - - - + + + + + - - - + + + diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs index 69411af..8468e2b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Production implementation that builds a real OPC UA ApplicationConfiguration. +/// Production implementation that builds a real OPC UA ApplicationConfiguration. /// internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfigurationFactory { @@ -54,11 +54,9 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi await config.Validate(ApplicationType.Client); if (settings.AutoAcceptCertificates) - { config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; - } - if (settings.SecurityMode != Models.SecurityMode.None) + if (settings.SecurityMode != SecurityMode.None) { var app = new ApplicationInstance { @@ -72,4 +70,4 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi Logger.Debug("ApplicationConfiguration created for {EndpointUrl}", settings.EndpointUrl); return config; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs index 353b935..e1d1897 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs @@ -5,13 +5,14 @@ using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Production endpoint discovery that queries the real server. +/// Production endpoint discovery that queries the real server. /// internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery { private static readonly ILogger Logger = Log.ForContext(); - public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode) + public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, + MessageSecurityMode requestedMode) { if (requestedMode == MessageSecurityMode.None) { @@ -54,9 +55,10 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery { var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host }; best.EndpointUrl = builder.ToString(); - Logger.Debug("Rewrote endpoint host from {ServerHost} to {RequestedHost}", serverUri.Host, requestedUri.Host); + Logger.Debug("Rewrote endpoint host from {ServerHost} to {RequestedHost}", serverUri.Host, + requestedUri.Host); } return best; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs index 8f3af60..41a0e38 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs @@ -5,7 +5,7 @@ using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Production session adapter wrapping a real OPC UA Session. +/// Production session adapter wrapping a real OPC UA Session. /// internal sealed class DefaultSessionAdapter : ISessionAdapter { @@ -67,14 +67,14 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter true, nodeClassMask); - return (continuationPoint, references ?? new ReferenceDescriptionCollection()); + return (continuationPoint, references ?? []); } public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync( byte[] continuationPoint, CancellationToken ct) { var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint); - return (nextCp, nextRefs ?? new ReferenceDescriptionCollection()); + return (nextCp, nextRefs ?? []); } public async Task HasChildrenAsync(NodeId nodeId, CancellationToken ct) @@ -134,26 +134,24 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter break; if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData) - { allValues.AddRange(historyData.DataValues); - } continuationPoint = result.ContinuationPoint; - } - while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues); + } while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues); return allValues; } public async Task> HistoryReadAggregateAsync( - NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs, CancellationToken ct) + NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs, + CancellationToken ct) { var details = new ReadProcessedDetails { StartTime = startTime, EndTime = endTime, ProcessingInterval = intervalMs, - AggregateType = new NodeIdCollection { aggregateId } + AggregateType = [aggregateId] }; var nodesToRead = new HistoryReadValueIdCollection @@ -178,9 +176,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter if (!StatusCode.IsBad(result.StatusCode) && result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData) - { allValues.AddRange(historyData.DataValues); - } } return allValues; @@ -204,10 +200,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter { try { - if (_session.Connected) - { - _session.Close(); - } + if (_session.Connected) _session.Close(); } catch (Exception ex) { @@ -219,12 +212,12 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter { try { - if (_session.Connected) - { - _session.Close(); - } + if (_session.Connected) _session.Close(); } - catch { } + catch + { + } + _session.Dispose(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs index 9999595..1efa221 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionFactory.cs @@ -5,7 +5,7 @@ using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Production session factory that creates real OPC UA sessions. +/// Production session factory that creates real OPC UA sessions. /// internal sealed class DefaultSessionFactory : ISessionFactory { @@ -34,4 +34,4 @@ internal sealed class DefaultSessionFactory : ISessionFactory Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl); return new DefaultSessionAdapter(session); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs index ba4c2c6..89bb573 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSubscriptionAdapter.cs @@ -5,13 +5,13 @@ using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Production subscription adapter wrapping a real OPC UA Subscription. +/// Production subscription adapter wrapping a real OPC UA Subscription. /// internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter { private static readonly ILogger Logger = Log.ForContext(); - private readonly Subscription _subscription; private readonly Dictionary _monitoredItems = new(); + private readonly Subscription _subscription; public DefaultSubscriptionAdapter(Subscription subscription) { @@ -33,9 +33,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter item.Notification += (_, e) => { if (e.NotificationValue is MonitoredItemNotification notification) - { onDataChange(nodeId.ToString(), notification.Value); - } }; _subscription.AddItem(item); @@ -75,10 +73,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter item.Notification += (_, e) => { - if (e.NotificationValue is EventFieldList eventFields) - { - onEvent(eventFields); - } + if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields); }; _subscription.AddItem(item); @@ -106,6 +101,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter { Logger.Warning(ex, "Error deleting subscription"); } + _monitoredItems.Clear(); } @@ -115,7 +111,10 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter { _subscription.Delete(true); } - catch { } + catch + { + } + _monitoredItems.Clear(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IApplicationConfigurationFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IApplicationConfigurationFactory.cs index 71e57d1..2b590eb 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IApplicationConfigurationFactory.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IApplicationConfigurationFactory.cs @@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Creates and configures an OPC UA ApplicationConfiguration. +/// Creates and configures an OPC UA ApplicationConfiguration. /// internal interface IApplicationConfigurationFactory { /// - /// Creates a validated ApplicationConfiguration for the given connection settings. + /// Creates a validated ApplicationConfiguration for the given connection settings. /// Task CreateAsync(ConnectionSettings settings, CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IEndpointDiscovery.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IEndpointDiscovery.cs index f7145f8..5cad362 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IEndpointDiscovery.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/IEndpointDiscovery.cs @@ -3,13 +3,14 @@ using Opc.Ua; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Abstracts OPC UA endpoint discovery for testability. +/// Abstracts OPC UA endpoint discovery for testability. /// internal interface IEndpointDiscovery { /// - /// Discovers endpoints at the given URL and returns the best match for the requested security mode. - /// Also rewrites the endpoint URL hostname to match the requested URL when they differ. + /// Discovers endpoints at the given URL and returns the best match for the requested security mode. + /// Also rewrites the endpoint URL hostname to match the requested URL when they differ. /// - EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode); -} + EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, + MessageSecurityMode requestedMode); +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs index 056380f..c2e6cc1 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs @@ -3,7 +3,7 @@ using Opc.Ua; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Abstracts the OPC UA session for read, write, browse, history, and subscription operations. +/// Abstracts the OPC UA session for read, write, browse, history, and subscription operations. /// internal interface ISessionAdapter : IDisposable { @@ -17,7 +17,7 @@ internal interface ISessionAdapter : IDisposable NamespaceTable NamespaceUris { get; } /// - /// Registers a keep-alive callback. The callback receives true when the session is healthy, false on failure. + /// Registers a keep-alive callback. The callback receives true when the session is healthy, false on failure. /// void RegisterKeepAliveHandler(Action callback); @@ -25,37 +25,39 @@ internal interface ISessionAdapter : IDisposable Task WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default); /// - /// Browses forward hierarchical references from the given node. - /// Returns (continuationPoint, references). + /// Browses forward hierarchical references from the given node. + /// Returns (continuationPoint, references). /// Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync( NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default); /// - /// Continues a browse from a continuation point. + /// Continues a browse from a continuation point. /// Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync( byte[] continuationPoint, CancellationToken ct = default); /// - /// Checks whether a node has any forward hierarchical child references. + /// Checks whether a node has any forward hierarchical child references. /// Task HasChildrenAsync(NodeId nodeId, CancellationToken ct = default); /// - /// Reads raw historical data. + /// Reads raw historical data. /// - Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default); + Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, + int maxValues, CancellationToken ct = default); /// - /// Reads processed/aggregate historical data. + /// Reads processed/aggregate historical data. /// - Task> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs, CancellationToken ct = default); + Task> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, + NodeId aggregateId, double intervalMs, CancellationToken ct = default); /// - /// Creates a subscription adapter for this session. + /// Creates a subscription adapter for this session. /// Task CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default); Task CloseAsync(CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionFactory.cs index 92ed3b6..05b7795 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionFactory.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionFactory.cs @@ -3,12 +3,12 @@ using Opc.Ua; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Creates OPC UA sessions from a configured endpoint. +/// Creates OPC UA sessions from a configured endpoint. /// internal interface ISessionFactory { /// - /// Creates a session to the given endpoint. + /// Creates a session to the given endpoint. /// /// The application configuration. /// The configured endpoint. @@ -24,4 +24,4 @@ internal interface ISessionFactory uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISubscriptionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISubscriptionAdapter.cs index 68bc974..d02ac40 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISubscriptionAdapter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISubscriptionAdapter.cs @@ -3,29 +3,30 @@ using Opc.Ua; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; /// -/// Abstracts OPC UA subscription and monitored item management. +/// Abstracts OPC UA subscription and monitored item management. /// internal interface ISubscriptionAdapter : IDisposable { uint SubscriptionId { get; } /// - /// Adds a data-change monitored item and returns its client handle for tracking. + /// Adds a data-change monitored item and returns its client handle for tracking. /// /// The node to monitor. /// The sampling interval in milliseconds. /// Callback when data changes. Receives (nodeIdString, DataValue). /// Cancellation token. /// A client handle that can be used to remove the item. - Task AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, Action onDataChange, CancellationToken ct = default); + Task AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, + Action onDataChange, CancellationToken ct = default); /// - /// Removes a previously added monitored item by its client handle. + /// Removes a previously added monitored item by its client handle. /// Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default); /// - /// Adds an event monitored item with the given event filter. + /// Adds an event monitored item with the given event filter. /// /// The node to monitor for events. /// The sampling interval. @@ -33,15 +34,16 @@ internal interface ISubscriptionAdapter : IDisposable /// Callback when events arrive. Receives the event field list. /// Cancellation token. /// A client handle for the monitored item. - Task AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter, Action onEvent, CancellationToken ct = default); + Task AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter, + Action onEvent, CancellationToken ct = default); /// - /// Requests a condition refresh for this subscription. + /// Requests a condition refresh for this subscription. /// Task ConditionRefreshAsync(CancellationToken ct = default); /// - /// Removes all monitored items and deletes the subscription. + /// Removes all monitored items and deletes the subscription. /// Task DeleteAsync(CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs index ec3f90e..e0c0c21 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs @@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers; /// -/// Maps the library's AggregateType enum to OPC UA aggregate function NodeIds. +/// Maps the library's AggregateType enum to OPC UA aggregate function NodeIds. /// public static class AggregateTypeMapper { /// - /// Returns the OPC UA NodeId for the specified aggregate type. + /// Returns the OPC UA NodeId for the specified aggregate type. /// public static NodeId ToNodeId(AggregateType aggregate) { @@ -24,4 +24,4 @@ public static class AggregateTypeMapper _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.") }; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/FailoverUrlParser.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/FailoverUrlParser.cs index 109acb5..cdab081 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/FailoverUrlParser.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/FailoverUrlParser.cs @@ -1,13 +1,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers; /// -/// Parses and normalizes failover URL sets for redundant OPC UA connections. +/// Parses and normalizes failover URL sets for redundant OPC UA connections. /// public static class FailoverUrlParser { /// - /// Parses a comma-separated failover URL string, prepending the primary URL. - /// Trims whitespace and deduplicates. + /// Parses a comma-separated failover URL string, prepending the primary URL. + /// Trims whitespace and deduplicates. /// /// The primary endpoint URL. /// Optional comma-separated failover URLs. @@ -15,7 +15,7 @@ public static class FailoverUrlParser public static string[] Parse(string primaryUrl, string? failoverCsv) { if (string.IsNullOrWhiteSpace(failoverCsv)) - return new[] { primaryUrl }; + return [primaryUrl]; var urls = new List { primaryUrl }; foreach (var url in failoverCsv.Split(',', StringSplitOptions.RemoveEmptyEntries)) @@ -24,11 +24,12 @@ public static class FailoverUrlParser if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) urls.Add(trimmed); } + return urls.ToArray(); } /// - /// Builds a failover URL set from the primary URL and an optional array of failover URLs. + /// Builds a failover URL set from the primary URL and an optional array of failover URLs. /// /// The primary endpoint URL. /// Optional failover URLs. @@ -36,7 +37,7 @@ public static class FailoverUrlParser public static string[] Parse(string primaryUrl, string[]? failoverUrls) { if (failoverUrls == null || failoverUrls.Length == 0) - return new[] { primaryUrl }; + return [primaryUrl]; var urls = new List { primaryUrl }; foreach (var url in failoverUrls) @@ -45,6 +46,7 @@ public static class FailoverUrlParser if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) urls.Add(trimmed); } + return urls.ToArray(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/SecurityModeMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/SecurityModeMapper.cs index 0ad53b1..7851ae4 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/SecurityModeMapper.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/SecurityModeMapper.cs @@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers; /// -/// Maps between the library's SecurityMode enum and OPC UA SDK MessageSecurityMode. +/// Maps between the library's SecurityMode enum and OPC UA SDK MessageSecurityMode. /// public static class SecurityModeMapper { /// - /// Converts a to an OPC UA . + /// Converts a to an OPC UA . /// public static MessageSecurityMode ToMessageSecurityMode(SecurityMode mode) { @@ -23,7 +23,7 @@ public static class SecurityModeMapper } /// - /// Parses a string to a value, case-insensitively. + /// Parses a string to a value, case-insensitively. /// /// The string to parse (e.g., "none", "sign", "encrypt", "signandencrypt"). /// The corresponding SecurityMode. @@ -39,4 +39,4 @@ public static class SecurityModeMapper $"Unknown security mode '{value}'. Valid values: none, sign, encrypt, signandencrypt") }; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/ValueConverter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/ValueConverter.cs index afc904e..c53c480 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/ValueConverter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Helpers/ValueConverter.cs @@ -1,13 +1,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers; /// -/// Converts raw string values into typed values based on the current value's runtime type. -/// Ported from the CLI tool's OpcUaHelper.ConvertValue. +/// Converts raw string values into typed values based on the current value's runtime type. +/// Ported from the CLI tool's OpcUaHelper.ConvertValue. /// public static class ValueConverter { /// - /// Converts a raw string value into the runtime type expected by the target node. + /// Converts a raw string value into the runtime type expected by the target node. /// /// The raw string supplied by the user. /// The current node value used to infer the target type. May be null. @@ -29,4 +29,4 @@ public static class ValueConverter _ => rawValue }; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs index 9cbe49e..6e7753f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs @@ -1,22 +1,23 @@ using Opc.Ua; using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; +using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared; /// -/// Shared OPC UA client service contract for CLI and UI consumers. +/// Shared OPC UA client service contract for CLI and UI consumers. /// public interface IOpcUaClientService : IDisposable { - Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default); - Task DisconnectAsync(CancellationToken ct = default); bool IsConnected { get; } ConnectionInfo? CurrentConnectionInfo { get; } + Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default); + Task DisconnectAsync(CancellationToken ct = default); Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default); Task WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default); - Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default); + Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default); Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default); Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default); @@ -25,12 +26,15 @@ public interface IOpcUaClientService : IDisposable Task UnsubscribeAlarmsAsync(CancellationToken ct = default); Task RequestConditionRefreshAsync(CancellationToken ct = default); - Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default); - Task> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default); + Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, + int maxValues = 1000, CancellationToken ct = default); + + Task> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, + AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default); Task GetRedundancyInfoAsync(CancellationToken ct = default); event EventHandler? DataChanged; event EventHandler? AlarmEvent; event EventHandler? ConnectionStateChanged; -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientServiceFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientServiceFactory.cs index f205fac..66a66fd 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientServiceFactory.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientServiceFactory.cs @@ -1,9 +1,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared; /// -/// Factory for creating instances. +/// Factory for creating instances. /// public interface IOpcUaClientServiceFactory { IOpcUaClientService Create(); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AggregateType.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AggregateType.cs index d3cc1d1..2aa7e53 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AggregateType.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AggregateType.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Aggregate functions for processed history reads. +/// Aggregate functions for processed history reads. /// public enum AggregateType { @@ -22,4 +22,4 @@ public enum AggregateType /// Last value in the interval. End -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs index 0938bea..307f3e1 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs @@ -1,10 +1,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Event data for an alarm or condition notification from the OPC UA server. +/// Event data for an alarm or condition notification from the OPC UA server. /// public sealed class AlarmEventArgs : EventArgs { + public AlarmEventArgs( + string sourceName, + string conditionName, + ushort severity, + string message, + bool retain, + bool activeState, + bool ackedState, + DateTime time) + { + SourceName = sourceName; + ConditionName = conditionName; + Severity = severity; + Message = message; + Retain = retain; + ActiveState = activeState; + AckedState = ackedState; + Time = time; + } + /// The name of the source object that raised the alarm. public string SourceName { get; } @@ -28,24 +48,4 @@ public sealed class AlarmEventArgs : EventArgs /// The time the event occurred. public DateTime Time { get; } - - public AlarmEventArgs( - string sourceName, - string conditionName, - ushort severity, - string message, - bool retain, - bool activeState, - bool ackedState, - DateTime time) - { - SourceName = sourceName; - ConditionName = conditionName; - Severity = severity; - Message = message; - Retain = retain; - ActiveState = activeState; - AckedState = ackedState; - Time = time; - } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/BrowseResult.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/BrowseResult.cs index c1694a8..aca7b85 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/BrowseResult.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/BrowseResult.cs @@ -1,30 +1,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Represents a single node in the browse result set. +/// Represents a single node in the browse result set. /// public sealed class BrowseResult { - /// - /// The string representation of the node's NodeId. - /// - public string NodeId { get; } - - /// - /// The display name of the node. - /// - public string DisplayName { get; } - - /// - /// The node class (e.g., "Object", "Variable", "Method"). - /// - public string NodeClass { get; } - - /// - /// Whether the node has child references. - /// - public bool HasChildren { get; } - public BrowseResult(string nodeId, string displayName, string nodeClass, bool hasChildren) { NodeId = nodeId; @@ -32,4 +12,24 @@ public sealed class BrowseResult NodeClass = nodeClass; HasChildren = hasChildren; } -} + + /// + /// The string representation of the node's NodeId. + /// + public string NodeId { get; } + + /// + /// The display name of the node. + /// + public string DisplayName { get; } + + /// + /// The node class (e.g., "Object", "Variable", "Method"). + /// + public string NodeClass { get; } + + /// + /// Whether the node has child references. + /// + public bool HasChildren { get; } +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionInfo.cs index 8e01606..40406a0 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionInfo.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionInfo.cs @@ -1,10 +1,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Information about the current OPC UA session. +/// Information about the current OPC UA session. /// public sealed class ConnectionInfo { + public ConnectionInfo( + string endpointUrl, + string serverName, + string securityMode, + string securityPolicyUri, + string sessionId, + string sessionName) + { + EndpointUrl = endpointUrl; + ServerName = serverName; + SecurityMode = securityMode; + SecurityPolicyUri = securityPolicyUri; + SessionId = sessionId; + SessionName = sessionName; + } + /// The endpoint URL of the connected server. public string EndpointUrl { get; } @@ -22,20 +38,4 @@ public sealed class ConnectionInfo /// The session name. public string SessionName { get; } - - public ConnectionInfo( - string endpointUrl, - string serverName, - string securityMode, - string securityPolicyUri, - string sessionId, - string sessionName) - { - EndpointUrl = endpointUrl; - ServerName = serverName; - SecurityMode = securityMode; - SecurityPolicyUri = securityPolicyUri; - SessionId = sessionId; - SessionName = sessionName; - } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionSettings.cs index f6e15c3..37d3f63 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionSettings.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionSettings.cs @@ -1,54 +1,54 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Settings for establishing an OPC UA client connection. +/// Settings for establishing an OPC UA client connection. /// public sealed class ConnectionSettings { /// - /// The primary OPC UA endpoint URL. + /// The primary OPC UA endpoint URL. /// public string EndpointUrl { get; set; } = string.Empty; /// - /// Optional failover endpoint URLs for redundancy. + /// Optional failover endpoint URLs for redundancy. /// public string[]? FailoverUrls { get; set; } /// - /// Optional username for authentication. + /// Optional username for authentication. /// public string? Username { get; set; } /// - /// Optional password for authentication. + /// Optional password for authentication. /// public string? Password { get; set; } /// - /// Transport security mode. Defaults to . + /// Transport security mode. Defaults to . /// public SecurityMode SecurityMode { get; set; } = SecurityMode.None; /// - /// Session timeout in seconds. Defaults to 60. + /// Session timeout in seconds. Defaults to 60. /// public int SessionTimeoutSeconds { get; set; } = 60; /// - /// Whether to automatically accept untrusted server certificates. Defaults to true. + /// Whether to automatically accept untrusted server certificates. Defaults to true. /// public bool AutoAcceptCertificates { get; set; } = true; /// - /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData. + /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData. /// public string CertificateStorePath { get; set; } = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LmxOpcUaClient", "pki"); /// - /// Validates the settings and throws if any required values are missing or invalid. + /// Validates the settings and throws if any required values are missing or invalid. /// /// Thrown when settings are invalid. public void Validate() @@ -57,9 +57,10 @@ public sealed class ConnectionSettings throw new ArgumentException("EndpointUrl must not be null or empty.", nameof(EndpointUrl)); if (SessionTimeoutSeconds <= 0) - throw new ArgumentException("SessionTimeoutSeconds must be greater than zero.", nameof(SessionTimeoutSeconds)); + throw new ArgumentException("SessionTimeoutSeconds must be greater than zero.", + nameof(SessionTimeoutSeconds)); if (SessionTimeoutSeconds > 3600) throw new ArgumentException("SessionTimeoutSeconds must not exceed 3600.", nameof(SessionTimeoutSeconds)); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionState.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionState.cs index bdf6158..29b435f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionState.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionState.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Represents the current state of the OPC UA client connection. +/// Represents the current state of the OPC UA client connection. /// public enum ConnectionState { @@ -16,4 +16,4 @@ public enum ConnectionState /// Connection was lost and reconnection is in progress. Reconnecting -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionStateChangedEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionStateChangedEventArgs.cs index 6645e7b..d490b22 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionStateChangedEventArgs.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/ConnectionStateChangedEventArgs.cs @@ -1,10 +1,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Event data raised when the client connection state changes. +/// Event data raised when the client connection state changes. /// public sealed class ConnectionStateChangedEventArgs : EventArgs { + public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string endpointUrl) + { + OldState = oldState; + NewState = newState; + EndpointUrl = endpointUrl; + } + /// The previous connection state. public ConnectionState OldState { get; } @@ -13,11 +20,4 @@ public sealed class ConnectionStateChangedEventArgs : EventArgs /// The endpoint URL associated with the state change. public string EndpointUrl { get; } - - public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string endpointUrl) - { - OldState = oldState; - NewState = newState; - EndpointUrl = endpointUrl; - } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/DataChangedEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/DataChangedEventArgs.cs index cd3aeb8..0758fcd 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/DataChangedEventArgs.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/DataChangedEventArgs.cs @@ -3,19 +3,19 @@ using Opc.Ua; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Event data for a monitored data value change. +/// Event data for a monitored data value change. /// public sealed class DataChangedEventArgs : EventArgs { - /// The string representation of the node that changed. - public string NodeId { get; } - - /// The new data value from the server. - public DataValue Value { get; } - public DataChangedEventArgs(string nodeId, DataValue value) { NodeId = nodeId; Value = value; } -} + + /// The string representation of the node that changed. + public string NodeId { get; } + + /// The new data value from the server. + public DataValue Value { get; } +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/RedundancyInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/RedundancyInfo.cs index a718ed2..f2e8a7e 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/RedundancyInfo.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/RedundancyInfo.cs @@ -1,10 +1,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Redundancy information read from the server. +/// Redundancy information read from the server. /// public sealed class RedundancyInfo { + public RedundancyInfo(string mode, byte serviceLevel, string[] serverUris, string applicationUri) + { + Mode = mode; + ServiceLevel = serviceLevel; + ServerUris = serverUris; + ApplicationUri = applicationUri; + } + /// The redundancy mode (e.g., "None", "Cold", "Warm", "Hot"). public string Mode { get; } @@ -16,12 +24,4 @@ public sealed class RedundancyInfo /// The application URI of the connected server. public string ApplicationUri { get; } - - public RedundancyInfo(string mode, byte serviceLevel, string[] serverUris, string applicationUri) - { - Mode = mode; - ServiceLevel = serviceLevel; - ServerUris = serverUris; - ApplicationUri = applicationUri; - } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/SecurityMode.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/SecurityMode.cs index 4313ec0..0be1bb3 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/SecurityMode.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/SecurityMode.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; /// -/// Transport security mode for the OPC UA connection. +/// Transport security mode for the OPC UA connection. /// public enum SecurityMode { @@ -13,4 +13,4 @@ public enum SecurityMode /// Messages are signed and encrypted. SignAndEncrypt -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs index b399111..6e88860 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs @@ -1,45 +1,42 @@ +using System.Text; using Opc.Ua; using Serilog; using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters; using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers; using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; +using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared; /// -/// Full implementation of using adapter abstractions for testability. +/// Full implementation of using adapter abstractions for testability. /// public sealed class OpcUaClientService : IOpcUaClientService { private static readonly ILogger Logger = Log.ForContext(); - private readonly IApplicationConfigurationFactory _configFactory; - private readonly IEndpointDiscovery _endpointDiscovery; - private readonly ISessionFactory _sessionFactory; - - private ISessionAdapter? _session; - private ISubscriptionAdapter? _dataSubscription; - private ISubscriptionAdapter? _alarmSubscription; - private ConnectionState _state = ConnectionState.Disconnected; - private ConnectionSettings? _settings; - private string[]? _allEndpointUrls; - private int _currentEndpointIndex; - private bool _disposed; - // Track active data subscriptions for replay after failover private readonly Dictionary _activeDataSubscriptions = new(); + + private readonly IApplicationConfigurationFactory _configFactory; + private readonly IEndpointDiscovery _endpointDiscovery; + + private readonly ISessionFactory _sessionFactory; + // Track alarm subscription state for replay after failover private (NodeId? SourceNodeId, int IntervalMs)? _activeAlarmSubscription; + private ISubscriptionAdapter? _alarmSubscription; + private string[]? _allEndpointUrls; + private int _currentEndpointIndex; + private ISubscriptionAdapter? _dataSubscription; + private bool _disposed; - public event EventHandler? DataChanged; - public event EventHandler? AlarmEvent; - public event EventHandler? ConnectionStateChanged; - - public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true; - public ConnectionInfo? CurrentConnectionInfo { get; private set; } + private ISessionAdapter? _session; + private ConnectionSettings? _settings; + private ConnectionState _state = ConnectionState.Disconnected; /// - /// Creates a new OpcUaClientService with the specified adapter dependencies. + /// Creates a new OpcUaClientService with the specified adapter dependencies. /// internal OpcUaClientService( IApplicationConfigurationFactory configFactory, @@ -52,7 +49,7 @@ public sealed class OpcUaClientService : IOpcUaClientService } /// - /// Creates a new OpcUaClientService with default production adapters. + /// Creates a new OpcUaClientService with default production adapters. /// public OpcUaClientService() : this( @@ -62,6 +59,13 @@ public sealed class OpcUaClientService : IOpcUaClientService { } + public event EventHandler? DataChanged; + public event EventHandler? AlarmEvent; + public event EventHandler? ConnectionStateChanged; + + public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true; + public ConnectionInfo? CurrentConnectionInfo { get; private set; } + public async Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default) { ThrowIfDisposed(); @@ -80,10 +84,7 @@ public sealed class OpcUaClientService : IOpcUaClientService session.RegisterKeepAliveHandler(isGood => { - if (!isGood) - { - _ = HandleKeepAliveFailureAsync(); - } + if (!isGood) _ = HandleKeepAliveFailureAsync(); }); CurrentConnectionInfo = BuildConnectionInfo(session); @@ -112,11 +113,13 @@ public sealed class OpcUaClientService : IOpcUaClientService await _dataSubscription.DeleteAsync(ct); _dataSubscription = null; } + if (_alarmSubscription != null) { await _alarmSubscription.DeleteAsync(ct); _alarmSubscription = null; } + if (_session != null) { await _session.CloseAsync(ct); @@ -150,7 +153,7 @@ public sealed class OpcUaClientService : IOpcUaClientService ThrowIfNotConnected(); // Read current value for type coercion when value is a string - object typedValue = value; + var typedValue = value; if (value is string rawString) { var currentDataValue = await _session!.ReadValueAsync(nodeId, ct); @@ -161,14 +164,15 @@ public sealed class OpcUaClientService : IOpcUaClientService return await _session!.WriteValueAsync(nodeId, dataValue, ct); } - public async Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default) + public async Task> BrowseAsync(NodeId? parentNodeId = null, + CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); var startNode = parentNodeId ?? ObjectIds.ObjectsFolder; var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method; - var results = new List(); + var results = new List(); var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct); @@ -180,7 +184,7 @@ public sealed class OpcUaClientService : IOpcUaClientService var hasChildren = reference.NodeClass == NodeClass.Object && await _session.HasChildrenAsync(childNodeId, ct); - results.Add(new Models.BrowseResult( + results.Add(new BrowseResult( reference.NodeId.ToString(), reference.DisplayName?.Text ?? string.Empty, reference.NodeClass.ToString(), @@ -188,13 +192,9 @@ public sealed class OpcUaClientService : IOpcUaClientService } if (continuationPoint != null && continuationPoint.Length > 0) - { (continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct); - } else - { break; - } } return results; @@ -209,10 +209,7 @@ public sealed class OpcUaClientService : IOpcUaClientService if (_activeDataSubscriptions.ContainsKey(nodeIdStr)) return; // Already subscribed - if (_dataSubscription == null) - { - _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct); - } + if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct); var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync( nodeId, intervalMs, OnDataChangeNotification, ct); @@ -229,16 +226,14 @@ public sealed class OpcUaClientService : IOpcUaClientService if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out var sub)) return; // Not subscribed, safe to ignore - if (_dataSubscription != null) - { - await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct); - } + if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct); _activeDataSubscriptions.Remove(nodeIdStr); Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId); } - public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default) + public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, + CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); @@ -305,16 +300,18 @@ public sealed class OpcUaClientService : IOpcUaClientService ThrowIfDisposed(); ThrowIfNotConnected(); - var redundancySupportValue = await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct); + var redundancySupportValue = + await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct); var redundancyMode = ((RedundancySupport)(int)redundancySupportValue.Value).ToString(); var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct); var serviceLevel = (byte)serviceLevelValue.Value; - string[] serverUris = Array.Empty(); + string[] serverUris = []; try { - var serverUriArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct); + var serverUriArrayValue = + await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct); if (serverUriArrayValue.Value is string[] uris) serverUris = uris; } @@ -323,7 +320,7 @@ public sealed class OpcUaClientService : IOpcUaClientService // ServerUriArray may not be present when RedundancySupport is None } - string applicationUri = string.Empty; + var applicationUri = string.Empty; try { var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct); @@ -354,7 +351,8 @@ public sealed class OpcUaClientService : IOpcUaClientService // --- Private helpers --- - private async Task ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl, CancellationToken ct) + private async Task ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl, + CancellationToken ct) { // Create a settings copy with the current endpoint URL var effectiveSettings = new ConnectionSettings @@ -372,12 +370,13 @@ public sealed class OpcUaClientService : IOpcUaClientService var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode); var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode); - UserIdentity identity = settings.Username != null - ? new UserIdentity(settings.Username, System.Text.Encoding.UTF8.GetBytes(settings.Password ?? "")) + 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, "LmxOpcUaClient", sessionTimeoutMs, identity, ct); + return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity, + ct); } private async Task HandleKeepAliveFailureAsync() @@ -392,9 +391,17 @@ public sealed class OpcUaClientService : IOpcUaClientService // Close old session if (_session != null) { - try { _session.Dispose(); } catch { } + try + { + _session.Dispose(); + } + catch + { + } + _session = null; } + _dataSubscription = null; _alarmSubscription = null; @@ -405,7 +412,7 @@ public sealed class OpcUaClientService : IOpcUaClientService } // Try each endpoint - for (int attempt = 0; attempt < _allEndpointUrls.Length; attempt++) + for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++) { _currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length; var url = _allEndpointUrls[_currentEndpointIndex]; @@ -418,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService session.RegisterKeepAliveHandler(isGood => { - if (!isGood) { _ = HandleKeepAliveFailureAsync(); } + if (!isGood) _ = HandleKeepAliveFailureAsync(); }); CurrentConnectionInfo = BuildConnectionInfo(session); @@ -448,7 +455,6 @@ public sealed class OpcUaClientService : IOpcUaClientService _activeDataSubscriptions.Clear(); foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions) - { try { if (_dataSubscription == null) @@ -462,7 +468,6 @@ public sealed class OpcUaClientService : IOpcUaClientService { Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr); } - } } // Replay alarm subscription @@ -569,4 +574,4 @@ public sealed class OpcUaClientService : IOpcUaClientService if (_state != ConnectionState.Connected || _session == null) throw new InvalidOperationException("Not connected to an OPC UA server."); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientServiceFactory.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientServiceFactory.cs index 5f0d52d..4dd5b6f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientServiceFactory.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientServiceFactory.cs @@ -1,7 +1,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared; /// -/// Default factory that creates instances with production adapters. +/// Default factory that creates instances with production adapters. /// public sealed class OpcUaClientServiceFactory : IOpcUaClientServiceFactory { @@ -9,4 +9,4 @@ public sealed class OpcUaClientServiceFactory : IOpcUaClientServiceFactory { return new OpcUaClientService(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj index 176672f..272f03a 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj @@ -1,19 +1,19 @@ - - net10.0 - enable - enable - ZB.MOM.WW.LmxOpcUa.Client.Shared - + + net10.0 + enable + enable + ZB.MOM.WW.LmxOpcUa.Client.Shared + - - - - + + + + - - - + + + diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml index 888cfec..c7d219f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml @@ -6,4 +6,4 @@ - + \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml.cs index a0f2e30..6d1d8de 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/App.axaml.cs @@ -8,7 +8,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Views; namespace ZB.MOM.WW.LmxOpcUa.Client.UI; -public partial class App : Application +public class App : Application { public override void Initialize() { @@ -30,4 +30,4 @@ public partial class App : Application base.OnFrameworkInitializationCompleted(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs index 419b9b3..36aa1c0 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs @@ -2,15 +2,20 @@ using Avalonia; namespace ZB.MOM.WW.LmxOpcUa.Client.UI; -class Program +internal class Program { [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() + { + return AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); -} + } +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/AvaloniaUiDispatcher.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/AvaloniaUiDispatcher.cs index e4cc9d8..e9cdd3f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/AvaloniaUiDispatcher.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/AvaloniaUiDispatcher.cs @@ -3,7 +3,7 @@ using Avalonia.Threading; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services; /// -/// Dispatches actions to the Avalonia UI thread. +/// Dispatches actions to the Avalonia UI thread. /// public sealed class AvaloniaUiDispatcher : IUiDispatcher { @@ -11,4 +11,4 @@ public sealed class AvaloniaUiDispatcher : IUiDispatcher { Dispatcher.UIThread.Post(action); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/IUiDispatcher.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/IUiDispatcher.cs index 6f5b3e5..886c037 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/IUiDispatcher.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/IUiDispatcher.cs @@ -1,12 +1,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services; /// -/// Abstraction for dispatching actions to the UI thread. +/// Abstraction for dispatching actions to the UI thread. /// public interface IUiDispatcher { /// - /// Posts an action to be executed on the UI thread. + /// Posts an action to be executed on the UI thread. /// void Post(Action action); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/SynchronousUiDispatcher.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/SynchronousUiDispatcher.cs index fd253ee..4e6e9ee 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/SynchronousUiDispatcher.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/SynchronousUiDispatcher.cs @@ -1,8 +1,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services; /// -/// Dispatcher that executes actions synchronously on the calling thread. -/// Used for unit testing where no UI thread is available. +/// Dispatcher that executes actions synchronously on the calling thread. +/// Used for unit testing where no UI thread is available. /// public sealed class SynchronousUiDispatcher : IUiDispatcher { @@ -10,4 +10,4 @@ public sealed class SynchronousUiDispatcher : IUiDispatcher { action(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs index 97f11d3..7bc02f7 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs @@ -3,19 +3,10 @@ using CommunityToolkit.Mvvm.ComponentModel; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// Represents a single alarm event row. +/// Represents a single alarm event row. /// -public partial class AlarmEventViewModel : ObservableObject +public class AlarmEventViewModel : ObservableObject { - public string SourceName { get; } - public string ConditionName { get; } - public ushort Severity { get; } - public string Message { get; } - public bool Retain { get; } - public bool ActiveState { get; } - public bool AckedState { get; } - public DateTime Time { get; } - public AlarmEventViewModel( string sourceName, string conditionName, @@ -35,4 +26,13 @@ public partial class AlarmEventViewModel : ObservableObject AckedState = ackedState; Time = time; } -} + + public string SourceName { get; } + public string ConditionName { get; } + public ushort Severity { get; } + public string Message { get; } + public bool Retain { get; } + public bool ActiveState { get; } + public bool AckedState { get; } + public DateTime Time { get; } +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs index 6c73bc3..4596c86 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs @@ -9,27 +9,14 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// ViewModel for the alarms panel. +/// ViewModel for the alarms panel. /// public partial class AlarmsViewModel : ObservableObject { - private readonly IOpcUaClientService _service; private readonly IUiDispatcher _dispatcher; + private readonly IOpcUaClientService _service; - /// Received alarm events. - public ObservableCollection AlarmEvents { get; } = new(); - - [ObservableProperty] - private string? _monitoredNodeIdText; - - [ObservableProperty] - private int _interval = 1000; - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))] - [NotifyCanExecuteChangedFor(nameof(UnsubscribeCommand))] - [NotifyCanExecuteChangedFor(nameof(RefreshCommand))] - private bool _isSubscribed; + [ObservableProperty] private int _interval = 1000; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))] @@ -37,6 +24,14 @@ public partial class AlarmsViewModel : ObservableObject [NotifyCanExecuteChangedFor(nameof(RefreshCommand))] private bool _isConnected; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SubscribeCommand))] + [NotifyCanExecuteChangedFor(nameof(UnsubscribeCommand))] + [NotifyCanExecuteChangedFor(nameof(RefreshCommand))] + private bool _isSubscribed; + + [ObservableProperty] private string? _monitoredNodeIdText; + public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) { _service = service; @@ -44,6 +39,9 @@ public partial class AlarmsViewModel : ObservableObject _service.AlarmEvent += OnAlarmEvent; } + /// Received alarm events. + public ObservableCollection AlarmEvents { get; } = []; + private void OnAlarmEvent(object? sender, AlarmEventArgs e) { _dispatcher.Post(() => @@ -60,14 +58,17 @@ public partial class AlarmsViewModel : ObservableObject }); } - private bool CanSubscribe() => IsConnected && !IsSubscribed; + private bool CanSubscribe() + { + return IsConnected && !IsSubscribed; + } [RelayCommand(CanExecute = nameof(CanSubscribe))] private async Task SubscribeAsync() { try { - NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText) + var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText) ? null : NodeId.Parse(MonitoredNodeIdText); @@ -80,7 +81,10 @@ public partial class AlarmsViewModel : ObservableObject } } - private bool CanUnsubscribe() => IsConnected && IsSubscribed; + private bool CanUnsubscribe() + { + return IsConnected && IsSubscribed; + } [RelayCommand(CanExecute = nameof(CanUnsubscribe))] private async Task UnsubscribeAsync() @@ -110,7 +114,7 @@ public partial class AlarmsViewModel : ObservableObject } /// - /// Clears alarm events and resets state. + /// Clears alarm events and resets state. /// public void Clear() { @@ -119,10 +123,10 @@ public partial class AlarmsViewModel : ObservableObject } /// - /// Unhooks event handlers from the service. + /// Unhooks event handlers from the service. /// public void Teardown() { _service.AlarmEvent -= OnAlarmEvent; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/BrowseTreeViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/BrowseTreeViewModel.cs index f1875ee..29b0022 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/BrowseTreeViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/BrowseTreeViewModel.cs @@ -6,15 +6,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// ViewModel for the OPC UA browse tree panel. +/// ViewModel for the OPC UA browse tree panel. /// -public partial class BrowseTreeViewModel : ObservableObject +public class BrowseTreeViewModel : ObservableObject { - private readonly IOpcUaClientService _service; private readonly IUiDispatcher _dispatcher; - - /// Top-level nodes in the browse tree. - public ObservableCollection RootNodes { get; } = new(); + private readonly IOpcUaClientService _service; public BrowseTreeViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) { @@ -22,18 +19,20 @@ public partial class BrowseTreeViewModel : ObservableObject _dispatcher = dispatcher; } + /// Top-level nodes in the browse tree. + public ObservableCollection RootNodes { get; } = []; + /// - /// Loads root nodes by browsing with a null parent. + /// Loads root nodes by browsing with a null parent. /// public async Task LoadRootsAsync() { - var results = await _service.BrowseAsync(null); + var results = await _service.BrowseAsync(); _dispatcher.Post(() => { RootNodes.Clear(); foreach (var result in results) - { RootNodes.Add(new TreeNodeViewModel( result.NodeId, result.DisplayName, @@ -41,15 +40,14 @@ public partial class BrowseTreeViewModel : ObservableObject result.HasChildren, _service, _dispatcher)); - } }); } /// - /// Clears all root nodes from the tree. + /// Clears all root nodes from the tree. /// public void Clear() { RootNodes.Clear(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryValueViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryValueViewModel.cs index ac7ad0f..4e7f696 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryValueViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryValueViewModel.cs @@ -3,15 +3,10 @@ using CommunityToolkit.Mvvm.ComponentModel; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// Represents a single historical value row. +/// Represents a single historical value row. /// -public partial class HistoryValueViewModel : ObservableObject +public class HistoryValueViewModel : ObservableObject { - public string Value { get; } - public string Status { get; } - public string SourceTimestamp { get; } - public string ServerTimestamp { get; } - public HistoryValueViewModel(string value, string status, string sourceTimestamp, string serverTimestamp) { Value = value; @@ -19,4 +14,9 @@ public partial class HistoryValueViewModel : ObservableObject SourceTimestamp = sourceTimestamp; ServerTimestamp = serverTimestamp; } -} + + public string Value { get; } + public string Status { get; } + public string SourceTimestamp { get; } + public string ServerTimestamp { get; } +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryViewModel.cs index 233a53f..923b01d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/HistoryViewModel.cs @@ -9,55 +9,30 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// ViewModel for the history panel. +/// ViewModel for the history panel. /// public partial class HistoryViewModel : ObservableObject { - private readonly IOpcUaClientService _service; private readonly IUiDispatcher _dispatcher; + private readonly IOpcUaClientService _service; - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))] - private string? _selectedNodeId; + [ObservableProperty] private DateTimeOffset _endTime = DateTimeOffset.UtcNow; - [ObservableProperty] - private DateTimeOffset _startTime = DateTimeOffset.UtcNow.AddHours(-1); + [ObservableProperty] private double _intervalMs = 3600000; - [ObservableProperty] - private DateTimeOffset _endTime = DateTimeOffset.UtcNow; - - [ObservableProperty] - private int _maxValues = 1000; - - [ObservableProperty] - private AggregateType? _selectedAggregateType; - - /// Available aggregate types (null means "Raw"). - public IReadOnlyList AggregateTypes { get; } = new AggregateType?[] - { - null, - AggregateType.Average, - AggregateType.Minimum, - AggregateType.Maximum, - AggregateType.Count, - AggregateType.Start, - AggregateType.End - }; - - [ObservableProperty] - private double _intervalMs = 3600000; - - public bool IsAggregateRead => SelectedAggregateType != null; - - [ObservableProperty] - private bool _isLoading; - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))] + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))] private bool _isConnected; - /// History read results. - public ObservableCollection Results { get; } = new(); + [ObservableProperty] private bool _isLoading; + + [ObservableProperty] private int _maxValues = 1000; + + [ObservableProperty] private AggregateType? _selectedAggregateType; + + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))] + private string? _selectedNodeId; + + [ObservableProperty] private DateTimeOffset _startTime = DateTimeOffset.UtcNow.AddHours(-1); public HistoryViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) { @@ -65,12 +40,32 @@ public partial class HistoryViewModel : ObservableObject _dispatcher = dispatcher; } + /// Available aggregate types (null means "Raw"). + public IReadOnlyList AggregateTypes { get; } = + [ + null, + AggregateType.Average, + AggregateType.Minimum, + AggregateType.Maximum, + AggregateType.Count, + AggregateType.Start, + AggregateType.End + ]; + + public bool IsAggregateRead => SelectedAggregateType != null; + + /// History read results. + public ObservableCollection Results { get; } = []; + partial void OnSelectedAggregateTypeChanged(AggregateType? value) { OnPropertyChanged(nameof(IsAggregateRead)); } - private bool CanReadHistory() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId); + private bool CanReadHistory() + { + return IsConnected && !string.IsNullOrEmpty(SelectedNodeId); + } [RelayCommand(CanExecute = nameof(CanReadHistory))] private async Task ReadHistoryAsync() @@ -86,33 +81,27 @@ public partial class HistoryViewModel : ObservableObject IReadOnlyList values; if (SelectedAggregateType != null) - { values = await _service.HistoryReadAggregateAsync( nodeId, StartTime.UtcDateTime, EndTime.UtcDateTime, SelectedAggregateType.Value, IntervalMs); - } else - { values = await _service.HistoryReadRawAsync( nodeId, StartTime.UtcDateTime, EndTime.UtcDateTime, MaxValues); - } _dispatcher.Post(() => { foreach (var dv in values) - { Results.Add(new HistoryValueViewModel( dv.Value?.ToString() ?? "(null)", dv.StatusCode.ToString(), dv.SourceTimestamp.ToString("O"), dv.ServerTimestamp.ToString("O"))); - } }); } catch (Exception ex) @@ -130,11 +119,11 @@ public partial class HistoryViewModel : ObservableObject } /// - /// Clears results and resets state. + /// Clears results and resets state. /// public void Clear() { Results.Clear(); SelectedNodeId = null; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs index 9fc3be1..be6f90d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs @@ -8,78 +8,49 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// Main window ViewModel coordinating all panels. +/// Main window ViewModel coordinating all panels. /// public partial class MainWindowViewModel : ObservableObject { - private readonly IOpcUaClientService _service; private readonly IUiDispatcher _dispatcher; + private readonly IOpcUaClientService _service; - [ObservableProperty] - private string _endpointUrl = "opc.tcp://localhost:4840"; + [ObservableProperty] private bool _autoAcceptCertificates = true; - [ObservableProperty] - private string? _username; - - [ObservableProperty] - private string? _password; - - [ObservableProperty] - private SecurityMode _selectedSecurityMode = SecurityMode.None; - - [ObservableProperty] - private string? _failoverUrls; - - [ObservableProperty] - private int _sessionTimeoutSeconds = 60; - - [ObservableProperty] - private bool _autoAcceptCertificates = true; - - [ObservableProperty] - private string _certificateStorePath = Path.Combine( + [ObservableProperty] private string _certificateStorePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LmxOpcUaClient", "pki"); - /// All available security modes. - public IReadOnlyList SecurityModes { get; } = Enum.GetValues(); - [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] [NotifyCanExecuteChangedFor(nameof(DisconnectCommand))] private ConnectionState _connectionState = ConnectionState.Disconnected; - public bool IsConnected => ConnectionState == ConnectionState.Connected; + [ObservableProperty] private string _endpointUrl = "opc.tcp://localhost:4840"; - [ObservableProperty] - private TreeNodeViewModel? _selectedTreeNode; + [ObservableProperty] private string? _failoverUrls; - [ObservableProperty] - private RedundancyInfo? _redundancyInfo; + [ObservableProperty] private bool _isHistoryEnabledForSelection; - [ObservableProperty] - private string _statusMessage = "Disconnected"; + [ObservableProperty] private string? _password; - [ObservableProperty] - private string _sessionLabel = string.Empty; + [ObservableProperty] private RedundancyInfo? _redundancyInfo; - [ObservableProperty] - private int _subscriptionCount; + [ObservableProperty] private SecurityMode _selectedSecurityMode = SecurityMode.None; - [ObservableProperty] - private int _selectedTabIndex; + [ObservableProperty] private int _selectedTabIndex; - [ObservableProperty] - private bool _isHistoryEnabledForSelection; + [ObservableProperty] private TreeNodeViewModel? _selectedTreeNode; - /// The currently selected tree nodes (supports multi-select). - public ObservableCollection SelectedTreeNodes { get; } = new(); + [ObservableProperty] private string _sessionLabel = string.Empty; - public BrowseTreeViewModel BrowseTree { get; } - public ReadWriteViewModel ReadWrite { get; } - public SubscriptionsViewModel Subscriptions { get; } - public AlarmsViewModel Alarms { get; } - public HistoryViewModel History { get; } + [ObservableProperty] private int _sessionTimeoutSeconds = 60; + + [ObservableProperty] private string _statusMessage = "Disconnected"; + + [ObservableProperty] private int _subscriptionCount; + + [ObservableProperty] private string? _username; public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher) { @@ -95,12 +66,23 @@ public partial class MainWindowViewModel : ObservableObject _service.ConnectionStateChanged += OnConnectionStateChanged; } + /// All available security modes. + public IReadOnlyList SecurityModes { get; } = Enum.GetValues(); + + public bool IsConnected => ConnectionState == ConnectionState.Connected; + + /// The currently selected tree nodes (supports multi-select). + public ObservableCollection SelectedTreeNodes { get; } = []; + + public BrowseTreeViewModel BrowseTree { get; } + public ReadWriteViewModel ReadWrite { get; } + public SubscriptionsViewModel Subscriptions { get; } + public AlarmsViewModel Alarms { get; } + public HistoryViewModel History { get; } + private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e) { - _dispatcher.Post(() => - { - ConnectionState = e.NewState; - }); + _dispatcher.Post(() => { ConnectionState = e.NewState; }); } partial void OnConnectionStateChanged(ConnectionState value) @@ -144,7 +126,10 @@ public partial class MainWindowViewModel : ObservableObject History.SelectedNodeId = value?.NodeId; } - private bool CanConnect() => ConnectionState == ConnectionState.Disconnected; + private bool CanConnect() + { + return ConnectionState == ConnectionState.Disconnected; + } [RelayCommand(CanExecute = nameof(CanConnect))] private async Task ConnectAsync() @@ -199,8 +184,11 @@ public partial class MainWindowViewModel : ObservableObject } } - private bool CanDisconnect() => ConnectionState == ConnectionState.Connected - || ConnectionState == ConnectionState.Reconnecting; + private bool CanDisconnect() + { + return ConnectionState == ConnectionState.Connected + || ConnectionState == ConnectionState.Reconnecting; + } [RelayCommand(CanExecute = nameof(CanDisconnect))] private async Task DisconnectAsync() @@ -217,15 +205,12 @@ public partial class MainWindowViewModel : ObservableObject } finally { - _dispatcher.Post(() => - { - ConnectionState = ConnectionState.Disconnected; - }); + _dispatcher.Post(() => { ConnectionState = ConnectionState.Disconnected; }); } } /// - /// Subscribes all selected tree nodes and switches to the Subscriptions tab. + /// Subscribes all selected tree nodes and switches to the Subscriptions tab. /// [RelayCommand] private async Task SubscribeSelectedNodesAsync() @@ -233,17 +218,14 @@ public partial class MainWindowViewModel : ObservableObject if (SelectedTreeNodes.Count == 0 || !IsConnected) return; var nodes = SelectedTreeNodes.ToList(); - foreach (var node in nodes) - { - await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId); - } + foreach (var node in nodes) await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId); SubscriptionCount = Subscriptions.SubscriptionCount; SelectedTabIndex = 1; // Subscriptions tab } /// - /// Sets the history tab's selected node and switches to the History tab. + /// Sets the history tab's selected node and switches to the History tab. /// [RelayCommand] private void ViewHistoryForSelectedNode() @@ -256,14 +238,14 @@ public partial class MainWindowViewModel : ObservableObject } /// - /// Updates whether "View History" should be enabled based on the selected node's type. - /// Only Variable nodes can have history. + /// Updates whether "View History" should be enabled based on the selected node's type. + /// Only Variable nodes can have history. /// public void UpdateHistoryEnabledForSelection() { IsHistoryEnabledForSelection = IsConnected - && SelectedTreeNodes.Count > 0 - && SelectedTreeNodes[0].NodeClass == "Variable"; + && SelectedTreeNodes.Count > 0 + && SelectedTreeNodes[0].NodeClass == "Variable"; } private static string[]? ParseFailoverUrls(string? csv) @@ -272,7 +254,7 @@ public partial class MainWindowViewModel : ObservableObject return null; return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(u => !string.IsNullOrEmpty(u)) - .ToArray(); + .Where(u => !string.IsNullOrEmpty(u)) + .ToArray(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs index 4eb2c6c..eecf7da 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/ReadWriteViewModel.cs @@ -7,42 +7,34 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// ViewModel for the read/write panel. +/// ViewModel for the read/write panel. /// public partial class ReadWriteViewModel : ObservableObject { - private readonly IOpcUaClientService _service; private readonly IUiDispatcher _dispatcher; + private readonly IOpcUaClientService _service; - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(ReadCommand))] - [NotifyCanExecuteChangedFor(nameof(WriteCommand))] - private string? _selectedNodeId; + [ObservableProperty] private string? _currentStatus; - [ObservableProperty] - private string? _currentValue; - - [ObservableProperty] - private string? _currentStatus; - - [ObservableProperty] - private string? _sourceTimestamp; - - [ObservableProperty] - private string? _serverTimestamp; - - [ObservableProperty] - private string? _writeValue; - - [ObservableProperty] - private string? _writeStatus; + [ObservableProperty] private string? _currentValue; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadCommand))] [NotifyCanExecuteChangedFor(nameof(WriteCommand))] private bool _isConnected; - public bool IsNodeSelected => !string.IsNullOrEmpty(SelectedNodeId); + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ReadCommand))] + [NotifyCanExecuteChangedFor(nameof(WriteCommand))] + private string? _selectedNodeId; + + [ObservableProperty] private string? _serverTimestamp; + + [ObservableProperty] private string? _sourceTimestamp; + + [ObservableProperty] private string? _writeStatus; + + [ObservableProperty] private string? _writeValue; public ReadWriteViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) { @@ -50,16 +42,18 @@ public partial class ReadWriteViewModel : ObservableObject _dispatcher = dispatcher; } + public bool IsNodeSelected => !string.IsNullOrEmpty(SelectedNodeId); + partial void OnSelectedNodeIdChanged(string? value) { OnPropertyChanged(nameof(IsNodeSelected)); - if (!string.IsNullOrEmpty(value) && IsConnected) - { - _ = ExecuteReadAsync(); - } + if (!string.IsNullOrEmpty(value) && IsConnected) _ = ExecuteReadAsync(); } - private bool CanReadOrWrite() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId); + private bool CanReadOrWrite() + { + return IsConnected && !string.IsNullOrEmpty(SelectedNodeId); + } [RelayCommand(CanExecute = nameof(CanReadOrWrite))] private async Task ReadAsync() @@ -106,22 +100,16 @@ public partial class ReadWriteViewModel : ObservableObject var nodeId = NodeId.Parse(SelectedNodeId); var statusCode = await _service.WriteValueAsync(nodeId, WriteValue); - _dispatcher.Post(() => - { - WriteStatus = statusCode.ToString(); - }); + _dispatcher.Post(() => { WriteStatus = statusCode.ToString(); }); } catch (Exception ex) { - _dispatcher.Post(() => - { - WriteStatus = $"Error: {ex.Message}"; - }); + _dispatcher.Post(() => { WriteStatus = $"Error: {ex.Message}"; }); } } /// - /// Clears all displayed values. + /// Clears all displayed values. /// public void Clear() { @@ -133,4 +121,4 @@ public partial class ReadWriteViewModel : ObservableObject WriteValue = null; WriteStatus = null; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionItemViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionItemViewModel.cs index 1783c66..e457e72 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionItemViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionItemViewModel.cs @@ -3,28 +3,25 @@ using CommunityToolkit.Mvvm.ComponentModel; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// Represents a single active subscription row. +/// Represents a single active subscription row. /// public partial class SubscriptionItemViewModel : ObservableObject { - /// The monitored NodeId. - public string NodeId { get; } + [ObservableProperty] private string? _status; - /// The subscription interval in milliseconds. - public int IntervalMs { get; } + [ObservableProperty] private string? _timestamp; - [ObservableProperty] - private string? _value; - - [ObservableProperty] - private string? _status; - - [ObservableProperty] - private string? _timestamp; + [ObservableProperty] private string? _value; public SubscriptionItemViewModel(string nodeId, int intervalMs) { NodeId = nodeId; IntervalMs = intervalMs; } -} + + /// The monitored NodeId. + public string NodeId { get; } + + /// The subscription interval in milliseconds. + public int IntervalMs { get; } +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs index 6e5fd78..54d5c2d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs @@ -9,35 +9,28 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// ViewModel for the subscriptions panel. +/// ViewModel for the subscriptions panel. /// public partial class SubscriptionsViewModel : ObservableObject { - private readonly IOpcUaClientService _service; private readonly IUiDispatcher _dispatcher; - - /// Currently active subscriptions. - public ObservableCollection ActiveSubscriptions { get; } = new(); - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))] - private string? _newNodeIdText; - - [ObservableProperty] - private int _newInterval = 1000; + private readonly IOpcUaClientService _service; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))] [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))] private bool _isConnected; - [ObservableProperty] - private int _subscriptionCount; + [ObservableProperty] private int _newInterval = 1000; - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))] + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))] + private string? _newNodeIdText; + + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))] private SubscriptionItemViewModel? _selectedSubscription; + [ObservableProperty] private int _subscriptionCount; + public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) { _service = service; @@ -45,23 +38,27 @@ public partial class SubscriptionsViewModel : ObservableObject _service.DataChanged += OnDataChanged; } + /// Currently active subscriptions. + public ObservableCollection ActiveSubscriptions { get; } = []; + private void OnDataChanged(object? sender, DataChangedEventArgs e) { _dispatcher.Post(() => { foreach (var item in ActiveSubscriptions) - { if (item.NodeId == e.NodeId) { item.Value = e.Value.Value?.ToString() ?? "(null)"; item.Status = e.Value.StatusCode.ToString(); item.Timestamp = e.Value.SourceTimestamp.ToString("O"); } - } }); } - private bool CanAddSubscription() => IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText); + private bool CanAddSubscription() + { + return IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText); + } [RelayCommand(CanExecute = nameof(CanAddSubscription))] private async Task AddSubscriptionAsync() @@ -88,7 +85,10 @@ public partial class SubscriptionsViewModel : ObservableObject } } - private bool CanRemoveSubscription() => IsConnected && SelectedSubscription != null; + private bool CanRemoveSubscription() + { + return IsConnected && SelectedSubscription != null; + } [RelayCommand(CanExecute = nameof(CanRemoveSubscription))] private async Task RemoveSubscriptionAsync() @@ -115,7 +115,7 @@ public partial class SubscriptionsViewModel : ObservableObject } /// - /// Subscribes to a node by ID (used by context menu). Skips if already subscribed. + /// Subscribes to a node by ID (used by context menu). Skips if already subscribed. /// public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000) { @@ -142,7 +142,7 @@ public partial class SubscriptionsViewModel : ObservableObject } /// - /// Clears all subscriptions and resets state. + /// Clears all subscriptions and resets state. /// public void Clear() { @@ -151,10 +151,10 @@ public partial class SubscriptionsViewModel : ObservableObject } /// - /// Unhooks event handlers from the service. + /// Unhooks event handlers from the service. /// public void Teardown() { _service.DataChanged -= OnDataChanged; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/TreeNodeViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/TreeNodeViewModel.cs index dd80e2f..c0b65f7 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/TreeNodeViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/TreeNodeViewModel.cs @@ -1,45 +1,27 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; -using Opc.Ua; using ZB.MOM.WW.LmxOpcUa.Client.Shared; using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// -/// Represents a single node in the OPC UA browse tree with lazy-load support. +/// Represents a single node in the OPC UA browse tree with lazy-load support. /// public partial class TreeNodeViewModel : ObservableObject { private static readonly TreeNodeViewModel PlaceholderSentinel = new(); + private readonly IUiDispatcher? _dispatcher; private readonly IOpcUaClientService? _service; - private readonly IUiDispatcher? _dispatcher; private bool _hasLoadedChildren; - /// The string NodeId of this node. - public string NodeId { get; } + [ObservableProperty] private bool _isExpanded; - /// The display name shown in the tree. - public string DisplayName { get; } - - /// The OPC UA node class (Object, Variable, etc.). - public string NodeClass { get; } - - /// Whether this node has child references. - public bool HasChildren { get; } - - /// Child nodes (may contain a placeholder sentinel before first expand). - public ObservableCollection Children { get; } = new(); - - [ObservableProperty] - private bool _isExpanded; - - [ObservableProperty] - private bool _isLoading; + [ObservableProperty] private bool _isLoading; /// - /// Private constructor for the placeholder sentinel only. + /// Private constructor for the placeholder sentinel only. /// private TreeNodeViewModel() { @@ -64,18 +46,32 @@ public partial class TreeNodeViewModel : ObservableObject _service = service; _dispatcher = dispatcher; - if (hasChildren) - { - Children.Add(PlaceholderSentinel); - } + if (hasChildren) Children.Add(PlaceholderSentinel); } + /// The string NodeId of this node. + public string NodeId { get; } + + /// The display name shown in the tree. + public string DisplayName { get; } + + /// The OPC UA node class (Object, Variable, etc.). + public string NodeClass { get; } + + /// Whether this node has child references. + public bool HasChildren { get; } + + /// Child nodes (may contain a placeholder sentinel before first expand). + public ObservableCollection Children { get; } = []; + + /// + /// Returns whether this node instance is the placeholder sentinel. + /// + internal bool IsPlaceholder => ReferenceEquals(this, PlaceholderSentinel); + partial void OnIsExpandedChanged(bool value) { - if (value && !_hasLoadedChildren && HasChildren) - { - _ = LoadChildrenAsync(); - } + if (value && !_hasLoadedChildren && HasChildren) _ = LoadChildrenAsync(); } private async Task LoadChildrenAsync() @@ -94,7 +90,6 @@ public partial class TreeNodeViewModel : ObservableObject { Children.Clear(); foreach (var result in results) - { Children.Add(new TreeNodeViewModel( result.NodeId, result.DisplayName, @@ -102,7 +97,6 @@ public partial class TreeNodeViewModel : ObservableObject result.HasChildren, _service, _dispatcher)); - } }); } catch @@ -114,9 +108,4 @@ public partial class TreeNodeViewModel : ObservableObject _dispatcher.Post(() => IsLoading = false); } } - - /// - /// Returns whether this node instance is the placeholder sentinel. - /// - internal bool IsPlaceholder => ReferenceEquals(this, PlaceholderSentinel); -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml index 74938f3..95a0807 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml @@ -33,4 +33,4 @@ - + \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml.cs index 6160e45..148c568 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AlarmsView.axaml.cs @@ -8,4 +8,4 @@ public partial class AlarmsView : UserControl { InitializeComponent(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml index b15ecd7..13f320b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml @@ -28,4 +28,4 @@ - + \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml.cs index becc6c4..61c3943 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml.cs @@ -8,4 +8,4 @@ public partial class BrowseTreeView : UserControl { InitializeComponent(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml index 3298096..6107f3e 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml @@ -65,4 +65,4 @@ - + \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml.cs index d0ea5d5..e24d07b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/HistoryView.axaml.cs @@ -8,4 +8,4 @@ public partial class HistoryView : UserControl { InitializeComponent(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml index 281c742..2eaf976 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml @@ -114,4 +114,4 @@ - + \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs index bc9c7ee..e0cd5d5 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using Avalonia.Controls; using Avalonia.Interactivity; using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; @@ -17,17 +18,11 @@ public partial class MainWindow : Window var browseTreeView = this.FindControl("BrowseTreePanel"); var treeView = browseTreeView?.FindControl("BrowseTree"); - if (treeView != null) - { - treeView.SelectionChanged += OnTreeSelectionChanged; - } + if (treeView != null) treeView.SelectionChanged += OnTreeSelectionChanged; // Wire up context menu opening to sync selection and check history var contextMenu = this.FindControl("TreeContextMenu"); - if (contextMenu != null) - { - contextMenu.Opening += OnTreeContextMenuOpening; - } + if (contextMenu != null) contextMenu.Opening += OnTreeContextMenuOpening; } private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -40,19 +35,12 @@ public partial class MainWindow : Window // Sync multi-selection collection vm.SelectedTreeNodes.Clear(); foreach (var item in treeView.SelectedItems) - { if (item is TreeNodeViewModel node) - { vm.SelectedTreeNodes.Add(node); - } - } } - private void OnTreeContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) + private void OnTreeContextMenuOpening(object? sender, CancelEventArgs e) { - if (DataContext is MainWindowViewModel vm) - { - vm.UpdateHistoryEnabledForSelection(); - } + if (DataContext is MainWindowViewModel vm) vm.UpdateHistoryEnabledForSelection(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml index 1fb5761..90c809e 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml @@ -36,4 +36,4 @@ - + \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml.cs index 0feaa00..b2732bf 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/ReadWriteView.axaml.cs @@ -8,4 +8,4 @@ public partial class ReadWriteView : UserControl { InitializeComponent(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml index 3c2d1d5..7032720 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml @@ -29,4 +29,4 @@ - + \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml.cs index 3d3692b..707750f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/SubscriptionsView.axaml.cs @@ -8,4 +8,4 @@ public partial class SubscriptionsView : UserControl { InitializeComponent(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ZB.MOM.WW.LmxOpcUa.Client.UI.csproj b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ZB.MOM.WW.LmxOpcUa.Client.UI.csproj index 2175e15..6c79f22 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ZB.MOM.WW.LmxOpcUa.Client.UI.csproj +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ZB.MOM.WW.LmxOpcUa.Client.UI.csproj @@ -1,30 +1,30 @@ - - WinExe - net10.0 - enable - enable - ZB.MOM.WW.LmxOpcUa.Client.UI - + + WinExe + net10.0 + enable + enable + ZB.MOM.WW.LmxOpcUa.Client.UI + - - - - - - - - - - + + + + + + + + + + - - - + + + - - - + + + diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs index 9bf531d..c0ba2c9 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs @@ -1,48 +1,48 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Top-level configuration holder binding all sections from appsettings.json. (SVC-003) + /// Top-level configuration holder binding all sections from appsettings.json. (SVC-003) /// public class AppConfiguration { /// - /// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space. + /// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space. /// - public OpcUaConfiguration OpcUa { get; set; } = new OpcUaConfiguration(); + public OpcUaConfiguration OpcUa { get; set; } = new(); /// - /// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes. + /// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes. /// - public MxAccessConfiguration MxAccess { get; set; } = new MxAccessConfiguration(); + public MxAccessConfiguration MxAccess { get; set; } = new(); /// - /// Gets or sets the repository settings used to query Galaxy metadata for address-space construction. + /// Gets or sets the repository settings used to query Galaxy metadata for address-space construction. /// - public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new GalaxyRepositoryConfiguration(); + public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new(); /// - /// Gets or sets the embedded dashboard settings used to surface service health to operators. + /// Gets or sets the embedded dashboard settings used to surface service health to operators. /// - public DashboardConfiguration Dashboard { get; set; } = new DashboardConfiguration(); + public DashboardConfiguration Dashboard { get; set; } = new(); /// - /// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data. + /// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data. /// - public HistorianConfiguration Historian { get; set; } = new HistorianConfiguration(); + public HistorianConfiguration Historian { get; set; } = new(); /// - /// Gets or sets the authentication and role-based access control settings. + /// Gets or sets the authentication and role-based access control settings. /// - public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration(); + public AuthenticationConfiguration Authentication { get; set; } = new(); /// - /// Gets or sets the transport security settings that control which OPC UA security profiles are exposed. + /// Gets or sets the transport security settings that control which OPC UA security profiles are exposed. /// - public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration(); + public SecurityProfileConfiguration Security { get; set; } = new(); /// - /// Gets or sets the redundancy settings that control how this server participates in a redundant pair. + /// Gets or sets the redundancy settings that control how this server participates in a redundant pair. /// - public RedundancyConfiguration Redundancy { get; set; } = new RedundancyConfiguration(); + public RedundancyConfiguration Redundancy { get; set; } = new(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs index 6a327f8..1980857 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs @@ -1,25 +1,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Authentication and role-based access control settings for the OPC UA server. + /// Authentication and role-based access control settings for the OPC UA server. /// public class AuthenticationConfiguration { /// - /// Gets or sets a value indicating whether anonymous OPC UA connections are accepted. + /// Gets or sets a value indicating whether anonymous OPC UA connections are accepted. /// public bool AllowAnonymous { get; set; } = true; /// - /// Gets or sets a value indicating whether anonymous users can write tag values. - /// When false, only authenticated users can write. Existing security classification restrictions still apply. + /// Gets or sets a value indicating whether anonymous users can write tag values. + /// When false, only authenticated users can write. Existing security classification restrictions still apply. /// public bool AnonymousCanWrite { get; set; } = true; /// - /// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true, - /// credentials are validated against the LDAP server and group membership determines permissions. + /// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true, + /// credentials are validated against the LDAP server and group membership determines permissions. /// - public LdapConfiguration Ldap { get; set; } = new LdapConfiguration(); + public LdapConfiguration Ldap { get; set; } = new(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs index ef35e24..6409fef 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs @@ -1,30 +1,41 @@ +using System; using System.Linq; +using Opc.Ua; using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.OpcUa; namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Validates and logs effective configuration at startup. (SVC-003, SVC-005) + /// Validates and logs effective configuration at startup. (SVC-003, SVC-005) /// public static class ConfigurationValidator { private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator)); /// - /// Validates the effective host configuration and writes the resolved values to the startup log before service initialization continues. + /// Validates the effective host configuration and writes the resolved values to the startup log before service + /// initialization continues. /// - /// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries, and dashboard behavior. - /// when the required settings are present and within supported bounds; otherwise, . + /// + /// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries, + /// and dashboard behavior. + /// + /// + /// when the required settings are present and within supported bounds; otherwise, + /// . + /// public static bool ValidateAndLog(AppConfiguration config) { - bool valid = true; + var valid = true; Log.Information("=== Effective Configuration ==="); // OPC UA - Log.Information("OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}", - config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName); + Log.Information( + "OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}", + config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, + config.OpcUa.GalaxyName); Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}", config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes); @@ -41,10 +52,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration } // MxAccess - Log.Information("MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}", + Log.Information( + "MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}", config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds, config.MxAccess.MaxConcurrentOperations); - Log.Information("MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s", + Log.Information( + "MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s", config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect, config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds); @@ -55,7 +68,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration } // Galaxy Repository - Log.Information("GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}", + Log.Information( + "GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}", config.GalaxyRepository.ConnectionString, config.GalaxyRepository.ChangeDetectionIntervalSeconds, config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes); @@ -70,7 +84,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds); // Security - Log.Information("Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}", + Log.Information( + "Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}", string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates, config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize); @@ -80,13 +95,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject); var unknownProfiles = config.Security.Profiles - .Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, System.StringComparer.OrdinalIgnoreCase)) + .Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase)) .ToList(); if (unknownProfiles.Count > 0) - { Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}", string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames)); - } if (config.Security.MinimumCertificateKeySize < 2048) { @@ -95,14 +108,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration } if (config.Security.AutoAcceptClientCertificates) - { - Log.Warning("Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production"); - } + Log.Warning( + "Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production"); - if (config.Security.Profiles.Count == 1 && config.Security.Profiles[0].Equals("None", System.StringComparison.OrdinalIgnoreCase)) - { + if (config.Security.Profiles.Count == 1 && + config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase)) Log.Warning("Only the 'None' security profile is configured — transport security is disabled"); - } // Authentication Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}", @@ -111,51 +122,53 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration if (config.Authentication.Ldap.Enabled) { Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}", - config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN); - Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}", + config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, + config.Authentication.Ldap.BaseDN); + Log.Information( + "Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}", config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup, config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup, config.Authentication.Ldap.AlarmAckGroup); if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn)) - { Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail"); - } } // Redundancy if (config.OpcUa.ApplicationUri != null) Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri); - Log.Information("Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}", - config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, config.Redundancy.ServiceLevelBase); + Log.Information( + "Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}", + config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, + config.Redundancy.ServiceLevelBase); if (config.Redundancy.ServerUris.Count > 0) - Log.Information("Redundancy.ServerUris=[{ServerUris}]", string.Join(", ", config.Redundancy.ServerUris)); + Log.Information("Redundancy.ServerUris=[{ServerUris}]", + string.Join(", ", config.Redundancy.ServerUris)); if (config.Redundancy.Enabled) { if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri)) { - Log.Error("OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity"); + Log.Error( + "OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity"); valid = false; } if (config.Redundancy.ServerUris.Count < 2) - { - Log.Warning("Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers"); - } + Log.Warning( + "Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers"); - if (config.OpcUa.ApplicationUri != null && !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri)) - { - Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris", config.OpcUa.ApplicationUri); - } + if (config.OpcUa.ApplicationUri != null && + !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri)) + Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris", + config.OpcUa.ApplicationUri); var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true); - if (mode == Opc.Ua.RedundancySupport.None) - { - Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", config.Redundancy.Mode); - } + if (mode == RedundancySupport.None) + Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", + config.Redundancy.Mode); } if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255) @@ -168,4 +181,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration return valid; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/DashboardConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/DashboardConfiguration.cs index e9778df..b6d3898 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/DashboardConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/DashboardConfiguration.cs @@ -1,23 +1,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Status dashboard configuration. (SVC-003, DASH-001) + /// Status dashboard configuration. (SVC-003, DASH-001) /// public class DashboardConfiguration { /// - /// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service. + /// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service. /// public bool Enabled { get; set; } = true; /// - /// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state. + /// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state. /// public int Port { get; set; } = 8081; /// - /// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot. + /// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot. /// public int RefreshIntervalSeconds { get; set; } = 10; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs index 3a9ea3f..0d010ff 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs @@ -1,28 +1,29 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Galaxy repository database configuration. (SVC-003, GR-005) + /// Galaxy repository database configuration. (SVC-003, GR-005) /// public class GalaxyRepositoryConfiguration { /// - /// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata. + /// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata. /// public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;"; /// - /// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space rebuild. + /// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space + /// rebuild. /// public int ChangeDetectionIntervalSeconds { get; set; } = 30; /// - /// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog. + /// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog. /// public int CommandTimeoutSeconds { get; set; } = 30; /// - /// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model. + /// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model. /// public bool ExtendedAttributes { get; set; } = false; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/HistorianConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/HistorianConfiguration.cs index 2121cf0..232cb97 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/HistorianConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/HistorianConfiguration.cs @@ -1,28 +1,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Wonderware Historian database configuration for OPC UA historical data access. + /// Wonderware Historian database configuration for OPC UA historical data access. /// public class HistorianConfiguration { /// - /// Gets or sets a value indicating whether OPC UA historical data access is enabled. + /// Gets or sets a value indicating whether OPC UA historical data access is enabled. /// public bool Enabled { get; set; } = false; /// - /// Gets or sets the connection string for the Wonderware Historian Runtime database. + /// Gets or sets the connection string for the Wonderware Historian Runtime database. /// public string ConnectionString { get; set; } = "Server=localhost;Database=Runtime;Integrated Security=true;"; /// - /// Gets or sets the SQL command timeout in seconds for historian queries. + /// Gets or sets the SQL command timeout in seconds for historian queries. /// public int CommandTimeoutSeconds { get; set; } = 30; /// - /// Gets or sets the maximum number of values returned per HistoryRead request. + /// Gets or sets the maximum number of values returned per HistoryRead request. /// public int MaxValuesPerRead { get; set; } = 10000; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs index b24ca32..e1a9f9c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs @@ -1,77 +1,75 @@ -using System.Collections.Generic; - namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// LDAP authentication and group-to-role mapping settings. + /// LDAP authentication and group-to-role mapping settings. /// public class LdapConfiguration { /// - /// Gets or sets whether LDAP authentication is enabled. - /// When true, user credentials are validated against the configured LDAP server - /// and group membership determines OPC UA permissions. + /// Gets or sets whether LDAP authentication is enabled. + /// When true, user credentials are validated against the configured LDAP server + /// and group membership determines OPC UA permissions. /// public bool Enabled { get; set; } = false; /// - /// Gets or sets the LDAP server hostname or IP address. + /// Gets or sets the LDAP server hostname or IP address. /// public string Host { get; set; } = "localhost"; /// - /// Gets or sets the LDAP server port. + /// Gets or sets the LDAP server port. /// public int Port { get; set; } = 3893; /// - /// Gets or sets the base DN for LDAP operations. + /// Gets or sets the base DN for LDAP operations. /// public string BaseDN { get; set; } = "dc=lmxopcua,dc=local"; /// - /// Gets or sets the bind DN template. Use {username} as a placeholder. + /// Gets or sets the bind DN template. Use {username} as a placeholder. /// public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local"; /// - /// Gets or sets the service account DN used for LDAP searches (group lookups). + /// Gets or sets the service account DN used for LDAP searches (group lookups). /// public string ServiceAccountDn { get; set; } = ""; /// - /// Gets or sets the service account password. + /// Gets or sets the service account password. /// public string ServiceAccountPassword { get; set; } = ""; /// - /// Gets or sets the LDAP connection timeout in seconds. + /// Gets or sets the LDAP connection timeout in seconds. /// public int TimeoutSeconds { get; set; } = 5; /// - /// Gets or sets the LDAP group name that grants read-only access. + /// Gets or sets the LDAP group name that grants read-only access. /// public string ReadOnlyGroup { get; set; } = "ReadOnly"; /// - /// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes. + /// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes. /// public string WriteOperateGroup { get; set; } = "WriteOperate"; /// - /// Gets or sets the LDAP group name that grants write access for Tune attributes. + /// Gets or sets the LDAP group name that grants write access for Tune attributes. /// public string WriteTuneGroup { get; set; } = "WriteTune"; /// - /// Gets or sets the LDAP group name that grants write access for Configure attributes. + /// Gets or sets the LDAP group name that grants write access for Configure attributes. /// public string WriteConfigureGroup { get; set; } = "WriteConfigure"; /// - /// Gets or sets the LDAP group name that grants alarm acknowledgment access. + /// Gets or sets the LDAP group name that grants alarm acknowledgment access. /// public string AlarmAckGroup { get; set; } = "AlarmAck"; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/MxAccessConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/MxAccessConfiguration.cs index d9fa196..a8da486 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/MxAccessConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/MxAccessConfiguration.cs @@ -1,58 +1,59 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// MXAccess client configuration. (SVC-003, MXA-008, MXA-009) + /// MXAccess client configuration. (SVC-003, MXA-008, MXA-009) /// public class MxAccessConfiguration { /// - /// Gets or sets the client name registered with the MXAccess runtime for this bridge instance. + /// Gets or sets the client name registered with the MXAccess runtime for this bridge instance. /// public string ClientName { get; set; } = "LmxOpcUa"; /// - /// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node. + /// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node. /// public string? NodeName { get; set; } /// - /// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics. + /// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics. /// public string? GalaxyName { get; set; } /// - /// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete. + /// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete. /// public int ReadTimeoutSeconds { get; set; } = 5; /// - /// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime. + /// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime. /// public int WriteTimeoutSeconds { get; set; } = 5; /// - /// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime. + /// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime. /// public int MaxConcurrentOperations { get; set; } = 10; /// - /// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection. + /// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection. /// public int MonitorIntervalSeconds { get; set; } = 5; /// - /// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess session. + /// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess + /// session. /// public bool AutoReconnect { get; set; } = true; /// - /// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data. + /// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data. /// public string? ProbeTag { get; set; } /// - /// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale. + /// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale. /// public int ProbeStaleThresholdSeconds { get; set; } = 60; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs index bf533ce..7a96e94 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs @@ -1,57 +1,57 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013) + /// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013) /// public class OpcUaConfiguration { /// - /// Gets or sets the IP address or hostname the OPC UA server binds to. - /// Defaults to 0.0.0.0 (all interfaces). Set to a specific IP or hostname to restrict listening. + /// Gets or sets the IP address or hostname the OPC UA server binds to. + /// Defaults to 0.0.0.0 (all interfaces). Set to a specific IP or hostname to restrict listening. /// public string BindAddress { get; set; } = "0.0.0.0"; /// - /// Gets or sets the TCP port on which the OPC UA server listens for client sessions. + /// Gets or sets the TCP port on which the OPC UA server listens for client sessions. /// public int Port { get; set; } = 4840; /// - /// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server. + /// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server. /// public string EndpointPath { get; set; } = "/LmxOpcUa"; /// - /// Gets or sets the server name presented to OPC UA clients and used in diagnostics. + /// Gets or sets the server name presented to OPC UA clients and used in diagnostics. /// public string ServerName { get; set; } = "LmxOpcUa"; /// - /// Gets or sets the Galaxy name represented by the published OPC UA namespace. + /// Gets or sets the Galaxy name represented by the published OPC UA namespace. /// public string GalaxyName { get; set; } = "ZB"; /// - /// Gets or sets the explicit application URI for this server instance. - /// When , defaults to urn:{GalaxyName}:LmxOpcUa. - /// Must be set to a unique value per instance when redundancy is enabled. + /// Gets or sets the explicit application URI for this server instance. + /// When , defaults to urn:{GalaxyName}:LmxOpcUa. + /// Must be set to a unique value per instance when redundancy is enabled. /// public string? ApplicationUri { get; set; } /// - /// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host. + /// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host. /// public int MaxSessions { get; set; } = 100; /// - /// Gets or sets the session timeout, in minutes, before idle client sessions are closed. + /// Gets or sets the session timeout, in minutes, before idle client sessions are closed. /// public int SessionTimeoutMinutes { get; set; } = 30; /// - /// Gets or sets a value indicating whether alarm tracking is enabled. - /// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored. + /// Gets or sets a value indicating whether alarm tracking is enabled. + /// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored. /// public bool AlarmTrackingEnabled { get; set; } = false; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/RedundancyConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/RedundancyConfiguration.cs index 9ed5d13..53fa481 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/RedundancyConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/RedundancyConfiguration.cs @@ -3,39 +3,39 @@ using System.Collections.Generic; namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Non-transparent redundancy settings that control how the server advertises itself - /// within a redundant pair and computes its dynamic ServiceLevel. + /// Non-transparent redundancy settings that control how the server advertises itself + /// within a redundant pair and computes its dynamic ServiceLevel. /// public class RedundancyConfiguration { /// - /// Gets or sets whether redundancy is enabled. When (default), - /// the server reports RedundancySupport.None and ServiceLevel = 255. + /// Gets or sets whether redundancy is enabled. When (default), + /// the server reports RedundancySupport.None and ServiceLevel = 255. /// public bool Enabled { get; set; } = false; /// - /// Gets or sets the redundancy mode. Valid values: Warm, Hot. + /// Gets or sets the redundancy mode. Valid values: Warm, Hot. /// public string Mode { get; set; } = "Warm"; /// - /// Gets or sets the role of this instance. Valid values: Primary, Secondary. - /// The primary advertises a higher ServiceLevel than the secondary when both are healthy. + /// Gets or sets the role of this instance. Valid values: Primary, Secondary. + /// The primary advertises a higher ServiceLevel than the secondary when both are healthy. /// public string Role { get; set; } = "Primary"; /// - /// Gets or sets the ApplicationUri values for all servers in the redundant set. - /// Must include this instance's own OpcUa.ApplicationUri. + /// Gets or sets the ApplicationUri values for all servers in the redundant set. + /// Must include this instance's own OpcUa.ApplicationUri. /// - public List ServerUris { get; set; } = new List(); + public List ServerUris { get; set; } = new(); /// - /// Gets or sets the base ServiceLevel when the server is fully healthy. - /// The secondary automatically receives ServiceLevelBase - 50. - /// Valid range: 1-255. + /// Gets or sets the base ServiceLevel when the server is fully healthy. + /// The secondary automatically receives ServiceLevelBase - 50. + /// Valid range: 1-255. /// public int ServiceLevelBase { get; set; } = 200; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs index a841ed2..e070d59 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs @@ -3,43 +3,44 @@ using System.Collections.Generic; namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// - /// Transport security settings that control which OPC UA security profiles the server exposes and how client certificates are handled. + /// Transport security settings that control which OPC UA security profiles the server exposes and how client + /// certificates are handled. /// public class SecurityProfileConfiguration { /// - /// Gets or sets the list of security profile names to expose as server endpoints. - /// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt". - /// Defaults to ["None"] for backward compatibility. + /// Gets or sets the list of security profile names to expose as server endpoints. + /// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt". + /// Defaults to ["None"] for backward compatibility. /// - public List Profiles { get; set; } = new List { "None" }; + public List Profiles { get; set; } = new() { "None" }; /// - /// Gets or sets a value indicating whether the server automatically accepts client certificates - /// that are not in the trusted store. Should be in production. + /// Gets or sets a value indicating whether the server automatically accepts client certificates + /// that are not in the trusted store. Should be in production. /// public bool AutoAcceptClientCertificates { get; set; } = true; /// - /// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected. + /// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected. /// public bool RejectSHA1Certificates { get; set; } = true; /// - /// Gets or sets the minimum RSA key size required for client certificates. + /// Gets or sets the minimum RSA key size required for client certificates. /// public int MinimumCertificateKeySize { get; set; } = 2048; /// - /// Gets or sets an optional override for the PKI root directory. - /// When , defaults to %LOCALAPPDATA%\OPC Foundation\pki. + /// Gets or sets an optional override for the PKI root directory. + /// When , defaults to %LOCALAPPDATA%\OPC Foundation\pki. /// public string? PkiRootPath { get; set; } /// - /// Gets or sets an optional override for the server certificate subject name. - /// When , defaults to CN={ServerName}, O=ZB MOM, DC=localhost. + /// Gets or sets an optional override for the server certificate subject name. + /// When , defaults to CN={ServerName}, O=ZB MOM, DC=localhost. /// public string? CertificateSubject { get; set; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionState.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionState.cs index 5a383c5..28d7eef 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionState.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionState.cs @@ -1,38 +1,38 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// MXAccess connection lifecycle states. (MXA-002) + /// MXAccess connection lifecycle states. (MXA-002) /// public enum ConnectionState { /// - /// No active session exists to the Galaxy runtime. + /// No active session exists to the Galaxy runtime. /// Disconnected, /// - /// The bridge is opening a new MXAccess session to the runtime. + /// The bridge is opening a new MXAccess session to the runtime. /// Connecting, /// - /// The bridge has an active MXAccess session and can service reads, writes, and subscriptions. + /// The bridge has an active MXAccess session and can service reads, writes, and subscriptions. /// Connected, /// - /// The bridge is closing the current MXAccess session and draining runtime resources. + /// The bridge is closing the current MXAccess session and draining runtime resources. /// Disconnecting, /// - /// The bridge detected a connection fault that requires operator attention or recovery logic. + /// The bridge detected a connection fault that requires operator attention or recovery logic. /// Error, /// - /// The bridge is attempting to restore service after a runtime communication failure. + /// The bridge is attempting to restore service after a runtime communication failure. /// Reconnecting } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs index 3df484f..76e63a1 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionStateChangedEventArgs.cs @@ -3,27 +3,12 @@ using System; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Event args for connection state transitions. (MXA-002) + /// Event args for connection state transitions. (MXA-002) /// public class ConnectionStateChangedEventArgs : EventArgs { /// - /// Gets the previous MXAccess connection state before the transition was raised. - /// - public ConnectionState PreviousState { get; } - - /// - /// Gets the new MXAccess connection state that the bridge moved into. - /// - public ConnectionState CurrentState { get; } - - /// - /// Gets an operator-facing message that explains why the connection state changed. - /// - public string Message { get; } - - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The connection state being exited. /// The connection state being entered. @@ -34,5 +19,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain CurrentState = current; Message = message ?? ""; } + + /// + /// Gets the previous MXAccess connection state before the transition was raised. + /// + public ConnectionState PreviousState { get; } + + /// + /// Gets the new MXAccess connection state that the bridge moved into. + /// + public ConnectionState CurrentState { get; } + + /// + /// Gets an operator-facing message that explains why the connection state changed. + /// + public string Message { get; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs index d433eb0..feca8dc 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs @@ -1,74 +1,76 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// DTO matching attributes.sql result columns. (GR-002) + /// DTO matching attributes.sql result columns. (GR-002) /// public class GalaxyAttributeInfo { /// - /// Gets or sets the Galaxy object identifier that owns the attribute. + /// Gets or sets the Galaxy object identifier that owns the attribute. /// public int GobjectId { get; set; } /// - /// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object. + /// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object. /// public string TagName { get; set; } = ""; /// - /// Gets or sets the attribute name as defined on the Galaxy template or instance. + /// Gets or sets the attribute name as defined on the Galaxy template or instance. /// public string AttributeName { get; set; } = ""; /// - /// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes. + /// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes. /// public string FullTagReference { get; set; } = ""; /// - /// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA. + /// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA. /// public int MxDataType { get; set; } /// - /// Gets or sets the human-readable Galaxy data type name returned by the repository query. + /// Gets or sets the human-readable Galaxy data type name returned by the repository query. /// public string DataTypeName { get; set; } = ""; /// - /// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node. + /// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node. /// public bool IsArray { get; set; } /// - /// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array. + /// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array. /// public int? ArrayDimension { get; set; } /// - /// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients. + /// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients. /// public string PrimitiveName { get; set; } = ""; /// - /// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation, or runtime data. + /// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation, + /// or runtime data. /// public string AttributeSource { get; set; } = ""; /// - /// Gets or sets the Galaxy security classification that determines OPC UA write access. - /// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly. + /// Gets or sets the Galaxy security classification that determines OPC UA write access. + /// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly. /// public int SecurityClassification { get; set; } = 1; /// - /// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the Wonderware Historian. + /// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the + /// Wonderware Historian. /// public bool IsHistorized { get; set; } /// - /// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm. + /// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm. /// public bool IsAlarm { get; set; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs index 69598c1..169e291 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs @@ -1,38 +1,38 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// DTO matching hierarchy.sql result columns. (GR-001) + /// DTO matching hierarchy.sql result columns. (GR-001) /// public class GalaxyObjectInfo { /// - /// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows. + /// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows. /// public int GobjectId { get; set; } /// - /// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree. + /// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree. /// public string TagName { get; set; } = ""; /// - /// Gets or sets the contained name shown for the object inside its parent area or object. + /// Gets or sets the contained name shown for the object inside its parent area or object. /// public string ContainedName { get; set; } = ""; /// - /// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy. + /// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy. /// public string BrowseName { get; set; } = ""; /// - /// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship. + /// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship. /// public int ParentGobjectId { get; set; } /// - /// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object. + /// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object. /// public bool IsArea { get; set; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IGalaxyRepository.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IGalaxyRepository.cs index 0273dd0..19b2319 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IGalaxyRepository.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IGalaxyRepository.cs @@ -6,41 +6,41 @@ using System.Threading.Tasks; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Interface for Galaxy repository database queries. (GR-001 through GR-004) + /// Interface for Galaxy repository database queries. (GR-001 through GR-004) /// public interface IGalaxyRepository { /// - /// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree. + /// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree. /// /// A token that cancels the repository query. /// A list of Galaxy objects ordered for address-space construction. Task> GetHierarchyAsync(CancellationToken ct = default); /// - /// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy. + /// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy. /// /// A token that cancels the repository query. /// A list of attribute definitions with MXAccess references and type metadata. Task> GetAttributesAsync(CancellationToken ct = default); /// - /// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild. + /// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild. /// /// A token that cancels the repository query. - /// The latest deploy timestamp, or when it cannot be determined. + /// The latest deploy timestamp, or when it cannot be determined. Task GetLastDeployTimeAsync(CancellationToken ct = default); /// - /// Verifies that the service can reach the Galaxy repository before it attempts to build the address space. + /// Verifies that the service can reach the Galaxy repository before it attempts to build the address space. /// /// A token that cancels the connectivity check. - /// when repository access succeeds; otherwise, . + /// when repository access succeeds; otherwise, . Task TestConnectionAsync(CancellationToken ct = default); /// - /// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild. + /// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild. /// event Action? OnGalaxyChanged; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxAccessClient.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxAccessClient.cs index 6fa0079..4f8e7d8 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxAccessClient.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxAccessClient.cs @@ -5,52 +5,62 @@ using System.Threading.Tasks; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Abstraction over MXAccess COM client for tag read/write/subscribe operations. - /// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009) + /// Abstraction over MXAccess COM client for tag read/write/subscribe operations. + /// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009) /// public interface IMxAccessClient : IDisposable { /// - /// Gets the current runtime connectivity state for the bridge. + /// Gets the current runtime connectivity state for the bridge. /// ConnectionState State { get; } /// - /// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic. + /// Gets the number of active runtime subscriptions currently being mirrored into OPC UA. + /// + int ActiveSubscriptionCount { get; } + + /// + /// Gets the number of reconnect cycles attempted since the client was created. + /// + int ReconnectCount { get; } + + /// + /// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic. /// event EventHandler? ConnectionStateChanged; /// - /// Occurs when a subscribed Galaxy attribute publishes a new runtime value. + /// Occurs when a subscribed Galaxy attribute publishes a new runtime value. /// event Action? OnTagValueChanged; /// - /// Opens the MXAccess session required for runtime reads, writes, and subscriptions. + /// Opens the MXAccess session required for runtime reads, writes, and subscriptions. /// /// A token that cancels the connection attempt. Task ConnectAsync(CancellationToken ct = default); /// - /// Closes the MXAccess session and releases runtime resources. + /// Closes the MXAccess session and releases runtime resources. /// Task DisconnectAsync(); /// - /// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers. + /// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers. /// /// The fully qualified MXAccess reference for the target attribute. /// The callback to invoke when the runtime publishes a new value for the attribute. Task SubscribeAsync(string fullTagReference, Action callback); /// - /// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer. + /// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer. /// /// The fully qualified MXAccess reference for the target attribute. Task UnsubscribeAsync(string fullTagReference); /// - /// Reads the current runtime value for a Galaxy attribute. + /// Reads the current runtime value for a Galaxy attribute. /// /// The fully qualified MXAccess reference for the target attribute. /// A token that cancels the read. @@ -58,22 +68,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain Task ReadAsync(string fullTagReference, CancellationToken ct = default); /// - /// Writes a new runtime value to a writable Galaxy attribute. + /// Writes a new runtime value to a writable Galaxy attribute. /// /// The fully qualified MXAccess reference for the target attribute. /// The value to write to the runtime. /// A token that cancels the write. - /// when the write is accepted by the runtime; otherwise, . + /// when the write is accepted by the runtime; otherwise, . Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default); - - /// - /// Gets the number of active runtime subscriptions currently being mirrored into OPC UA. - /// - int ActiveSubscriptionCount { get; } - - /// - /// Gets the number of reconnect cycles attempted since the client was created. - /// - int ReconnectCount { get; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxProxy.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxProxy.cs index 5e08001..8c1f07f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxProxy.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxProxy.cs @@ -1,10 +1,9 @@ -using System; using ArchestrA.MxAccess; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Delegate matching LMXProxyServer.OnDataChange COM event signature. + /// Delegate matching LMXProxyServer.OnDataChange COM event signature. /// /// The runtime connection handle that raised the change. /// The runtime item handle for the attribute that changed. @@ -21,7 +20,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain ref MXSTATUS_PROXY[] ItemStatus); /// - /// Delegate matching LMXProxyServer.OnWriteComplete COM event signature. + /// Delegate matching LMXProxyServer.OnWriteComplete COM event signature. /// /// The runtime connection handle that processed the write. /// The runtime item handle that was written. @@ -32,25 +31,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain ref MXSTATUS_PROXY[] ItemStatus); /// - /// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001) + /// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001) /// public interface IMxProxy { /// - /// Registers the bridge as an MXAccess client with the runtime proxy. + /// Registers the bridge as an MXAccess client with the runtime proxy. /// /// The client identity reported to the runtime for diagnostics and session tracking. /// The runtime connection handle assigned to the client session. int Register(string clientName); /// - /// Unregisters the bridge from the runtime proxy and releases the connection handle. + /// Unregisters the bridge from the runtime proxy and releases the connection handle. /// - /// The connection handle returned by . + /// The connection handle returned by . void Unregister(int handle); /// - /// Adds a Galaxy attribute reference to the active runtime session. + /// Adds a Galaxy attribute reference to the active runtime session. /// /// The runtime connection handle. /// The fully qualified attribute reference to resolve. @@ -58,28 +57,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain int AddItem(int handle, string address); /// - /// Removes a previously registered attribute from the runtime session. + /// Removes a previously registered attribute from the runtime session. /// /// The runtime connection handle. - /// The item handle returned by . + /// The item handle returned by . void RemoveItem(int handle, int itemHandle); /// - /// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge. + /// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge. /// /// The runtime connection handle. /// The item handle to monitor. void AdviseSupervisory(int handle, int itemHandle); /// - /// Stops supervisory updates for an attribute. + /// Stops supervisory updates for an attribute. /// /// The runtime connection handle. /// The item handle to stop monitoring. void UnAdviseSupervisory(int handle, int itemHandle); /// - /// Writes a new value to a runtime attribute through the COM proxy. + /// Writes a new value to a runtime attribute through the COM proxy. /// /// The runtime connection handle. /// The item handle to write. @@ -88,13 +87,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain void Write(int handle, int itemHandle, object value, int securityClassification); /// - /// Occurs when the runtime pushes a data-change callback for a subscribed attribute. + /// Occurs when the runtime pushes a data-change callback for a subscribed attribute. /// event MxDataChangeHandler? OnDataChange; /// - /// Occurs when the runtime acknowledges completion of a write request. + /// Occurs when the runtime acknowledges completion of a write request. /// event MxWriteCompleteHandler? OnWriteComplete; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs index f2768cc..895ae76 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs @@ -3,31 +3,32 @@ using System.Collections.Generic; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP, etc.). + /// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP, + /// etc.). /// public interface IUserAuthenticationProvider { /// - /// Validates a username/password combination. + /// Validates a username/password combination. /// bool ValidateCredentials(string username, string password); } /// - /// Extended interface for providers that can resolve application-level roles for authenticated users. - /// When the auth provider implements this interface, OnImpersonateUser uses the returned roles - /// to control write and alarm-ack permissions. + /// Extended interface for providers that can resolve application-level roles for authenticated users. + /// When the auth provider implements this interface, OnImpersonateUser uses the returned roles + /// to control write and alarm-ack permissions. /// public interface IRoleProvider { /// - /// Returns the set of application-level roles granted to the user. + /// Returns the set of application-level roles granted to the user. /// IReadOnlyList GetUserRoles(string username); } /// - /// Well-known application-level role names used for permission enforcement. + /// Well-known application-level role names used for permission enforcement. /// public static class AppRoles { @@ -37,4 +38,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain public const string WriteConfigure = "WriteConfigure"; public const string AlarmAck = "AlarmAck"; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs index 8163440..a5a5099 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.DirectoryServices.Protocols; -using System.Linq; using System.Net; using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; @@ -9,7 +8,7 @@ using ZB.MOM.WW.LmxOpcUa.Host.Configuration; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Validates credentials via LDAP bind and resolves group membership to application roles. + /// Validates credentials via LDAP bind and resolves group membership to application roles. /// public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider { @@ -31,30 +30,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain }; } - public bool ValidateCredentials(string username, string password) - { - try - { - var bindDn = _config.BindDnTemplate.Replace("{username}", username); - using (var connection = CreateConnection()) - { - connection.Bind(new NetworkCredential(bindDn, password)); - } - Log.Debug("LDAP bind succeeded for {Username}", username); - return true; - } - catch (LdapException ex) - { - Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message); - return false; - } - catch (Exception ex) - { - Log.Warning(ex, "LDAP error during credential validation for {Username}", username); - return false; - } - } - public IReadOnlyList GetUserRoles(string username) { try @@ -87,15 +62,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain } var roles = new List(); - for (int i = 0; i < memberOf.Count; i++) + for (var i = 0; i < memberOf.Count; i++) { var dn = memberOf[i]?.ToString() ?? ""; // Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...") var groupName = ExtractGroupName(dn); - if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) - { - roles.Add(role); - } + if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role); } if (roles.Count == 0) @@ -115,6 +87,31 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain } } + public bool ValidateCredentials(string username, string password) + { + try + { + var bindDn = _config.BindDnTemplate.Replace("{username}", username); + using (var connection = CreateConnection()) + { + connection.Bind(new NetworkCredential(bindDn, password)); + } + + Log.Debug("LDAP bind succeeded for {Username}", username); + return true; + } + catch (LdapException ex) + { + Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message); + return false; + } + catch (Exception ex) + { + Log.Warning(ex, "LDAP error during credential validation for {Username}", username); + return false; + } + } + private LdapConnection CreateConnection() { var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port); @@ -148,4 +145,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain .Replace("\0", "\\00"); } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs index f5edfaa..df3a60e 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs @@ -1,9 +1,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Stable identifiers for custom OPC UA roles mapped from LDAP groups. - /// The namespace URI is registered in the server namespace table at startup, - /// and the string identifiers are resolved to runtime NodeIds before use. + /// Stable identifiers for custom OPC UA roles mapped from LDAP groups. + /// The namespace URI is registered in the server namespace table at startup, + /// and the string identifiers are resolved to runtime NodeIds before use. /// public static class LmxRoleIds { @@ -15,4 +15,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain public const string WriteConfigure = "Role.WriteConfigure"; public const string AlarmAck = "Role.AlarmAck"; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxDataTypeMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxDataTypeMapper.cs index b42fbe0..b000e2c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxDataTypeMapper.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxDataTypeMapper.cs @@ -3,14 +3,14 @@ using System; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005) - /// See gr/data_type_mapping.md for full mapping table. + /// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005) + /// See gr/data_type_mapping.md for full mapping table. /// public static class MxDataTypeMapper { /// - /// Maps mx_data_type to OPC UA DataType NodeId numeric identifier. - /// Unknown types default to String (i=12). + /// Maps mx_data_type to OPC UA DataType NodeId numeric identifier. + /// Unknown types default to String (i=12). /// /// The Galaxy MX data type code. /// The OPC UA built-in data type node identifier. @@ -18,24 +18,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { return mxDataType switch { - 1 => 1, // Boolean → i=1 - 2 => 6, // Integer → Int32 i=6 - 3 => 10, // Float → Float i=10 - 4 => 11, // Double → Double i=11 - 5 => 12, // String → String i=12 - 6 => 13, // Time → DateTime i=13 - 7 => 11, // ElapsedTime → Double i=11 (seconds) - 8 => 12, // Reference → String i=12 - 13 => 6, // Enumeration → Int32 i=6 - 14 => 12, // Custom → String i=12 - 15 => 21, // InternationalizedString → LocalizedText i=21 - 16 => 12, // Custom → String i=12 - _ => 12 // Unknown → String i=12 + 1 => 1, // Boolean → i=1 + 2 => 6, // Integer → Int32 i=6 + 3 => 10, // Float → Float i=10 + 4 => 11, // Double → Double i=11 + 5 => 12, // String → String i=12 + 6 => 13, // Time → DateTime i=13 + 7 => 11, // ElapsedTime → Double i=11 (seconds) + 8 => 12, // Reference → String i=12 + 13 => 6, // Enumeration → Int32 i=6 + 14 => 12, // Custom → String i=12 + 15 => 21, // InternationalizedString → LocalizedText i=21 + 16 => 12, // Custom → String i=12 + _ => 12 // Unknown → String i=12 }; } /// - /// Maps mx_data_type to the corresponding CLR type. + /// Maps mx_data_type to the corresponding CLR type. /// /// The Galaxy MX data type code. /// The CLR type used to represent runtime values for the MX type. @@ -49,18 +49,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain 4 => typeof(double), 5 => typeof(string), 6 => typeof(DateTime), - 7 => typeof(double), // ElapsedTime as seconds - 8 => typeof(string), // Reference as string - 13 => typeof(int), // Enum backing integer + 7 => typeof(double), // ElapsedTime as seconds + 8 => typeof(string), // Reference as string + 13 => typeof(int), // Enum backing integer 14 => typeof(string), - 15 => typeof(string), // LocalizedText stored as string + 15 => typeof(string), // LocalizedText stored as string 16 => typeof(string), _ => typeof(string) }; } /// - /// Returns the OPC UA type name for a given mx_data_type. + /// Returns the OPC UA type name for a given mx_data_type. /// /// The Galaxy MX data type code. /// The OPC UA type name used in diagnostics. @@ -84,4 +84,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain }; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxErrorCodes.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxErrorCodes.cs index 7ef7ba7..9624476 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxErrorCodes.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxErrorCodes.cs @@ -1,42 +1,42 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009) + /// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009) /// public static class MxErrorCodes { /// - /// The requested Galaxy attribute reference does not resolve in the runtime. + /// The requested Galaxy attribute reference does not resolve in the runtime. /// public const int MX_E_InvalidReference = 1008; /// - /// The supplied value does not match the attribute's configured data type. + /// The supplied value does not match the attribute's configured data type. /// public const int MX_E_WrongDataType = 1012; /// - /// The target attribute cannot be written because it is read-only or protected. + /// The target attribute cannot be written because it is read-only or protected. /// public const int MX_E_NotWritable = 1013; /// - /// The runtime did not complete the operation within the configured timeout. + /// The runtime did not complete the operation within the configured timeout. /// public const int MX_E_RequestTimedOut = 1014; /// - /// Communication with the MXAccess runtime failed during the operation. + /// Communication with the MXAccess runtime failed during the operation. /// public const int MX_E_CommFailure = 1015; /// - /// The operation was attempted without an active MXAccess session. + /// The operation was attempted without an active MXAccess session. /// public const int MX_E_NotConnected = 1016; /// - /// Converts a numeric MXAccess error code into an operator-facing message. + /// Converts a numeric MXAccess error code into an operator-facing message. /// /// The MXAccess error code returned by the runtime. /// A human-readable description of the runtime failure. @@ -55,7 +55,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain } /// - /// Maps an MXAccess error code to the OPC quality state that should be exposed to clients. + /// Maps an MXAccess error code to the OPC quality state that should be exposed to clients. /// /// The MXAccess error code returned by the runtime. /// The quality classification that best represents the runtime failure. @@ -73,4 +73,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain }; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Quality.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Quality.cs index ea6223c..440c963 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Quality.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Quality.cs @@ -1,101 +1,122 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005) + /// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005) /// public enum Quality : byte { // Bad family (0-63) /// - /// No valid process value is available. + /// No valid process value is available. /// Bad = 0, + /// - /// The value is invalid because the Galaxy attribute definition or mapping is wrong. + /// The value is invalid because the Galaxy attribute definition or mapping is wrong. /// BadConfigError = 4, + /// - /// The bridge is not currently connected to the Galaxy runtime. + /// The bridge is not currently connected to the Galaxy runtime. /// BadNotConnected = 8, + /// - /// The runtime device or adapter failed while obtaining the value. + /// The runtime device or adapter failed while obtaining the value. /// BadDeviceFailure = 12, + /// - /// The underlying field source reported a bad sensor condition. + /// The underlying field source reported a bad sensor condition. /// BadSensorFailure = 16, + /// - /// Communication with the runtime failed while retrieving the value. + /// Communication with the runtime failed while retrieving the value. /// BadCommFailure = 20, + /// - /// The attribute is intentionally unavailable for service, such as a locked or unwritable value. + /// The attribute is intentionally unavailable for service, such as a locked or unwritable value. /// BadOutOfService = 24, + /// - /// The bridge is still waiting for the first usable value after startup or resubscription. + /// The bridge is still waiting for the first usable value after startup or resubscription. /// BadWaitingForInitialData = 32, // Uncertain family (64-191) /// - /// A value is available, but it should be treated cautiously. + /// A value is available, but it should be treated cautiously. /// Uncertain = 64, + /// - /// The last usable value is being repeated because a newer one is unavailable. + /// The last usable value is being repeated because a newer one is unavailable. /// UncertainLastUsable = 68, + /// - /// The sensor or source is providing a value with reduced accuracy. + /// The sensor or source is providing a value with reduced accuracy. /// UncertainSensorNotAccurate = 80, + /// - /// The value exceeds its engineered limits. + /// The value exceeds its engineered limits. /// UncertainEuExceeded = 84, + /// - /// The source is operating in a degraded or subnormal state. + /// The source is operating in a degraded or subnormal state. /// UncertainSubNormal = 88, // Good family (192+) /// - /// The value is current and suitable for normal client use. + /// The value is current and suitable for normal client use. /// Good = 192, + /// - /// The value is good but currently overridden locally rather than flowing from the live source. + /// The value is good but currently overridden locally rather than flowing from the live source. /// GoodLocalOverride = 216 } /// - /// Helper methods for reasoning about OPC quality families used by the bridge. + /// Helper methods for reasoning about OPC quality families used by the bridge. /// public static class QualityExtensions { /// - /// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients. + /// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients. /// /// The quality code to inspect. - /// when the value is in the good quality range; otherwise, . - public static bool IsGood(this Quality q) => (byte)q >= 192; + /// when the value is in the good quality range; otherwise, . + public static bool IsGood(this Quality q) + { + return (byte)q >= 192; + } /// - /// Determines whether the quality represents an uncertain runtime value that should be treated cautiously. + /// Determines whether the quality represents an uncertain runtime value that should be treated cautiously. /// /// The quality code to inspect. - /// when the value is in the uncertain range; otherwise, . - public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 192; + /// when the value is in the uncertain range; otherwise, . + public static bool IsUncertain(this Quality q) + { + return (byte)q >= 64 && (byte)q < 192; + } /// - /// Determines whether the quality represents a bad runtime value that should not be used as valid process data. + /// Determines whether the quality represents a bad runtime value that should not be used as valid process data. /// /// The quality code to inspect. - /// when the value is in the bad range; otherwise, . - public static bool IsBad(this Quality q) => (byte)q < 64; + /// when the value is in the bad range; otherwise, . + public static bool IsBad(this Quality q) + { + return (byte)q < 64; + } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/QualityMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/QualityMapper.cs index 7b6751e..f8b190c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/QualityMapper.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/QualityMapper.cs @@ -1,13 +1,15 @@ +using System; + namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005) + /// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005) /// public static class QualityMapper { /// - /// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality. - /// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad. + /// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality. + /// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad. /// /// The raw MXAccess quality integer. /// The mapped bridge quality value. @@ -16,7 +18,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain var b = (byte)(mxQuality & 0xFF); // Try exact match first - if (System.Enum.IsDefined(typeof(Quality), b)) + if (Enum.IsDefined(typeof(Quality), b)) return (Quality)b; // Fall back to category @@ -26,7 +28,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain } /// - /// Maps domain Quality to OPC UA StatusCode uint32. + /// Maps domain Quality to OPC UA StatusCode uint32. /// /// The bridge quality value. /// The OPC UA status code represented as a 32-bit unsigned integer. @@ -34,14 +36,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { return quality switch { - Quality.Good => 0x00000000u, // Good + Quality.Good => 0x00000000u, // Good Quality.GoodLocalOverride => 0x00D80000u, // Good_LocalOverride - Quality.Uncertain => 0x40000000u, // Uncertain + Quality.Uncertain => 0x40000000u, // Uncertain Quality.UncertainLastUsable => 0x40900000u, Quality.UncertainSensorNotAccurate => 0x40930000u, Quality.UncertainEuExceeded => 0x40940000u, Quality.UncertainSubNormal => 0x40950000u, - Quality.Bad => 0x80000000u, // Bad + Quality.Bad => 0x80000000u, // Bad Quality.BadConfigError => 0x80890000u, Quality.BadNotConnected => 0x808A0000u, Quality.BadDeviceFailure => 0x808B0000u, @@ -50,9 +52,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain Quality.BadOutOfService => 0x808D0000u, Quality.BadWaitingForInitialData => 0x80320000u, _ => quality.IsGood() ? 0x00000000u : - quality.IsUncertain() ? 0x40000000u : - 0x80000000u + quality.IsUncertain() ? 0x40000000u : + 0x80000000u }; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/SecurityClassificationMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/SecurityClassificationMapper.cs index 4d3f058..ab4dd4d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/SecurityClassificationMapper.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/SecurityClassificationMapper.cs @@ -1,17 +1,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Maps Galaxy security classification values to OPC UA write access decisions. - /// See gr/data_type_mapping.md for the full mapping table. + /// Maps Galaxy security classification values to OPC UA write access decisions. + /// See gr/data_type_mapping.md for the full mapping table. /// public static class SecurityClassificationMapper { /// - /// Determines whether an attribute with the given security classification should allow writes. + /// Determines whether an attribute with the given security classification should allow writes. /// /// The Galaxy security classification value. - /// for FreeAccess (0), Operate (1), Tune (4), Configure (5); - /// for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6). + /// + /// for FreeAccess (0), Operate (1), Tune (4), Configure (5); + /// for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6). + /// public static bool IsWritable(int securityClassification) { switch (securityClassification) @@ -25,4 +27,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Vtq.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Vtq.cs index 25f556c..0b94218 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Vtq.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Vtq.cs @@ -3,27 +3,27 @@ using System; namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// - /// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007) + /// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007) /// public readonly struct Vtq : IEquatable { /// - /// Gets the runtime value returned for the Galaxy attribute. + /// Gets the runtime value returned for the Galaxy attribute. /// public object? Value { get; } /// - /// Gets the timestamp associated with the runtime value. + /// Gets the timestamp associated with the runtime value. /// public DateTime Timestamp { get; } /// - /// Gets the quality classification that tells OPC UA clients whether the value is usable. + /// Gets the quality classification that tells OPC UA clients whether the value is usable. /// public Quality Quality { get; } /// - /// Initializes a new instance of the struct for a Galaxy attribute value. + /// Initializes a new instance of the struct for a Galaxy attribute value. /// /// The runtime value returned by MXAccess. /// The timestamp assigned to the runtime value. @@ -36,41 +36,61 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain } /// - /// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value. + /// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value. /// /// The runtime value to wrap. /// A VTQ carrying the provided value with the current UTC timestamp and good quality. - public static Vtq Good(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Good); + public static Vtq Good(object? value) + { + return new Vtq(value, DateTime.UtcNow, Quality.Good); + } /// - /// Creates a bad-quality VTQ snapshot when no usable runtime value is available. + /// Creates a bad-quality VTQ snapshot when no usable runtime value is available. /// /// The specific bad quality reason to expose to clients. /// A VTQ with no value, the current UTC timestamp, and the requested bad quality. - public static Vtq Bad(Quality quality = Quality.Bad) => new Vtq(null, DateTime.UtcNow, quality); + public static Vtq Bad(Quality quality = Quality.Bad) + { + return new Vtq(null, DateTime.UtcNow, quality); + } /// - /// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously. + /// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously. /// /// The runtime value to wrap. /// A VTQ carrying the provided value with the current UTC timestamp and uncertain quality. - public static Vtq Uncertain(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Uncertain); + public static Vtq Uncertain(object? value) + { + return new Vtq(value, DateTime.UtcNow, Quality.Uncertain); + } /// - /// Compares two VTQ snapshots for exact value, timestamp, and quality equality. + /// Compares two VTQ snapshots for exact value, timestamp, and quality equality. /// /// The other VTQ snapshot to compare. - /// when all fields match; otherwise, . - public bool Equals(Vtq other) => - Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality; + /// when all fields match; otherwise, . + public bool Equals(Vtq other) + { + return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality; + } /// - public override bool Equals(object? obj) => obj is Vtq other && Equals(other); + public override bool Equals(object? obj) + { + return obj is Vtq other && Equals(other); + } /// - public override int GetHashCode() => HashCode.Combine(Value, Timestamp, Quality); + public override int GetHashCode() + { + return HashCode.Combine(Value, Timestamp, Quality); + } /// - public override string ToString() => $"Vtq({Value}, {Timestamp:O}, {Quality})"; + public override string ToString() + { + return $"Vtq({Value}, {Timestamp:O}, {Quality})"; + } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs index ba1b695..8ac08ac 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs @@ -2,48 +2,56 @@ using System; using System.Threading; using System.Threading.Tasks; using Serilog; -using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository { /// - /// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004) + /// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004) /// public class ChangeDetectionService : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); + private readonly int _intervalSeconds; private readonly IGalaxyRepository _repository; - private readonly int _intervalSeconds; private CancellationTokenSource? _cts; - private DateTime? _lastKnownDeployTime; /// - /// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt. - /// - public event Action? OnGalaxyChanged; - - /// - /// Gets the last deploy timestamp observed by the polling loop. - /// - public DateTime? LastKnownDeployTime => _lastKnownDeployTime; - - /// - /// Initializes a new change detector for Galaxy deploy timestamps. + /// Initializes a new change detector for Galaxy deploy timestamps. /// /// The repository used to query the latest deploy timestamp. /// The polling interval, in seconds, between deploy checks. /// An optional deploy timestamp already known at service startup. - public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds, DateTime? initialDeployTime = null) + public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds, + DateTime? initialDeployTime = null) { _repository = repository; _intervalSeconds = intervalSeconds; - _lastKnownDeployTime = initialDeployTime; + LastKnownDeployTime = initialDeployTime; } /// - /// Starts the background polling loop that watches for Galaxy deploy changes. + /// Gets the last deploy timestamp observed by the polling loop. + /// + public DateTime? LastKnownDeployTime { get; private set; } + + /// + /// Stops the polling loop and disposes the underlying cancellation resources. + /// + public void Dispose() + { + Stop(); + _cts?.Dispose(); + } + + /// + /// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt. + /// + public event Action? OnGalaxyChanged; + + /// + /// Starts the background polling loop that watches for Galaxy deploy changes. /// public void Start() { @@ -53,7 +61,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository } /// - /// Stops the background polling loop. + /// Stops the background polling loop. /// public void Stop() { @@ -64,7 +72,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository private async Task PollLoopAsync(CancellationToken ct) { // If no initial deploy time was provided, first poll triggers unconditionally - bool firstPoll = _lastKnownDeployTime == null; + var firstPoll = LastKnownDeployTime == null; while (!ct.IsCancellationRequested) { @@ -75,15 +83,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository if (firstPoll) { firstPoll = false; - _lastKnownDeployTime = deployTime; + LastKnownDeployTime = deployTime; Log.Information("Initial deploy time: {DeployTime}", deployTime); OnGalaxyChanged?.Invoke(); } - else if (deployTime != _lastKnownDeployTime) + else if (deployTime != LastKnownDeployTime) { Log.Information("Galaxy deployment change detected: {Previous} → {Current}", - _lastKnownDeployTime, deployTime); - _lastKnownDeployTime = deployTime; + LastKnownDeployTime, deployTime); + LastKnownDeployTime = deployTime; OnGalaxyChanged?.Invoke(); } } @@ -106,14 +114,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository } } } - - /// - /// Stops the polling loop and disposes the underlying cancellation resources. - /// - public void Dispose() - { - Stop(); - _cts?.Dispose(); - } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs index 4a04437..4db7813 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs @@ -10,7 +10,7 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository { /// - /// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007) + /// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007) /// public class GalaxyRepositoryService : IGalaxyRepository { @@ -19,10 +19,178 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository private readonly GalaxyRepositoryConfiguration _config; /// - /// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild. + /// Initializes a new repository service that reads Galaxy metadata from the configured SQL database. + /// + /// The repository connection, timeout, and attribute-selection settings. + public GalaxyRepositoryService(GalaxyRepositoryConfiguration config) + { + _config = config; + } + + /// + /// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild. /// public event Action? OnGalaxyChanged; + /// + /// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree. + /// + /// A token that cancels the database query. + /// The deployed Galaxy objects that should appear in the namespace. + public async Task> GetHierarchyAsync(CancellationToken ct = default) + { + var results = new List(); + + using var conn = new SqlConnection(_config.ConnectionString); + await conn.OpenAsync(ct); + + using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; + using var reader = await cmd.ExecuteReaderAsync(ct); + + while (await reader.ReadAsync(ct)) + results.Add(new GalaxyObjectInfo + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + ContainedName = reader.IsDBNull(2) ? "" : reader.GetString(2), + BrowseName = reader.GetString(3), + ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), + IsArea = Convert.ToInt32(reader.GetValue(5)) == 1 + }); + + if (results.Count == 0) + Log.Warning("GetHierarchyAsync returned zero rows"); + else + Log.Information("GetHierarchyAsync returned {Count} objects", results.Count); + + return results; + } + + /// + /// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes. + /// + /// A token that cancels the database query. + /// The attribute rows required to build runtime tag mappings and variable metadata. + public async Task> GetAttributesAsync(CancellationToken ct = default) + { + var results = new List(); + var extended = _config.ExtendedAttributes; + var sql = extended ? ExtendedAttributesSql : AttributesSql; + + using var conn = new SqlConnection(_config.ConnectionString); + await conn.OpenAsync(ct); + + using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; + using var reader = await cmd.ExecuteReaderAsync(ct); + + while (await reader.ReadAsync(ct)) + results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader)); + + Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count, + extended); + return results; + } + + /// + /// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale. + /// + /// A token that cancels the database query. + /// The most recent deploy timestamp, or when none is available. + public async Task GetLastDeployTimeAsync(CancellationToken ct = default) + { + using var conn = new SqlConnection(_config.ConnectionString); + await conn.OpenAsync(ct); + + using var cmd = new SqlCommand(ChangeDetectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; + var result = await cmd.ExecuteScalarAsync(ct); + + return result is DateTime dt ? dt : null; + } + + /// + /// Executes a lightweight query to confirm that the repository database is reachable. + /// + /// A token that cancels the connectivity check. + /// when the query succeeds; otherwise, . + public async Task TestConnectionAsync(CancellationToken ct = default) + { + try + { + using var conn = new SqlConnection(_config.ConnectionString); + await conn.OpenAsync(ct); + + using var cmd = new SqlCommand(TestConnectionSql, conn) + { CommandTimeout = _config.CommandTimeoutSeconds }; + await cmd.ExecuteScalarAsync(ct); + + Log.Information("Galaxy repository database connection successful"); + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Galaxy repository database connection failed"); + return false; + } + } + + /// + /// Reads a row from the standard attributes query (12 columns). + /// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type, + /// data_type_name, is_array, array_dimension, mx_attribute_category, + /// security_classification, is_historized, is_alarm + /// + private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader) + { + return new GalaxyAttributeInfo + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + AttributeName = reader.GetString(2), + FullTagReference = reader.GetString(3), + MxDataType = Convert.ToInt32(reader.GetValue(4)), + DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5), + IsArray = Convert.ToBoolean(reader.GetValue(6)), + ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)), + SecurityClassification = Convert.ToInt32(reader.GetValue(9)), + IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, + IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1 + }; + } + + /// + /// Reads a row from the extended attributes query (14 columns). + /// Columns: gobject_id, tag_name, primitive_name, attribute_name, full_tag_reference, + /// mx_data_type, data_type_name, is_array, array_dimension, + /// mx_attribute_category, security_classification, is_historized, is_alarm, attribute_source + /// + private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader) + { + return new GalaxyAttributeInfo + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2), + AttributeName = reader.GetString(3), + FullTagReference = reader.GetString(4), + MxDataType = Convert.ToInt32(reader.GetValue(5)), + DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6), + IsArray = Convert.ToBoolean(reader.GetValue(7)), + ArrayDimension = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)), + SecurityClassification = Convert.ToInt32(reader.GetValue(10)), + IsHistorized = Convert.ToInt32(reader.GetValue(11)) == 1, + IsAlarm = Convert.ToInt32(reader.GetValue(12)) == 1, + AttributeSource = reader.IsDBNull(13) ? "" : reader.GetString(13) + }; + } + + /// + /// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy. + /// + public void RaiseGalaxyChanged() + { + OnGalaxyChanged?.Invoke(); + } + #region SQL Queries (GR-006: const string, no dynamic SQL) private const string HierarchySql = @" @@ -263,172 +431,5 @@ ORDER BY tag_name, primitive_name, attribute_name"; private const string TestConnectionSql = "SELECT 1"; #endregion - - /// - /// Initializes a new repository service that reads Galaxy metadata from the configured SQL database. - /// - /// The repository connection, timeout, and attribute-selection settings. - public GalaxyRepositoryService(GalaxyRepositoryConfiguration config) - { - _config = config; - } - - /// - /// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree. - /// - /// A token that cancels the database query. - /// The deployed Galaxy objects that should appear in the namespace. - public async Task> GetHierarchyAsync(CancellationToken ct = default) - { - var results = new List(); - - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - results.Add(new GalaxyObjectInfo - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - ContainedName = reader.IsDBNull(2) ? "" : reader.GetString(2), - BrowseName = reader.GetString(3), - ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), - IsArea = Convert.ToInt32(reader.GetValue(5)) == 1 - }); - } - - if (results.Count == 0) - Log.Warning("GetHierarchyAsync returned zero rows"); - else - Log.Information("GetHierarchyAsync returned {Count} objects", results.Count); - - return results; - } - - /// - /// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes. - /// - /// A token that cancels the database query. - /// The attribute rows required to build runtime tag mappings and variable metadata. - public async Task> GetAttributesAsync(CancellationToken ct = default) - { - var results = new List(); - var extended = _config.ExtendedAttributes; - var sql = extended ? ExtendedAttributesSql : AttributesSql; - - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader)); - } - - Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count, extended); - return results; - } - - /// - /// Reads a row from the standard attributes query (12 columns). - /// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type, - /// data_type_name, is_array, array_dimension, mx_attribute_category, - /// security_classification, is_historized, is_alarm - /// - private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader) - { - return new GalaxyAttributeInfo - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - AttributeName = reader.GetString(2), - FullTagReference = reader.GetString(3), - MxDataType = Convert.ToInt32(reader.GetValue(4)), - DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5), - IsArray = Convert.ToBoolean(reader.GetValue(6)), - ArrayDimension = reader.IsDBNull(7) ? null : (int?)Convert.ToInt32(reader.GetValue(7)), - SecurityClassification = Convert.ToInt32(reader.GetValue(9)), - IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, - IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1 - }; - } - - /// - /// Reads a row from the extended attributes query (14 columns). - /// Columns: gobject_id, tag_name, primitive_name, attribute_name, full_tag_reference, - /// mx_data_type, data_type_name, is_array, array_dimension, - /// mx_attribute_category, security_classification, is_historized, is_alarm, attribute_source - /// - private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader) - { - return new GalaxyAttributeInfo - { - GobjectId = Convert.ToInt32(reader.GetValue(0)), - TagName = reader.GetString(1), - PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2), - AttributeName = reader.GetString(3), - FullTagReference = reader.GetString(4), - MxDataType = Convert.ToInt32(reader.GetValue(5)), - DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6), - IsArray = Convert.ToBoolean(reader.GetValue(7)), - ArrayDimension = reader.IsDBNull(8) ? null : (int?)Convert.ToInt32(reader.GetValue(8)), - SecurityClassification = Convert.ToInt32(reader.GetValue(10)), - IsHistorized = Convert.ToInt32(reader.GetValue(11)) == 1, - IsAlarm = Convert.ToInt32(reader.GetValue(12)) == 1, - AttributeSource = reader.IsDBNull(13) ? "" : reader.GetString(13) - }; - } - - /// - /// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale. - /// - /// A token that cancels the database query. - /// The most recent deploy timestamp, or when none is available. - public async Task GetLastDeployTimeAsync(CancellationToken ct = default) - { - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(ChangeDetectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - var result = await cmd.ExecuteScalarAsync(ct); - - return result is DateTime dt ? dt : null; - } - - /// - /// Executes a lightweight query to confirm that the repository database is reachable. - /// - /// A token that cancels the connectivity check. - /// when the query succeeds; otherwise, . - public async Task TestConnectionAsync(CancellationToken ct = default) - { - try - { - using var conn = new SqlConnection(_config.ConnectionString); - await conn.OpenAsync(ct); - - using var cmd = new SqlCommand(TestConnectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; - await cmd.ExecuteScalarAsync(ct); - - Log.Information("Galaxy repository database connection successful"); - return true; - } - catch (Exception ex) - { - Log.Warning(ex, "Galaxy repository database connection failed"); - return false; - } - } - - /// - /// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy. - /// - public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs index 76090f5..e3a6de5 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryStats.cs @@ -3,38 +3,38 @@ using System; namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository { /// - /// POCO for dashboard: Galaxy repository status info. (DASH-009) + /// POCO for dashboard: Galaxy repository status info. (DASH-009) /// public class GalaxyRepositoryStats { /// - /// Gets or sets the Galaxy name currently being represented by the bridge. + /// Gets or sets the Galaxy name currently being represented by the bridge. /// public string GalaxyName { get; set; } = ""; /// - /// Gets or sets a value indicating whether the Galaxy repository database is reachable. + /// Gets or sets a value indicating whether the Galaxy repository database is reachable. /// public bool DbConnected { get; set; } /// - /// Gets or sets the latest deploy timestamp read from the Galaxy repository. + /// Gets or sets the latest deploy timestamp read from the Galaxy repository. /// public DateTime? LastDeployTime { get; set; } /// - /// Gets or sets the number of Galaxy objects currently published into the OPC UA address space. + /// Gets or sets the number of Galaxy objects currently published into the OPC UA address space. /// public int ObjectCount { get; set; } /// - /// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space. + /// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space. /// public int AttributeCount { get; set; } /// - /// Gets or sets the UTC time when the address space was last rebuilt from repository data. + /// Gets or sets the UTC time when the address space was last rebuilt from repository data. /// public DateTime? LastRebuildTime { get; set; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs index aea8cf4..06af16f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs @@ -11,7 +11,7 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.Historian { /// - /// Reads historical data from the Wonderware Historian Runtime database. + /// Reads historical data from the Wonderware Historian Runtime database. /// public class HistorianDataSource { @@ -20,7 +20,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian private readonly HistorianConfiguration _config; /// - /// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian queries. + /// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian queries. /// /// The Historian connection settings and command timeout used for runtime history lookups. public HistorianDataSource(HistorianConfiguration config) @@ -29,7 +29,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian } /// - /// Reads raw historical values for a tag from the Historian. + /// Reads raw historical values for a tag from the Historian. /// /// The Wonderware tag name backing the OPC UA node whose raw history is being requested. /// The inclusive start of the client-requested history window. @@ -84,7 +84,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian } /// - /// Reads aggregate historical values for a tag from the Historian. + /// Reads aggregate historical values for a tag from the Historian. /// /// The Wonderware tag name backing the OPC UA node whose aggregate history is being requested. /// The inclusive start of the aggregate history window requested by the OPC UA client. @@ -134,8 +134,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian /// - /// Maps an OPC UA aggregate NodeId to the corresponding Historian column name. - /// Returns null if the aggregate is not supported. + /// Maps an OPC UA aggregate NodeId to the corresponding Historian column name. + /// Returns null if the aggregate is not supported. /// /// The OPC UA aggregate identifier requested by the history client. public static string? MapAggregateToColumn(NodeId aggregateId) @@ -157,4 +157,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian return null; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Metrics/PerformanceMetrics.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Metrics/PerformanceMetrics.cs index 9cb8934..e50715e 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Metrics/PerformanceMetrics.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Metrics/PerformanceMetrics.cs @@ -9,73 +9,73 @@ using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics { /// - /// Disposable scope returned by . (MXA-008) + /// Disposable scope returned by . (MXA-008) /// public interface ITimingScope : IDisposable { /// - /// Marks whether the timed bridge operation completed successfully. + /// Marks whether the timed bridge operation completed successfully. /// /// A value indicating whether the measured operation succeeded. void SetSuccess(bool success); } /// - /// Statistics snapshot for a single operation type. + /// Statistics snapshot for a single operation type. /// public class MetricsStatistics { /// - /// Gets or sets the total number of recorded executions for the operation. + /// Gets or sets the total number of recorded executions for the operation. /// public long TotalCount { get; set; } /// - /// Gets or sets the number of recorded executions that completed successfully. + /// Gets or sets the number of recorded executions that completed successfully. /// public long SuccessCount { get; set; } /// - /// Gets or sets the ratio of successful executions to total executions. + /// Gets or sets the ratio of successful executions to total executions. /// public double SuccessRate { get; set; } /// - /// Gets or sets the mean execution time in milliseconds across the recorded sample. + /// Gets or sets the mean execution time in milliseconds across the recorded sample. /// public double AverageMilliseconds { get; set; } /// - /// Gets or sets the fastest recorded execution time in milliseconds. + /// Gets or sets the fastest recorded execution time in milliseconds. /// public double MinMilliseconds { get; set; } /// - /// Gets or sets the slowest recorded execution time in milliseconds. + /// Gets or sets the slowest recorded execution time in milliseconds. /// public double MaxMilliseconds { get; set; } /// - /// Gets or sets the 95th percentile execution time in milliseconds. + /// Gets or sets the 95th percentile execution time in milliseconds. /// public double Percentile95Milliseconds { get; set; } } /// - /// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008) + /// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008) /// public class OperationMetrics { - private readonly List _durations = new List(); - private readonly object _lock = new object(); - private long _totalCount; - private long _successCount; - private double _totalMilliseconds; - private double _minMilliseconds = double.MaxValue; + private readonly List _durations = new(); + private readonly object _lock = new(); private double _maxMilliseconds; + private double _minMilliseconds = double.MaxValue; + private long _successCount; + private long _totalCount; + private double _totalMilliseconds; /// - /// Records the outcome and duration of a single bridge operation invocation. + /// Records the outcome and duration of a single bridge operation invocation. /// /// The elapsed time for the operation. /// A value indicating whether the operation completed successfully. @@ -98,7 +98,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } /// - /// Creates a snapshot of the current statistics for this operation type. + /// Creates a snapshot of the current statistics for this operation type. /// /// A statistics snapshot suitable for logs, status reporting, and tests. public MetricsStatistics GetStatistics() @@ -126,20 +126,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } /// - /// Tracks per-operation performance metrics with periodic logging. (MXA-008) + /// Tracks per-operation performance metrics with periodic logging. (MXA-008) /// public class PerformanceMetrics : IDisposable { private static readonly ILogger Logger = Log.ForContext(); - private readonly ConcurrentDictionary _metrics - = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary + _metrics = new(StringComparer.OrdinalIgnoreCase); private readonly Timer _reportingTimer; private bool _disposed; /// - /// Initializes a new metrics collector and starts periodic performance reporting. + /// Initializes a new metrics collector and starts periodic performance reporting. /// public PerformanceMetrics() { @@ -148,7 +148,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } /// - /// Records a completed bridge operation under the specified metrics bucket. + /// Stops periodic reporting and emits a final metrics snapshot. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _reportingTimer.Dispose(); + ReportMetrics(null); + } + + /// + /// Records a completed bridge operation under the specified metrics bucket. /// /// The logical operation name, such as read, write, or subscribe. /// The elapsed time for the operation. @@ -160,7 +171,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } /// - /// Starts timing a bridge operation and returns a disposable scope that records the result when disposed. + /// Starts timing a bridge operation and returns a disposable scope that records the result when disposed. /// /// The logical operation name to record. /// A timing scope that reports elapsed time back into this collector. @@ -170,17 +181,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } /// - /// Retrieves the raw metrics bucket for a named operation. + /// Retrieves the raw metrics bucket for a named operation. /// /// The logical operation name to look up. - /// The metrics bucket when present; otherwise, . + /// The metrics bucket when present; otherwise, . public OperationMetrics? GetMetrics(string operationName) { return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null; } /// - /// Produces a statistics snapshot for all recorded bridge operations. + /// Produces a statistics snapshot for all recorded bridge operations. /// /// A dictionary keyed by operation name containing current metrics statistics. public Dictionary GetStatistics() @@ -208,29 +219,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } /// - /// Stops periodic reporting and emits a final metrics snapshot. - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _reportingTimer.Dispose(); - ReportMetrics(null); - } - - /// - /// Timing scope that records one operation result into the owning metrics collector. + /// Timing scope that records one operation result into the owning metrics collector. /// private class TimingScope : ITimingScope { private readonly PerformanceMetrics _metrics; private readonly string _operationName; private readonly Stopwatch _stopwatch; - private bool _success = true; private bool _disposed; + private bool _success = true; /// - /// Initializes a timing scope for a named bridge operation. + /// Initializes a timing scope for a named bridge operation. /// /// The metrics collector that should receive the result. /// The logical operation name being timed. @@ -242,13 +242,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } /// - /// Marks whether the timed operation should be recorded as successful. + /// Marks whether the timed operation should be recorded as successful. /// /// A value indicating whether the operation succeeded. - public void SetSuccess(bool success) => _success = success; + public void SetSuccess(bool success) + { + _success = success; + } /// - /// Stops timing and records the operation result once. + /// Stops timing and records the operation result once. /// public void Dispose() { @@ -259,4 +262,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Connection.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Connection.cs index 4f0df2d..25092c8 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Connection.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Connection.cs @@ -1,8 +1,6 @@ using System; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess @@ -10,7 +8,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess public sealed partial class MxAccessClient { /// - /// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription. + /// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription. /// /// A token that cancels the connection attempt. public async Task ConnectAsync(CancellationToken ct = default) @@ -59,7 +57,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations. + /// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations. /// public async Task DisconnectAsync() { @@ -72,7 +70,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { // UnAdvise + RemoveItem for all active subscriptions foreach (var kvp in _addressToHandle) - { try { _proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value); @@ -82,14 +79,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key); } - } // Unwire events before unregister DetachProxyEvents(); // Unregister - try { _proxy.Unregister(_connectionHandle); } - catch (Exception ex) { Log.Warning(ex, "Error during Unregister"); } + try + { + _proxy.Unregister(_connectionHandle); + } + catch (Exception ex) + { + Log.Warning(ex, "Error during Unregister"); + } }); _handleToAddress.Clear(); @@ -108,7 +110,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Attempts to recover from a runtime fault by disconnecting and reconnecting the client. + /// Attempts to recover from a runtime fault by disconnecting and reconnecting the client. /// public async Task ReconnectAsync() { @@ -144,4 +146,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess _proxyEventsAttached = false; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs index eec170e..906ca5a 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs @@ -1,6 +1,5 @@ using System; using ArchestrA.MxAccess; -using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess @@ -8,8 +7,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess public sealed partial class MxAccessClient { /// - /// COM event handler for MxAccess OnDataChange events. - /// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface. + /// COM event handler for MxAccess OnDataChange events. + /// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface. /// private void HandleOnDataChange( int hLMXServerHandle, @@ -31,30 +30,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess // Check MXSTATUS_PROXY — if success is false, use more specific quality if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0) - { quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail); - } var timestamp = ConvertTimestamp(pftItemTimeStamp); var vtq = new Vtq(pvItemValue, timestamp, quality); // Update probe timestamp if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase)) - { _lastProbeValueTime = DateTime.UtcNow; - } // Invoke stored subscription callback - if (_storedSubscriptions.TryGetValue(address, out var callback)) - { - callback(address, vtq); - } + if (_storedSubscriptions.TryGetValue(address, out var callback)) callback(address, vtq); if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads)) - { foreach (var pendingRead in pendingReads.Values) pendingRead.TrySetResult(vtq); - } // Global handler OnTagValueChanged?.Invoke(address, vtq); @@ -66,7 +56,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// COM event handler for MxAccess OnWriteComplete events. + /// COM event handler for MxAccess OnWriteComplete events. /// private void HandleOnWriteComplete( int hLMXServerHandle, @@ -77,7 +67,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { if (_pendingWrites.TryRemove(phItemHandle, out var tcs)) { - bool success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0; + var success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0; if (success) { tcs.TrySetResult(true); @@ -104,4 +94,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess return DateTime.UtcNow; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs index 0cf6730..3004259 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs @@ -1,7 +1,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess @@ -9,7 +8,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess public sealed partial class MxAccessClient { /// - /// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness. + /// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness. /// public void StartMonitor() { @@ -19,7 +18,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Stops the background monitor loop. + /// Stops the background monitor loop. /// public void StopMonitor() { @@ -41,7 +40,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess try { - if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) && _config.AutoReconnect) + if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) && + _config.AutoReconnect) { Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state); await ReconnectAsync(); @@ -68,4 +68,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess Log.Information("MxAccess monitor stopped"); } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs index 0f189c2..349f225 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs @@ -1,7 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; -using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess @@ -9,7 +9,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess public sealed partial class MxAccessClient { /// - /// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback. + /// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback. /// /// The fully qualified Galaxy tag reference to read. /// A token that cancels the read. @@ -33,7 +33,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess }); var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference, - _ => new System.Collections.Concurrent.ConcurrentDictionary>()); + _ => new ConcurrentDictionary>()); pendingReads[itemHandle] = tcs; _handleToAddress[itemHandle] = fullTagReference; @@ -86,12 +86,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Writes a value to a Galaxy tag and waits for the runtime write-complete callback. + /// Writes a value to a Galaxy tag and waits for the runtime write-complete callback. /// /// The fully qualified Galaxy tag reference to write. /// The value to send to the runtime. /// A token that cancels the write. - /// when the runtime acknowledges success; otherwise, . + /// when the runtime acknowledges success; otherwise, . public async Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) { if (_state != ConnectionState.Connected) return false; @@ -121,7 +121,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds)); cts.Token.Register(() => { - Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference, _config.WriteTimeoutSeconds); + Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference, + _config.WriteTimeoutSeconds); tcs.TrySetResult(false); }); @@ -162,4 +163,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs index b2e05d7..f01faff 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs @@ -1,6 +1,5 @@ using System; using System.Threading.Tasks; -using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess @@ -8,7 +7,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess public sealed partial class MxAccessClient { /// - /// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected. + /// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected. /// /// The fully qualified Galaxy tag reference to monitor. /// The callback that should receive runtime value changes. @@ -22,7 +21,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Removes a persistent subscription callback and tears down the runtime item when appropriate. + /// Removes a persistent subscription callback and tears down the runtime item when appropriate. /// /// The fully qualified Galaxy tag reference to stop monitoring. public async Task UnsubscribeAsync(string fullTagReference) @@ -38,7 +37,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess _handleToAddress.TryRemove(itemHandle, out _); if (_state == ConnectionState.Connected) - { await _staThread.RunAsync(() => { try @@ -51,7 +49,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess Log.Warning(ex, "Error unsubscribing {Address}", fullTagReference); } }); - } } } @@ -95,7 +92,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess private async Task ReplayStoredSubscriptionsAsync() { foreach (var kvp in _storedSubscriptions) - { try { await SubscribeInternalAsync(kvp.Key); @@ -104,9 +100,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key); } - } Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count); } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.cs index f974be7..1943bc4 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Serilog; @@ -11,77 +10,55 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { /// - /// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction. - /// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor. - /// (MXA-001 through MXA-009) + /// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction. + /// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor. + /// (MXA-001 through MXA-009) /// public sealed partial class MxAccessClient : IMxAccessClient { private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly StaComThread _staThread; - private readonly IMxProxy _proxy; + private readonly ConcurrentDictionary _addressToHandle = new(StringComparer.OrdinalIgnoreCase); private readonly MxAccessConfiguration _config; + + // Handle mappings + private readonly ConcurrentDictionary _handleToAddress = new(); private readonly PerformanceMetrics _metrics; private readonly SemaphoreSlim _operationSemaphore; - private int _connectionHandle; - private volatile ConnectionState _state = ConnectionState.Disconnected; - private bool _proxyEventsAttached; - private CancellationTokenSource? _monitorCts; + private readonly ConcurrentDictionary>> + _pendingReadsByAddress + = new(StringComparer.OrdinalIgnoreCase); - // Handle mappings - private readonly ConcurrentDictionary _handleToAddress = new ConcurrentDictionary(); - private readonly ConcurrentDictionary _addressToHandle = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + // Pending writes + private readonly ConcurrentDictionary> _pendingWrites = new(); + + private readonly IMxProxy _proxy; + + private readonly StaComThread _staThread; // Subscription storage private readonly ConcurrentDictionary> _storedSubscriptions - = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + = new(StringComparer.OrdinalIgnoreCase); - // Pending writes - private readonly ConcurrentDictionary> _pendingWrites - = new ConcurrentDictionary>(); - private readonly ConcurrentDictionary>> _pendingReadsByAddress - = new ConcurrentDictionary>>(StringComparer.OrdinalIgnoreCase); + private int _connectionHandle; + private DateTime _lastProbeValueTime = DateTime.UtcNow; + private CancellationTokenSource? _monitorCts; // Probe private string? _probeTag; - private DateTime _lastProbeValueTime = DateTime.UtcNow; + private bool _proxyEventsAttached; private int _reconnectCount; + private volatile ConnectionState _state = ConnectionState.Disconnected; /// - /// Gets the current runtime connection state for the MXAccess client. - /// - public ConnectionState State => _state; - - /// - /// Gets the number of active tag subscriptions currently maintained against the runtime. - /// - public int ActiveSubscriptionCount => _storedSubscriptions.Count; - - /// - /// Gets the number of reconnect attempts performed since the client was created. - /// - public int ReconnectCount => _reconnectCount; - - /// - /// Occurs when the MXAccess connection state changes. - /// - public event EventHandler? ConnectionStateChanged; - - /// - /// Occurs when a subscribed runtime tag publishes a new value. - /// - public event Action? OnTagValueChanged; - - /// - /// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings. + /// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings. /// /// The STA thread used to marshal COM interactions. /// The COM proxy abstraction used to talk to the runtime. /// The runtime timeout, throttling, and reconnect settings. /// The metrics collector used to time MXAccess operations. - public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config, PerformanceMetrics metrics) + public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config, + PerformanceMetrics metrics) { _staThread = staThread; _proxy = proxy; @@ -90,17 +67,33 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess _operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations); } - private void SetState(ConnectionState newState, string message = "") - { - var previous = _state; - if (previous == newState) return; - _state = newState; - Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message); - ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message)); - } + /// + /// Gets the current runtime connection state for the MXAccess client. + /// + public ConnectionState State => _state; /// - /// Cancels monitoring and disconnects the runtime session before releasing local resources. + /// Gets the number of active tag subscriptions currently maintained against the runtime. + /// + public int ActiveSubscriptionCount => _storedSubscriptions.Count; + + /// + /// Gets the number of reconnect attempts performed since the client was created. + /// + public int ReconnectCount => _reconnectCount; + + /// + /// Occurs when the MXAccess connection state changes. + /// + public event EventHandler? ConnectionStateChanged; + + /// + /// Occurs when a subscribed runtime tag publishes a new value. + /// + public event Action? OnTagValueChanged; + + /// + /// Cancels monitoring and disconnects the runtime session before releasing local resources. /// public void Dispose() { @@ -119,5 +112,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess _monitorCts?.Dispose(); } } + + private void SetState(ConnectionState newState, string message = "") + { + var previous = _state; + if (previous == newState) return; + _state = newState; + Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message); + ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message)); + } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxProxyAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxProxyAdapter.cs index 1dd07cd..b882540 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxProxyAdapter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxProxyAdapter.cs @@ -6,25 +6,25 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { /// - /// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy. - /// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001) + /// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy. + /// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001) /// public sealed class MxProxyAdapter : IMxProxy { private LMXProxyServer? _lmxProxy; /// - /// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute. + /// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute. /// public event MxDataChangeHandler? OnDataChange; /// - /// Occurs when the COM proxy confirms completion of a write request. + /// Occurs when the COM proxy confirms completion of a write request. /// public event MxWriteCompleteHandler? OnWriteComplete; /// - /// Creates and registers the COM proxy session that backs live MXAccess operations. + /// Creates and registers the COM proxy session that backs live MXAccess operations. /// /// The client name reported to the Wonderware runtime. /// The runtime connection handle assigned by the COM server. @@ -43,13 +43,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Unregisters the COM proxy session and releases the underlying COM object. + /// Unregisters the COM proxy session and releases the underlying COM object. /// - /// The runtime connection handle returned by . + /// The runtime connection handle returned by . public void Unregister(int handle) { if (_lmxProxy != null) - { try { _lmxProxy.OnDataChange -= ProxyOnDataChange; @@ -61,52 +60,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess Marshal.ReleaseComObject(_lmxProxy); _lmxProxy = null; } - } } /// - /// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy. + /// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy. /// /// The runtime connection handle. /// The fully qualified Galaxy attribute reference. /// The item handle assigned by the COM proxy. - public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address); + public int AddItem(int handle, string address) + { + return _lmxProxy!.AddItem(handle, address); + } /// - /// Removes an item handle from the active COM proxy session. + /// Removes an item handle from the active COM proxy session. /// /// The runtime connection handle. /// The item handle to remove. - public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle); + public void RemoveItem(int handle, int itemHandle) + { + _lmxProxy!.RemoveItem(handle, itemHandle); + } /// - /// Enables supervisory callbacks for the specified runtime item. + /// Enables supervisory callbacks for the specified runtime item. /// /// The runtime connection handle. /// The item handle to monitor. - public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle); + public void AdviseSupervisory(int handle, int itemHandle) + { + _lmxProxy!.AdviseSupervisory(handle, itemHandle); + } /// - /// Disables supervisory callbacks for the specified runtime item. + /// Disables supervisory callbacks for the specified runtime item. /// /// The runtime connection handle. /// The item handle to stop monitoring. - public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle); + public void UnAdviseSupervisory(int handle, int itemHandle) + { + _lmxProxy!.UnAdvise(handle, itemHandle); + } /// - /// Writes a value to the specified runtime item through the COM proxy. + /// Writes a value to the specified runtime item through the COM proxy. /// /// The runtime connection handle. /// The item handle to write. /// The value to send to the runtime. /// The Wonderware security classification applied to the write. public void Write(int handle, int itemHandle, object value, int securityClassification) - => _lmxProxy!.Write(handle, itemHandle, value, securityClassification); + { + _lmxProxy!.Write(handle, itemHandle, value, securityClassification); + } private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue, int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus) { - OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp, ref ItemStatus); + OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp, + ref ItemStatus); } private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus) @@ -114,4 +127,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus); } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/StaComThread.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/StaComThread.cs index d58e1b7..d5f365a 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/StaComThread.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/StaComThread.cs @@ -8,8 +8,8 @@ using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { /// - /// Dedicated STA thread with a raw Win32 message pump for COM interop. - /// All MxAccess COM objects must be created and called on this thread. (MXA-001) + /// Dedicated STA thread with a raw Win32 message pump for COM interop. + /// All MxAccess COM objects must be created and called on this thread. (MXA-001) /// public sealed class StaComThread : IDisposable { @@ -18,21 +18,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess private static readonly ILogger Log = Serilog.Log.ForContext(); private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5); + private readonly TaskCompletionSource _ready = new(); private readonly Thread _thread; - private readonly TaskCompletionSource _ready = new TaskCompletionSource(); - private readonly ConcurrentQueue _workItems = new ConcurrentQueue(); - private volatile uint _nativeThreadId; - private bool _disposed; - - private long _totalMessages; + private readonly ConcurrentQueue _workItems = new(); private long _appMessages; private long _dispatchedMessages; - private long _workItemsExecuted; + private bool _disposed; private DateTime _lastLogTime; + private volatile uint _nativeThreadId; + + private long _totalMessages; + private long _workItemsExecuted; /// - /// Initializes a dedicated STA thread wrapper for Wonderware COM interop. + /// Initializes a dedicated STA thread wrapper for Wonderware COM interop. /// public StaComThread() { @@ -45,12 +45,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Gets a value indicating whether the STA thread is running and able to accept work. + /// Gets a value indicating whether the STA thread is running and able to accept work. /// public bool IsRunning => _nativeThreadId != 0 && !_disposed; /// - /// Starts the STA thread and waits until its message pump is ready for COM work. + /// Stops the STA thread and releases the message-pump resources used for COM interop. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try + { + if (_nativeThreadId != 0) + PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero); + _thread.Join(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error shutting down STA COM thread"); + } + + Log.Information("STA COM thread stopped"); + } + + /// + /// Starts the STA thread and waits until its message pump is ready for COM work. /// public void Start() { @@ -60,7 +82,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Queues an action to execute on the STA thread. + /// Queues an action to execute on the STA thread. /// /// The work item to execute on the STA thread. /// A task that completes when the action has finished executing. @@ -86,7 +108,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess } /// - /// Queues a function to execute on the STA thread and returns its result. + /// Queues a function to execute on the STA thread and returns its result. /// /// The result type produced by the function. /// The work item to execute on the STA thread. @@ -111,28 +133,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess return tcs.Task; } - /// - /// Stops the STA thread and releases the message-pump resources used for COM interop. - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - try - { - if (_nativeThreadId != 0) - PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero); - _thread.Join(TimeSpan.FromSeconds(5)); - } - catch (Exception ex) - { - Log.Warning(ex, "Error shutting down STA COM thread"); - } - - Log.Information("STA COM thread stopped"); - } - private void ThreadEntry() { try @@ -171,7 +171,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess LogPumpStatsIfDue(); } - Log.Information("STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})", + Log.Information( + "STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})", _totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted); } catch (Exception ex) @@ -186,8 +187,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess while (_workItems.TryDequeue(out var workItem)) { _workItemsExecuted++; - try { workItem(); } - catch (Exception ex) { Log.Error(ex, "Unhandled exception in STA work item"); } + try + { + workItem(); + } + catch (Exception ex) + { + Log.Error(ex, "Unhandled exception in STA work item"); + } } } @@ -195,7 +202,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess { var now = DateTime.UtcNow; if (now - _lastLogTime < PumpLogInterval) return; - Log.Debug("STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}", + Log.Debug( + "STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}", _totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count); _lastLogTime = now; } @@ -239,11 +247,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); + private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, + uint wRemoveMsg); [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); #endregion } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs index 5a05c86..42f3477 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs @@ -7,134 +7,16 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Builds the tag reference mappings from Galaxy hierarchy and attributes. - /// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004) + /// Builds the tag reference mappings from Galaxy hierarchy and attributes. + /// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004) /// public class AddressSpaceBuilder { private static readonly ILogger Log = Serilog.Log.ForContext(); /// - /// Node info for the address space tree. - /// - public class NodeInfo - { - /// - /// Gets or sets the Galaxy object identifier represented by this address-space node. - /// - public int GobjectId { get; set; } - - /// - /// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata. - /// - public string TagName { get; set; } = ""; - - /// - /// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node. - /// - public string BrowseName { get; set; } = ""; - - /// - /// Gets or sets the parent Galaxy object identifier used to assemble the tree. - /// - public int ParentGobjectId { get; set; } - - /// - /// Gets or sets a value indicating whether the node represents a Galaxy area folder. - /// - public bool IsArea { get; set; } - - /// - /// Gets or sets the attribute nodes published beneath this object. - /// - public List Attributes { get; set; } = new(); - - /// - /// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy. - /// - public List Children { get; set; } = new(); - } - - /// - /// Lightweight description of an attribute node that will become an OPC UA variable. - /// - public class AttributeNodeInfo - { - /// - /// Gets or sets the Galaxy attribute name published under the object. - /// - public string AttributeName { get; set; } = ""; - - /// - /// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions. - /// - public string FullTagReference { get; set; } = ""; - - /// - /// Gets or sets the Galaxy data type code used to pick the OPC UA variable type. - /// - public int MxDataType { get; set; } - - /// - /// Gets or sets a value indicating whether the attribute is modeled as an array. - /// - public bool IsArray { get; set; } - - /// - /// Gets or sets the declared array length when the attribute is a fixed-size array. - /// - public int? ArrayDimension { get; set; } - - /// - /// Gets or sets the primitive name that groups the attribute under a sub-object node. - /// Empty for root-level attributes. - /// - public string PrimitiveName { get; set; } = ""; - - /// - /// Gets or sets the Galaxy security classification that determines OPC UA write access. - /// - public int SecurityClassification { get; set; } = 1; - - /// - /// Gets or sets a value indicating whether the attribute is historized. - /// - public bool IsHistorized { get; set; } - - /// - /// Gets or sets a value indicating whether the attribute is an alarm. - /// - public bool IsAlarm { get; set; } - } - - /// - /// Result of building the address space model. - /// - public class AddressSpaceModel - { - /// - /// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace. - /// - public List RootNodes { get; set; } = new(); - - /// - /// Gets or sets the mapping from OPC UA node identifiers to runtime tag references. - /// - public Dictionary NodeIdToTagReference { get; set; } = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets or sets the number of non-area Galaxy objects included in the model. - /// - public int ObjectCount { get; set; } - - /// - /// Gets or sets the number of variable nodes created from Galaxy attributes. - /// - public int VariableCount { get; set; } - } - - /// - /// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes nodes. + /// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes + /// nodes. /// /// The Galaxy object hierarchy returned by the repository. /// The Galaxy attribute rows associated with the hierarchy. @@ -187,7 +69,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa model.ObjectCount++; if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs)) - { foreach (var attr in attrs) { node.Attributes.Add(new AttributeNodeInfo @@ -206,7 +87,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference; model.VariableCount++; } - } return node; } @@ -220,5 +100,125 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa ? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2) : attr.FullTagReference; } + + /// + /// Node info for the address space tree. + /// + public class NodeInfo + { + /// + /// Gets or sets the Galaxy object identifier represented by this address-space node. + /// + public int GobjectId { get; set; } + + /// + /// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata. + /// + public string TagName { get; set; } = ""; + + /// + /// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node. + /// + public string BrowseName { get; set; } = ""; + + /// + /// Gets or sets the parent Galaxy object identifier used to assemble the tree. + /// + public int ParentGobjectId { get; set; } + + /// + /// Gets or sets a value indicating whether the node represents a Galaxy area folder. + /// + public bool IsArea { get; set; } + + /// + /// Gets or sets the attribute nodes published beneath this object. + /// + public List Attributes { get; set; } = new(); + + /// + /// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy. + /// + public List Children { get; set; } = new(); + } + + /// + /// Lightweight description of an attribute node that will become an OPC UA variable. + /// + public class AttributeNodeInfo + { + /// + /// Gets or sets the Galaxy attribute name published under the object. + /// + public string AttributeName { get; set; } = ""; + + /// + /// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions. + /// + public string FullTagReference { get; set; } = ""; + + /// + /// Gets or sets the Galaxy data type code used to pick the OPC UA variable type. + /// + public int MxDataType { get; set; } + + /// + /// Gets or sets a value indicating whether the attribute is modeled as an array. + /// + public bool IsArray { get; set; } + + /// + /// Gets or sets the declared array length when the attribute is a fixed-size array. + /// + public int? ArrayDimension { get; set; } + + /// + /// Gets or sets the primitive name that groups the attribute under a sub-object node. + /// Empty for root-level attributes. + /// + public string PrimitiveName { get; set; } = ""; + + /// + /// Gets or sets the Galaxy security classification that determines OPC UA write access. + /// + public int SecurityClassification { get; set; } = 1; + + /// + /// Gets or sets a value indicating whether the attribute is historized. + /// + public bool IsHistorized { get; set; } + + /// + /// Gets or sets a value indicating whether the attribute is an alarm. + /// + public bool IsAlarm { get; set; } + } + + /// + /// Result of building the address space model. + /// + public class AddressSpaceModel + { + /// + /// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace. + /// + public List RootNodes { get; set; } = new(); + + /// + /// Gets or sets the mapping from OPC UA node identifiers to runtime tag references. + /// + public Dictionary NodeIdToTagReference { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the number of non-area Galaxy objects included in the model. + /// + public int ObjectCount { get; set; } + + /// + /// Gets or sets the number of variable nodes created from Galaxy attributes. + /// + public int VariableCount { get; set; } + } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceDiff.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceDiff.cs index 17307f2..e04a9f9 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceDiff.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceDiff.cs @@ -5,12 +5,12 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes. + /// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes. /// public static class AddressSpaceDiff { /// - /// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference. + /// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference. /// /// The previously published Galaxy object hierarchy snapshot. /// The previously published Galaxy attribute snapshot keyed to the old hierarchy. @@ -27,24 +27,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Added objects foreach (var id in newObjects.Keys) - { if (!oldObjects.ContainsKey(id)) changed.Add(id); - } // Removed objects foreach (var id in oldObjects.Keys) - { if (!newObjects.ContainsKey(id)) changed.Add(id); - } // Modified objects foreach (var kvp in newObjects) - { if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value)) changed.Add(kvp.Key); - } // Attribute changes — group by gobject_id and compare var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId) @@ -72,7 +66,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy. + /// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy. /// /// The root Galaxy objects that were detected as changed between snapshots. /// The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt. @@ -88,13 +82,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { var id = queue.Dequeue(); if (childrenByParent.TryGetValue(id, out var children)) - { foreach (var childId in children) - { if (expanded.Add(childId)) queue.Enqueue(childId); - } - } } return expanded; @@ -103,10 +93,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b) { return a.TagName == b.TagName - && a.BrowseName == b.BrowseName - && a.ContainedName == b.ContainedName - && a.ParentGobjectId == b.ParentGobjectId - && a.IsArea == b.IsArea; + && a.BrowseName == b.BrowseName + && a.ContainedName == b.ContainedName + && a.ParentGobjectId == b.ParentGobjectId + && a.IsArea == b.IsArea; } private static bool AttributeSetsEqual(List? a, List? b) @@ -119,11 +109,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList(); var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList(); - for (int i = 0; i < sortedA.Count; i++) - { + for (var i = 0; i < sortedA.Count; i++) if (!AttributesEqual(sortedA[i], sortedB[i])) return false; - } return true; } @@ -131,14 +119,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b) { return a.AttributeName == b.AttributeName - && a.FullTagReference == b.FullTagReference - && a.MxDataType == b.MxDataType - && a.IsArray == b.IsArray - && a.ArrayDimension == b.ArrayDimension - && a.PrimitiveName == b.PrimitiveName - && a.SecurityClassification == b.SecurityClassification - && a.IsHistorized == b.IsHistorized - && a.IsAlarm == b.IsAlarm; + && a.FullTagReference == b.FullTagReference + && a.MxDataType == b.MxDataType + && a.IsArray == b.IsArray + && a.ArrayDimension == b.ArrayDimension + && a.PrimitiveName == b.PrimitiveName + && a.SecurityClassification == b.SecurityClassification + && a.IsHistorized == b.IsHistorized + && a.IsAlarm == b.IsAlarm; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/DataValueConverter.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/DataValueConverter.cs index 6410b28..095fb2c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/DataValueConverter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/DataValueConverter.cs @@ -5,12 +5,12 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007) + /// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007) /// public static class DataValueConverter { /// - /// Converts a bridge VTQ snapshot into an OPC UA data value. + /// Converts a bridge VTQ snapshot into an OPC UA data value. /// /// The VTQ snapshot to convert. /// An OPC UA data value suitable for reads and subscriptions. @@ -22,7 +22,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { Value = ConvertToOpcUaValue(vtq.Value), StatusCode = statusCode, - SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc ? vtq.Timestamp : vtq.Timestamp.ToUniversalTime(), + SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc + ? vtq.Timestamp + : vtq.Timestamp.ToUniversalTime(), ServerTimestamp = DateTime.UtcNow }; @@ -30,7 +32,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Converts an OPC UA data value back into a bridge VTQ snapshot. + /// Converts an OPC UA data value back into a bridge VTQ snapshot. /// /// The OPC UA data value to convert. /// A VTQ snapshot containing the converted value, timestamp, and derived quality. @@ -87,4 +89,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa }; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index c4dec47..8717f06 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -13,179 +13,63 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Custom node manager that builds the OPC UA address space from Galaxy hierarchy data. - /// (OPC-002 through OPC-013) + /// Custom node manager that builds the OPC UA address space from Galaxy hierarchy data. + /// (OPC-002 through OPC-013) /// public class LmxNodeManager : CustomNodeManager2 { private static readonly ILogger Log = Serilog.Log.ForContext(); + private readonly Dictionary _alarmAckedTags = new(StringComparer.OrdinalIgnoreCase); + private readonly NodeId? _alarmAckRoleId; - private readonly IMxAccessClient _mxAccessClient; - private readonly PerformanceMetrics _metrics; - private readonly HistorianDataSource? _historianDataSource; + // Alarm tracking: maps InAlarm tag reference → alarm source info + private readonly Dictionary _alarmInAlarmTags = new(StringComparer.OrdinalIgnoreCase); private readonly bool _alarmTrackingEnabled; private readonly bool _anonymousCanWrite; - private readonly NodeId? _writeOperateRoleId; - private readonly NodeId? _writeTuneRoleId; - private readonly NodeId? _writeConfigureRoleId; - private readonly NodeId? _alarmAckRoleId; + private readonly AutoResetEvent _dataChangeSignal = new(false); + private readonly Dictionary> _gobjectToTagRefs = new(); + private readonly HistorianDataSource? _historianDataSource; + private readonly PerformanceMetrics _metrics; + + private readonly IMxAccessClient _mxAccessClient; private readonly string _namespaceUri; // NodeId → full_tag_reference for read/write resolution - private readonly Dictionary _nodeIdToTagReference = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _nodeIdToTagReference = new(StringComparer.OrdinalIgnoreCase); - // Ref-counted MXAccess subscriptions - private readonly Dictionary _subscriptionRefCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _tagToVariableNode = new Dictionary(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _tagMetadata = new Dictionary(StringComparer.OrdinalIgnoreCase); - - private IDictionary>? _externalReferences; + // Incremental sync: persistent node map and reverse lookup + private readonly Dictionary _nodeMap = new(); // Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock - private readonly ConcurrentDictionary _pendingDataChanges = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - private readonly AutoResetEvent _dataChangeSignal = new AutoResetEvent(false); - private Thread? _dispatchThread; - private volatile bool _dispatchRunning; + private readonly ConcurrentDictionary _pendingDataChanges = new(StringComparer.OrdinalIgnoreCase); + + // Ref-counted MXAccess subscriptions + private readonly Dictionary _subscriptionRefCounts = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tagMetadata = new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary _tagToVariableNode = + new(StringComparer.OrdinalIgnoreCase); + + private readonly NodeId? _writeConfigureRoleId; + private readonly NodeId? _writeOperateRoleId; + private readonly NodeId? _writeTuneRoleId; + private long _dispatchCycleCount; private volatile bool _dispatchDisposed; + private volatile bool _dispatchRunning; + private Thread? _dispatchThread; + + private IDictionary>? _externalReferences; + private List? _lastAttributes; + private List? _lastHierarchy; + private DateTime _lastMetricsReportTime = DateTime.UtcNow; + private long _lastReportedMxChangeEvents; + private long _totalDispatchBatchSize; // Dispatch queue metrics private long _totalMxChangeEvents; - private long _lastReportedMxChangeEvents; - private long _totalDispatchBatchSize; - private long _dispatchCycleCount; - private DateTime _lastMetricsReportTime = DateTime.UtcNow; - private double _lastEventsPerSecond; - private double _lastAvgBatchSize; - - private sealed class TagMetadata - { - /// - /// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants. - /// - public int MxDataType { get; set; } - - /// - /// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node. - /// - public bool IsArray { get; set; } - - /// - /// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array. - /// - public int? ArrayDimension { get; set; } - - /// - /// Gets or sets the Galaxy security classification (0=FreeAccess, 1=Operate, 4=Tune, 5=Configure, etc.). - /// Used at write time to determine which write role is required. - /// - public int SecurityClassification { get; set; } - } - - // Alarm tracking: maps InAlarm tag reference → alarm source info - private readonly Dictionary _alarmInAlarmTags = new Dictionary(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _alarmAckedTags = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // Incremental sync: persistent node map and reverse lookup - private readonly Dictionary _nodeMap = new Dictionary(); - private readonly Dictionary> _gobjectToTagRefs = new Dictionary>(); - private List? _lastHierarchy; - private List? _lastAttributes; - - private sealed class AlarmInfo - { - /// - /// Gets or sets the full tag reference for the process value whose alarm state is tracked. - /// - public string SourceTagReference { get; set; } = ""; - - /// - /// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition. - /// - public NodeId SourceNodeId { get; set; } = NodeId.Null; - - /// - /// Gets or sets the operator-facing source name used in generated alarm events. - /// - public string SourceName { get; set; } = ""; - - /// - /// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued. - /// - public bool LastInAlarm { get; set; } - - /// - /// Gets or sets the retained OPC UA condition node associated with the source alarm. - /// - public AlarmConditionState? ConditionNode { get; set; } - - /// - /// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates. - /// - public string PriorityTagReference { get; set; } = ""; - - /// - /// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text. - /// - public string DescAttrNameTagReference { get; set; } = ""; - - /// - /// Gets or sets the cached OPC UA severity derived from the latest alarm priority value. - /// - public ushort CachedSeverity { get; set; } - - /// - /// Gets or sets the cached alarm message used when emitting active and cleared events. - /// - public string CachedMessage { get; set; } = ""; - - /// - /// Gets or sets the Galaxy tag reference for the alarm acknowledged state. - /// - public string AckedTagReference { get; set; } = ""; - - /// - /// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment. - /// - public string AckMsgTagReference { get; set; } = ""; - } /// - /// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O. - /// - public IReadOnlyDictionary NodeIdToTagReference => _nodeIdToTagReference; - - /// - /// Gets the number of variable nodes currently published from Galaxy attributes. - /// - public int VariableNodeCount { get; private set; } - - /// - /// Gets the number of non-area object nodes currently published from the Galaxy hierarchy. - /// - public int ObjectNodeCount { get; private set; } - - /// - /// Gets the total number of MXAccess data change events received since startup. - /// - public long TotalMxChangeEvents => Interlocked.Read(ref _totalMxChangeEvents); - - /// - /// Gets the number of items currently waiting in the dispatch queue. - /// - public int PendingDataChangeCount => _pendingDataChanges.Count; - - /// - /// Gets the most recently computed MXAccess data change events per second. - /// - public double MxChangeEventsPerSecond => _lastEventsPerSecond; - - /// - /// Gets the most recently computed average dispatch batch size (proxy for queue depth under load). - /// - public double AverageDispatchBatchSize => _lastAvgBatchSize; - - /// - /// Initializes a new node manager for the Galaxy-backed OPC UA namespace. + /// Initializes a new node manager for the Galaxy-backed OPC UA namespace. /// /// The hosting OPC UA server internals. /// The OPC UA application configuration for the host. @@ -227,6 +111,41 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa StartDispatchThread(); } + /// + /// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O. + /// + public IReadOnlyDictionary NodeIdToTagReference => _nodeIdToTagReference; + + /// + /// Gets the number of variable nodes currently published from Galaxy attributes. + /// + public int VariableNodeCount { get; private set; } + + /// + /// Gets the number of non-area object nodes currently published from the Galaxy hierarchy. + /// + public int ObjectNodeCount { get; private set; } + + /// + /// Gets the total number of MXAccess data change events received since startup. + /// + public long TotalMxChangeEvents => Interlocked.Read(ref _totalMxChangeEvents); + + /// + /// Gets the number of items currently waiting in the dispatch queue. + /// + public int PendingDataChangeCount => _pendingDataChanges.Count; + + /// + /// Gets the most recently computed MXAccess data change events per second. + /// + public double MxChangeEventsPerSecond { get; private set; } + + /// + /// Gets the most recently computed average dispatch batch size (proxy for queue depth under load). + /// + public double AverageDispatchBatchSize { get; private set; } + /// public override void CreateAddressSpace(IDictionary> externalReferences) { @@ -238,7 +157,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003) + /// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003) /// /// The Galaxy object hierarchy that defines folders and objects in the namespace. /// The Galaxy attributes that become OPC UA variable nodes. @@ -324,21 +243,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa StringComparer.OrdinalIgnoreCase); // Track variable nodes created for direct attributes that also have primitive children - var variableNodes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var variableNodes = + new Dictionary(StringComparer.OrdinalIgnoreCase); // First pass: create direct (root-level) attribute variables var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key)); if (directGroup != null) - { foreach (var attr in directGroup) { var variable = CreateAttributeVariable(node, attr); if (primitiveGroupNames.Contains(attr.AttributeName)) - { variableNodes[attr.AttributeName] = variable; - } } - } // Second pass: add primitive child attributes under the matching variable node foreach (var group in byPrimitive) @@ -361,88 +277,87 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa parentForAttrs = primNode; } - foreach (var attr in group) - { - CreateAttributeVariable(parentForAttrs, attr); - } + foreach (var attr in group) CreateAttributeVariable(parentForAttrs, attr); } } } // Build alarm tracking: create AlarmConditionState for each alarm attribute - if (_alarmTrackingEnabled) foreach (var obj in sorted) - { - if (obj.IsArea) continue; - if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue; - - var hasAlarms = false; - var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList(); - foreach (var alarmAttr in alarmAttrs) + if (_alarmTrackingEnabled) + foreach (var obj in sorted) { - var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm"; - if (!_tagToVariableNode.ContainsKey(inAlarmTagRef)) - continue; + if (obj.IsArea) continue; + if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue; - var alarmNodeIdStr = alarmAttr.FullTagReference.EndsWith("[]") - ? alarmAttr.FullTagReference.Substring(0, alarmAttr.FullTagReference.Length - 2) - : alarmAttr.FullTagReference; - - // Find the source variable node for the alarm - _tagToVariableNode.TryGetValue(alarmAttr.FullTagReference, out var sourceVariable); - var sourceNodeId = new NodeId(alarmNodeIdStr, NamespaceIndex); - - // Create AlarmConditionState attached to the source variable - var conditionNodeId = new NodeId(alarmNodeIdStr + ".Condition", NamespaceIndex); - var condition = new AlarmConditionState(sourceVariable); - condition.Create(SystemContext, conditionNodeId, - new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex), - new LocalizedText("en", alarmAttr.AttributeName + " Alarm"), - true); - condition.SourceNode.Value = sourceNodeId; - condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']'); - condition.ConditionName.Value = alarmAttr.AttributeName; - condition.AutoReportStateChanges = true; - - // Set initial state: enabled, inactive, acknowledged - condition.SetEnableState(SystemContext, true); - condition.SetActiveState(SystemContext, false); - condition.SetAcknowledgedState(SystemContext, true); - condition.SetSeverity(SystemContext, EventSeverity.Medium); - condition.Retain.Value = false; - condition.OnReportEvent = (context, node, e) => Server.ReportEvent(context, e); - condition.OnAcknowledge = OnAlarmAcknowledge; - - // Add HasCondition reference from source to condition - if (sourceVariable != null) + var hasAlarms = false; + var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)) + .ToList(); + foreach (var alarmAttr in alarmAttrs) { - sourceVariable.AddReference(ReferenceTypeIds.HasCondition, false, conditionNodeId); - condition.AddReference(ReferenceTypeIds.HasCondition, true, sourceNodeId); + var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm"; + if (!_tagToVariableNode.ContainsKey(inAlarmTagRef)) + continue; + + var alarmNodeIdStr = alarmAttr.FullTagReference.EndsWith("[]") + ? alarmAttr.FullTagReference.Substring(0, alarmAttr.FullTagReference.Length - 2) + : alarmAttr.FullTagReference; + + // Find the source variable node for the alarm + _tagToVariableNode.TryGetValue(alarmAttr.FullTagReference, out var sourceVariable); + var sourceNodeId = new NodeId(alarmNodeIdStr, NamespaceIndex); + + // Create AlarmConditionState attached to the source variable + var conditionNodeId = new NodeId(alarmNodeIdStr + ".Condition", NamespaceIndex); + var condition = new AlarmConditionState(sourceVariable); + condition.Create(SystemContext, conditionNodeId, + new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex), + new LocalizedText("en", alarmAttr.AttributeName + " Alarm"), + true); + condition.SourceNode.Value = sourceNodeId; + condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']'); + condition.ConditionName.Value = alarmAttr.AttributeName; + condition.AutoReportStateChanges = true; + + // Set initial state: enabled, inactive, acknowledged + condition.SetEnableState(SystemContext, true); + condition.SetActiveState(SystemContext, false); + condition.SetAcknowledgedState(SystemContext, true); + condition.SetSeverity(SystemContext, EventSeverity.Medium); + condition.Retain.Value = false; + condition.OnReportEvent = (context, node, e) => Server.ReportEvent(context, e); + condition.OnAcknowledge = OnAlarmAcknowledge; + + // Add HasCondition reference from source to condition + if (sourceVariable != null) + { + sourceVariable.AddReference(ReferenceTypeIds.HasCondition, false, conditionNodeId); + condition.AddReference(ReferenceTypeIds.HasCondition, true, sourceNodeId); + } + + AddPredefinedNode(SystemContext, condition); + + var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']'); + var alarmInfo = new AlarmInfo + { + SourceTagReference = alarmAttr.FullTagReference, + SourceNodeId = sourceNodeId, + SourceName = alarmAttr.AttributeName, + ConditionNode = condition, + PriorityTagReference = baseTagRef + ".Priority", + DescAttrNameTagReference = baseTagRef + ".DescAttrName", + AckedTagReference = baseTagRef + ".Acked", + AckMsgTagReference = baseTagRef + ".AckMsg" + }; + _alarmInAlarmTags[inAlarmTagRef] = alarmInfo; + _alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo; + hasAlarms = true; } - AddPredefinedNode(SystemContext, condition); - - var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']'); - var alarmInfo = new AlarmInfo - { - SourceTagReference = alarmAttr.FullTagReference, - SourceNodeId = sourceNodeId, - SourceName = alarmAttr.AttributeName, - ConditionNode = condition, - PriorityTagReference = baseTagRef + ".Priority", - DescAttrNameTagReference = baseTagRef + ".DescAttrName", - AckedTagReference = baseTagRef + ".Acked", - AckMsgTagReference = baseTagRef + ".AckMsg" - }; - _alarmInAlarmTags[inAlarmTagRef] = alarmInfo; - _alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo; - hasAlarms = true; + // Enable EventNotifier on this node and all ancestors so alarm events propagate + if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode)) + EnableEventNotifierUpChain(objNode); } - // Enable EventNotifier on this node and all ancestors so alarm events propagate - if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode)) - EnableEventNotifierUpChain(objNode); - } - // Auto-subscribe to InAlarm tags so we detect alarm transitions if (_alarmTrackingEnabled) SubscribeAlarmTags(); @@ -450,7 +365,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _lastHierarchy = new List(hierarchy); _lastAttributes = new List(attributes); - Log.Information("Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references, {Alarms} alarm tags", + Log.Information( + "Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references, {Alarms} alarm tags", ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count, _alarmInAlarmTags.Count); } } @@ -460,7 +376,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa foreach (var kvp in _alarmInAlarmTags) { // Subscribe to InAlarm, Priority, and DescAttrName for each alarm - var tagsToSubscribe = new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference, kvp.Value.AckedTagReference }; + var tagsToSubscribe = new[] + { + kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference, + kvp.Value.AckedTagReference + }; foreach (var tag in tagsToSubscribe) { if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag)) @@ -510,9 +430,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (condition == null) return; - ushort severity = info.CachedSeverity; - string message = active - ? (!string.IsNullOrEmpty(info.CachedMessage) ? info.CachedMessage : $"Alarm active: {info.SourceName}") + var severity = info.CachedSeverity; + var message = active + ? !string.IsNullOrEmpty(info.CachedMessage) ? info.CachedMessage : $"Alarm active: {info.SourceName}" : $"Alarm cleared: {info.SourceName}"; // Set a new EventId so clients can reference this event for acknowledge @@ -523,7 +443,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa condition.SetSeverity(SystemContext, (EventSeverity)severity); // Retain while active or unacknowledged - condition.Retain.Value = active || (condition.AckedState?.Id?.Value == false); + condition.Retain.Value = active || condition.AckedState?.Id?.Value == false; // Reset acknowledged state when alarm activates if (active) @@ -538,7 +458,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010) + /// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010) /// /// The latest Galaxy object hierarchy to publish. /// The latest Galaxy attributes to publish. @@ -548,7 +468,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Incrementally syncs the address space by detecting changed gobjects and rebuilding only those subtrees. (OPC-010) + /// Incrementally syncs the address space by detecting changed gobjects and rebuilding only those subtrees. (OPC-010) /// /// The latest Galaxy object hierarchy snapshot to compare against the currently published model. /// The latest Galaxy attribute snapshot to compare against the currently published variables. @@ -584,16 +504,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Snapshot subscriptions for changed tags before teardown var affectedSubscriptions = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var id in changedIds) - { if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs)) - { foreach (var tagRef in tagRefs) - { if (_subscriptionRefCounts.TryGetValue(tagRef, out var count)) affectedSubscriptions[tagRef] = count; - } - } - } // Tear down changed subtrees TearDownGobjects(changedIds); @@ -640,8 +554,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Unsubscribe if actively subscribed if (_subscriptionRefCounts.ContainsKey(tagRef)) { - try { _mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult(); } - catch { /* ignore */ } + try + { + _mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult(); + } + catch + { + /* ignore */ + } + _subscriptionRefCounts.Remove(tagRef); } @@ -654,14 +575,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { var info = _alarmInAlarmTags[alarmKey]; // Unsubscribe alarm auto-subscriptions - foreach (var alarmTag in new[] { alarmKey, info.PriorityTagReference, info.DescAttrNameTagReference }) - { + foreach (var alarmTag in new[] + { alarmKey, info.PriorityTagReference, info.DescAttrNameTagReference }) if (!string.IsNullOrEmpty(alarmTag)) - { - try { _mxAccessClient.UnsubscribeAsync(alarmTag).GetAwaiter().GetResult(); } - catch { /* ignore */ } - } - } + try + { + _mxAccessClient.UnsubscribeAsync(alarmTag).GetAwaiter().GetResult(); + } + catch + { + /* ignore */ + } + _alarmInAlarmTags.Remove(alarmKey); if (!string.IsNullOrEmpty(info.AckedTagReference)) _alarmAckedTags.Remove(info.AckedTagReference); @@ -670,8 +595,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Delete variable node if (_tagToVariableNode.TryGetValue(tagRef, out var variable)) { - try { DeleteNode(SystemContext, variable.NodeId); } - catch { /* ignore */ } + try + { + DeleteNode(SystemContext, variable.NodeId); + } + catch + { + /* ignore */ + } + _tagToVariableNode.Remove(tagRef); } @@ -683,14 +615,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa VariableNodeCount--; } + _gobjectToTagRefs.Remove(id); } // Delete the object/folder node itself if (_nodeMap.TryGetValue(id, out var objNode)) { - try { DeleteNode(SystemContext, objNode.NodeId); } - catch { /* ignore */ } + try + { + DeleteNode(SystemContext, objNode.NodeId); + } + catch + { + /* ignore */ + } + _nodeMap.Remove(id); if (!(objNode is FolderState)) ObjectNodeCount--; @@ -782,14 +722,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key)); if (directGroup != null) - { foreach (var attr in directGroup) { var variable = CreateAttributeVariable(node, attr); if (primitiveGroupNames.Contains(attr.AttributeName)) variableNodes[attr.AttributeName] = variable; } - } foreach (var group in byPrimitive) { @@ -809,10 +747,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa parentForAttrs = primNode; } - foreach (var attr in group) - { - CreateAttributeVariable(parentForAttrs, attr); - } + foreach (var attr in group) CreateAttributeVariable(parentForAttrs, attr); } } } @@ -897,19 +832,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (ownerAttr == null || !gobjectIds.Contains(ownerAttr.GobjectId)) continue; - foreach (var tag in new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference }) + foreach (var tag in new[] + { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference }) { if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag)) continue; - try { _mxAccessClient.SubscribeAsync(tag, (_, _) => { }); } - catch { /* ignore */ } + try + { + _mxAccessClient.SubscribeAsync(tag, (_, _) => { }); + } + catch + { + /* ignore */ + } } } } } /// - /// Sorts hierarchy so parents always appear before children, regardless of input order. + /// Sorts hierarchy so parents always appear before children, regardless of input order. /// private static List TopologicalSort(List hierarchy) { @@ -945,17 +887,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa variable.NodeId = new NodeId(nodeIdString, NamespaceIndex); if (attr.IsArray && attr.ArrayDimension.HasValue) - { variable.ArrayDimensions = new ReadOnlyList(new List { (uint)attr.ArrayDimension.Value }); - } var accessLevel = SecurityClassificationMapper.IsWritable(attr.SecurityClassification) ? AccessLevels.CurrentReadOrWrite : AccessLevels.CurrentRead; - if (attr.IsHistorized) - { - accessLevel |= AccessLevels.HistoryRead; - } + if (attr.IsHistorized) accessLevel |= AccessLevels.HistoryRead; variable.AccessLevel = accessLevel; variable.UserAccessLevel = accessLevel; variable.Historizing = attr.IsHistorized; @@ -980,6 +917,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa tagList = new List(); _gobjectToTagRefs[attr.GobjectId] = tagList; } + tagList.Add(attr.FullTagReference); VariableNodeCount++; @@ -1034,7 +972,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa return obj; } - private BaseDataVariableState CreateVariable(NodeState parent, string path, string name, NodeId dataType, int valueRank) + private BaseDataVariableState CreateVariable(NodeState parent, string path, string name, NodeId dataType, + int valueRank) { var variable = new BaseDataVariableState(parent) { @@ -1059,6 +998,110 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa return variable; } + #region Condition Refresh + + /// + /// The OPC UA request context for the condition refresh operation. + /// The monitored event items that should receive retained alarm conditions. + public override ServiceResult ConditionRefresh(OperationContext context, + IList monitoredItems) + { + foreach (var kvp in _alarmInAlarmTags) + { + var info = kvp.Value; + if (info.ConditionNode == null || info.ConditionNode.Retain?.Value != true) + continue; + + foreach (var item in monitoredItems) item.QueueEvent(info.ConditionNode); + } + + return ServiceResult.Good; + } + + #endregion + + private sealed class TagMetadata + { + /// + /// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants. + /// + public int MxDataType { get; set; } + + /// + /// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node. + /// + public bool IsArray { get; set; } + + /// + /// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array. + /// + public int? ArrayDimension { get; set; } + + /// + /// Gets or sets the Galaxy security classification (0=FreeAccess, 1=Operate, 4=Tune, 5=Configure, etc.). + /// Used at write time to determine which write role is required. + /// + public int SecurityClassification { get; set; } + } + + private sealed class AlarmInfo + { + /// + /// Gets or sets the full tag reference for the process value whose alarm state is tracked. + /// + public string SourceTagReference { get; set; } = ""; + + /// + /// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition. + /// + public NodeId SourceNodeId { get; set; } = NodeId.Null; + + /// + /// Gets or sets the operator-facing source name used in generated alarm events. + /// + public string SourceName { get; set; } = ""; + + /// + /// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued. + /// + public bool LastInAlarm { get; set; } + + /// + /// Gets or sets the retained OPC UA condition node associated with the source alarm. + /// + public AlarmConditionState? ConditionNode { get; set; } + + /// + /// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates. + /// + public string PriorityTagReference { get; set; } = ""; + + /// + /// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text. + /// + public string DescAttrNameTagReference { get; set; } = ""; + + /// + /// Gets or sets the cached OPC UA severity derived from the latest alarm priority value. + /// + public ushort CachedSeverity { get; set; } + + /// + /// Gets or sets the cached alarm message used when emitting active and cleared events. + /// + public string CachedMessage { get; set; } = ""; + + /// + /// Gets or sets the Galaxy tag reference for the alarm acknowledged state. + /// + public string AckedTagReference { get; set; } = ""; + + /// + /// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment. + /// + public string AckMsgTagReference { get; set; } = ""; + } + #region Read/Write Handlers /// @@ -1067,7 +1110,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { base.Read(context, maxAge, nodesToRead, results, errors); - for (int i = 0; i < nodesToRead.Count; i++) + for (var i = 0; i < nodesToRead.Count; i++) { if (nodesToRead[i].AttributeId != Attributes.Value) continue; @@ -1079,7 +1122,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (nodeIdStr == null) continue; if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) - { try { var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult(); @@ -1091,7 +1133,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa Log.Warning(ex, "Read failed for {TagRef}", tagRef); errors[i] = new ServiceResult(StatusCodes.BadInternalError); } - } } } @@ -1101,7 +1142,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { base.Write(context, nodesToWrite, errors); - for (int i = 0; i < nodesToWrite.Count; i++) + for (var i = 0; i < nodesToWrite.Count; i++) { if (nodesToWrite[i].AttributeId != Attributes.Value) continue; @@ -1217,13 +1258,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private static void EnableEventNotifierUpChain(NodeState node) { - for (var current = node as BaseInstanceState; current != null; current = current.Parent as BaseInstanceState) - { + for (var current = node as BaseInstanceState; + current != null; + current = current.Parent as BaseInstanceState) if (current is BaseObjectState obj) obj.EventNotifier = EventNotifiers.SubscribeToEvents; else if (current is FolderState folder) folder.EventNotifier = EventNotifiers.SubscribeToEvents; - } } private void ReportEventUpNotifierChain(BaseInstanceState sourceNode, IFilterTarget eventInstance) @@ -1232,14 +1273,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa current.ReportEvent(SystemContext, eventInstance); } - private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray) + private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, + out object updatedArray) { updatedArray = null!; if (!int.TryParse(indexRange, out var index) || index < 0) return false; - var currentValue = NormalizePublishedValue(tagRef, _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult().Value); + var currentValue = + NormalizePublishedValue(tagRef, _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult().Value); if (currentValue is not Array currentArray || currentArray.Rank != 1 || index >= currentArray.Length) return false; @@ -1305,7 +1348,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (value != null) return value; - if (!_tagMetadata.TryGetValue(tagRef, out var metadata) || !metadata.IsArray || !metadata.ArrayDimension.HasValue) + if (!_tagMetadata.TryGetValue(tagRef, out var metadata) || !metadata.IsArray || + !metadata.ArrayDimension.HasValue) return null; return CreateDefaultArrayValue(metadata); @@ -1317,40 +1361,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var values = Array.CreateInstance(elementType, metadata.ArrayDimension!.Value); if (elementType == typeof(string)) - { - for (int i = 0; i < values.Length; i++) + for (var i = 0; i < values.Length; i++) values.SetValue(string.Empty, i); - } return values; } #endregion - #region Condition Refresh - - /// - /// The OPC UA request context for the condition refresh operation. - /// The monitored event items that should receive retained alarm conditions. - public override ServiceResult ConditionRefresh(OperationContext context, IList monitoredItems) - { - foreach (var kvp in _alarmInAlarmTags) - { - var info = kvp.Value; - if (info.ConditionNode == null || info.ConditionNode.Retain?.Value != true) - continue; - - foreach (var item in monitoredItems) - { - item.QueueEvent(info.ConditionNode); - } - } - - return ServiceResult.Good; - } - - #endregion - #region HistoryRead /// @@ -1384,7 +1402,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { var maxValues = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0; var dataValues = _historianDataSource.ReadRawAsync( - tagRef, details.StartTime, details.EndTime, maxValues) + tagRef, details.StartTime, details.EndTime, maxValues) .GetAwaiter().GetResult(); var historyData = new HistoryData(); @@ -1449,8 +1467,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa try { var dataValues = _historianDataSource.ReadAggregateAsync( - tagRef, details.StartTime, details.EndTime, - details.ProcessingInterval, column) + tagRef, details.StartTime, details.EndTime, + details.ProcessingInterval, column) .GetAwaiter().GetResult(); var historyData = new HistoryData(); @@ -1476,12 +1494,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa #region Subscription Delivery /// - /// Called by the OPC UA framework during monitored item creation. - /// Triggers ref-counted MXAccess subscriptions early so the runtime value - /// can arrive before the initial publish to the client. + /// Called by the OPC UA framework during monitored item creation. + /// Triggers ref-counted MXAccess subscriptions early so the runtime value + /// can arrive before the initial publish to the client. /// /// - protected override void OnMonitoredItemCreated(ServerSystemContext context, NodeHandle handle, MonitoredItem monitoredItem) + protected override void OnMonitoredItemCreated(ServerSystemContext context, NodeHandle handle, + MonitoredItem monitoredItem) { base.OnMonitoredItemCreated(context, handle, monitoredItem); @@ -1491,11 +1510,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Called by the OPC UA framework after monitored items are deleted. - /// Decrements ref-counted MXAccess subscriptions. + /// Called by the OPC UA framework after monitored items are deleted. + /// Decrements ref-counted MXAccess subscriptions. /// /// - protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, IList monitoredItems) + protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, + IList monitoredItems) { foreach (var item in monitoredItems) { @@ -1506,11 +1526,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Called by the OPC UA framework after monitored items are transferred to a new session. - /// Rebuilds MXAccess subscription bookkeeping when transferred items arrive without local in-memory state. + /// Called by the OPC UA framework after monitored items are transferred to a new session. + /// Rebuilds MXAccess subscription bookkeeping when transferred items arrive without local in-memory state. /// /// - protected override void OnMonitoredItemsTransferred(ServerSystemContext context, IList monitoredItems) + protected override void OnMonitoredItemsTransferred(ServerSystemContext context, + IList monitoredItems) { base.OnMonitoredItemsTransferred(context, monitoredItems); @@ -1531,7 +1552,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Increments the subscription reference count for a Galaxy tag and opens the runtime subscription when the first OPC UA monitored item appears. + /// Increments the subscription reference count for a Galaxy tag and opens the runtime subscription when the first OPC + /// UA monitored item appears. /// /// The fully qualified Galaxy tag reference to subscribe. internal void SubscribeTag(string fullTagReference) @@ -1555,7 +1577,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Decrements the subscription reference count for a Galaxy tag and closes the runtime subscription when no OPC UA monitored items remain. + /// Decrements the subscription reference count for a Galaxy tag and closes the runtime subscription when no OPC UA + /// monitored items remain. /// /// The fully qualified Galaxy tag reference to unsubscribe. internal void UnsubscribeTag(string fullTagReference) @@ -1582,8 +1605,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Rebuilds subscription reference counts for monitored items that were transferred by the OPC UA stack. - /// Existing in-memory bookkeeping is preserved to avoid double-counting normal in-process transfers. + /// Rebuilds subscription reference counts for monitored items that were transferred by the OPC UA stack. + /// Existing in-memory bookkeeping is preserved to avoid double-counting normal in-process transfers. /// /// The Galaxy tag references represented by the transferred monitored items. internal void RestoreTransferredSubscriptions(IEnumerable fullTagReferences) @@ -1594,7 +1617,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var tagsToSubscribe = new List(); foreach (var kvp in transferredCounts) - { lock (Lock) { if (_subscriptionRefCounts.ContainsKey(kvp.Key)) @@ -1603,7 +1625,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _subscriptionRefCounts[kvp.Key] = kvp.Value; tagsToSubscribe.Add(kvp.Key); } - } foreach (var tagRef in tagsToSubscribe) _ = _mxAccessClient.SubscribeAsync(tagRef, (_, _) => { }); @@ -1653,7 +1674,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa Log.Information("Data change dispatch thread started"); while (_dispatchRunning) - { try { _dataChangeSignal.WaitOne(TimeSpan.FromMilliseconds(100)); @@ -1669,8 +1689,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } // Prepare updates outside the Lock. Shared-state lookups stay inside the Lock. - var updates = new List<(string address, BaseDataVariableState variable, DataValue dataValue)>(keys.Count); - var pendingAlarmEvents = new List<(string address, AlarmInfo info, bool active, ushort? severity, string? message)>(); + var updates = + new List<(string address, BaseDataVariableState variable, DataValue dataValue)>(keys.Count); + var pendingAlarmEvents = + new List<(string address, AlarmInfo info, bool active, ushort? severity, string? message)>(); var pendingAckedEvents = new List<(AlarmInfo info, bool acked)>(); foreach (var address in keys) @@ -1680,13 +1702,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa AlarmInfo? alarmInfo = null; AlarmInfo? ackedAlarmInfo = null; - bool newInAlarm = false; - bool newAcked = false; + var newInAlarm = false; + var newAcked = false; lock (Lock) { if (_tagToVariableNode.TryGetValue(address, out var variable)) - { try { var dataValue = CreatePublishedDataValue(address, vtq); @@ -1696,11 +1717,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { Log.Warning(ex, "Error preparing data change for {Address}", address); } - } if (_alarmInAlarmTags.TryGetValue(address, out alarmInfo)) { - newInAlarm = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int intVal && intVal != 0); + newInAlarm = vtq.Value is true || vtq.Value is 1 || + (vtq.Value is int intVal && intVal != 0); if (newInAlarm == alarmInfo.LastInAlarm) alarmInfo = null; } @@ -1708,7 +1729,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Check for Acked transitions if (_alarmAckedTags.TryGetValue(address, out ackedAlarmInfo)) { - newAcked = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int ackedIntVal && ackedIntVal != 0); + newAcked = vtq.Value is true || vtq.Value is 1 || + (vtq.Value is int ackedIntVal && ackedIntVal != 0); pendingAckedEvents.Add((ackedAlarmInfo, newAcked)); ackedAlarmInfo = null; // handled } @@ -1724,11 +1746,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { try { - var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter().GetResult(); + var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter() + .GetResult(); if (pVtq.Value is int ip) - severity = (ushort)System.Math.Min(System.Math.Max(ip, 1), 1000); + severity = (ushort)Math.Min(Math.Max(ip, 1), 1000); else if (pVtq.Value is short sp) - severity = (ushort)System.Math.Min(System.Math.Max((int)sp, 1), 1000); + severity = (ushort)Math.Min(Math.Max((int)sp, 1), 1000); } catch { @@ -1737,7 +1760,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa try { - var dVtq = _mxAccessClient.ReadAsync(alarmInfo.DescAttrNameTagReference).GetAwaiter().GetResult(); + var dVtq = _mxAccessClient.ReadAsync(alarmInfo.DescAttrNameTagReference).GetAwaiter() + .GetResult(); if (dVtq.Value is string desc && !string.IsNullOrEmpty(desc)) message = desc; } @@ -1752,12 +1776,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Apply under Lock so ClearChangeMasks propagates to monitored items. if (updates.Count > 0 || pendingAlarmEvents.Count > 0 || pendingAckedEvents.Count > 0) - { lock (Lock) { foreach (var (address, variable, dataValue) in updates) { - if (!_tagToVariableNode.TryGetValue(address, out var currentVariable) || !ReferenceEquals(currentVariable, variable)) + if (!_tagToVariableNode.TryGetValue(address, out var currentVariable) || + !ReferenceEquals(currentVariable, variable)) continue; variable.Value = dataValue.Value; @@ -1768,7 +1792,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa foreach (var (address, info, active, severity, message) in pendingAlarmEvents) { - if (!_alarmInAlarmTags.TryGetValue(address, out var currentInfo) || !ReferenceEquals(currentInfo, info)) + if (!_alarmInAlarmTags.TryGetValue(address, out var currentInfo) || + !ReferenceEquals(currentInfo, info)) continue; if (currentInfo.LastInAlarm == active) @@ -1799,7 +1824,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa try { condition.SetAcknowledgedState(SystemContext, acked); - condition.Retain.Value = (condition.ActiveState?.Id?.Value == true) || !acked; + condition.Retain.Value = condition.ActiveState?.Id?.Value == true || !acked; if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var src)) ReportEventUpNotifierChain(src, condition); @@ -1813,7 +1838,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } } } - } Interlocked.Add(ref _totalDispatchBatchSize, updates.Count); Interlocked.Increment(ref _dispatchCycleCount); @@ -1823,7 +1847,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { Log.Error(ex, "Unhandled error in data change dispatch loop"); } - } Log.Information("Data change dispatch thread stopped"); } @@ -1848,8 +1871,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa Interlocked.Exchange(ref _dispatchCycleCount, 0); _lastMetricsReportTime = now; - _lastEventsPerSecond = eventsPerSecond; - _lastAvgBatchSize = avgQueueSize; + MxChangeEventsPerSecond = eventsPerSecond; + AverageDispatchBatchSize = avgQueueSize; Log.Information( "DataChange dispatch: EventsPerSec={EventsPerSec:F1}, AvgBatchSize={AvgBatchSize:F1}, PendingItems={Pending}, TotalEvents={Total}", @@ -1872,4 +1895,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa #endregion } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs index e026cbe..1826aaf 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Text; using Opc.Ua; using Opc.Ua.Server; using Serilog; @@ -12,49 +11,30 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Custom OPC UA server that creates the LmxNodeManager, handles user authentication, - /// and exposes redundancy state through the standard server object. (OPC-001, OPC-012) + /// Custom OPC UA server that creates the LmxNodeManager, handles user authentication, + /// and exposes redundancy state through the standard server object. (OPC-001, OPC-012) /// public class LmxOpcUaServer : StandardServer { private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly string _galaxyName; - private readonly IMxAccessClient _mxAccessClient; - private readonly PerformanceMetrics _metrics; - private readonly HistorianDataSource? _historianDataSource; private readonly bool _alarmTrackingEnabled; + private readonly string? _applicationUri; private readonly AuthenticationConfiguration _authConfig; private readonly IUserAuthenticationProvider? _authProvider; + + private readonly string _galaxyName; + private readonly HistorianDataSource? _historianDataSource; + private readonly PerformanceMetrics _metrics; + private readonly IMxAccessClient _mxAccessClient; private readonly RedundancyConfiguration _redundancyConfig; - private readonly string? _applicationUri; - private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator(); + private readonly ServiceLevelCalculator _serviceLevelCalculator = new(); + private NodeId? _alarmAckRoleId; // Resolved custom role NodeIds (populated in CreateMasterNodeManager) private NodeId? _readOnlyRoleId; + private NodeId? _writeConfigureRoleId; private NodeId? _writeOperateRoleId; private NodeId? _writeTuneRoleId; - private NodeId? _writeConfigureRoleId; - private NodeId? _alarmAckRoleId; - - private LmxNodeManager? _nodeManager; - - /// - /// Gets the custom node manager that publishes the Galaxy-backed namespace. - /// - public LmxNodeManager? NodeManager => _nodeManager; - - /// - /// Gets the number of active OPC UA sessions currently connected to the server. - /// - public int ActiveSessionCount - { - get - { - try { return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0; } - catch { return 0; } - } - } public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, @@ -72,18 +52,42 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _applicationUri = applicationUri; } + /// + /// Gets the custom node manager that publishes the Galaxy-backed namespace. + /// + public LmxNodeManager? NodeManager { get; private set; } + + /// + /// Gets the number of active OPC UA sessions currently connected to the server. + /// + public int ActiveSessionCount + { + get + { + try + { + return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0; + } + catch + { + return 0; + } + } + } + /// - protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration) + protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, + ApplicationConfiguration configuration) { // Resolve custom role NodeIds from the roles namespace ResolveRoleNodeIds(server); var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; - _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, + NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, _writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId); - var nodeManagers = new List { _nodeManager }; + var nodeManagers = new List { NodeManager }; return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); } @@ -136,11 +140,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray(); serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false); - Log.Information("Set ServerUriArray to [{Uris}]", string.Join(", ", _redundancyConfig.ServerUris)); + Log.Information("Set ServerUriArray to [{Uris}]", + string.Join(", ", _redundancyConfig.ServerUris)); } else { - Log.Warning("ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type"); + Log.Warning( + "ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type"); } } @@ -151,23 +157,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } catch (Exception ex) { - Log.Warning(ex, "Failed to configure redundancy nodes — redundancy state may not be visible to clients"); + Log.Warning(ex, + "Failed to configure redundancy nodes — redundancy state may not be visible to clients"); } } /// - /// Updates the server's ServiceLevel based on current runtime health. - /// Called by the service layer when MXAccess or DB health changes. + /// Updates the server's ServiceLevel based on current runtime health. + /// Called by the service layer when MXAccess or DB health changes. /// public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) { var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected); try { - if (ServerInternal != null) - { - SetServiceLevelValue(ServerInternal, level); - } + if (ServerInternal != null) SetServiceLevelValue(ServerInternal, level); } catch (Exception ex) { @@ -206,7 +210,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (args.NewIdentity is AnonymousIdentityToken anonymousToken) { if (!_authConfig.AllowAnonymous) - throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled"); + throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, + "Anonymous access is disabled"); args.Identity = new RoleBasedIdentity( new UserIdentity(anonymousToken), @@ -232,26 +237,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var appRoles = roleProvider.GetUserRoles(userNameToken.UserName); foreach (var appRole in appRoles) - { switch (appRole) { case AppRoles.ReadOnly: if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly)); break; case AppRoles.WriteOperate: - if (_writeOperateRoleId != null) roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate)); + if (_writeOperateRoleId != null) + roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate)); break; case AppRoles.WriteTune: if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune)); break; case AppRoles.WriteConfigure: - if (_writeConfigureRoleId != null) roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure)); + if (_writeConfigureRoleId != null) + roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure)); break; case AppRoles.AlarmAck: if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck)); break; } - } Log.Information("User {Username} authenticated with roles [{Roles}]", userNameToken.UserName, string.Join(", ", appRoles)); @@ -279,9 +284,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa ProductUri = $"urn:{_galaxyName}:LmxOpcUa", SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0", BuildNumber = "1", - BuildDate = System.DateTime.UtcNow + BuildDate = DateTime.UtcNow }; return properties; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaQualityMapper.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaQualityMapper.cs index a9c139e..5814b79 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaQualityMapper.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaQualityMapper.cs @@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005) + /// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005) /// public static class OpcUaQualityMapper { /// - /// Converts bridge quality values into OPC UA status codes. + /// Converts bridge quality values into OPC UA status codes. /// /// The bridge quality value. /// The OPC UA status code to publish. @@ -19,7 +19,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Converts an OPC UA status code back into a bridge quality category. + /// Converts an OPC UA status code back into a bridge quality category. /// /// The OPC UA status code to interpret. /// The bridge quality category represented by the status code. @@ -30,4 +30,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa return Quality.Bad; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs index 9b5480c..7c435c6 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs @@ -1,8 +1,8 @@ using System; +using System.IO; using System.Threading.Tasks; using Opc.Ua; using Opc.Ua.Configuration; -using Opc.Ua.Server; using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; @@ -12,46 +12,25 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013) + /// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013) /// public class OpcUaServerHost : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); - - private readonly OpcUaConfiguration _config; - private readonly IMxAccessClient _mxAccessClient; - private readonly PerformanceMetrics _metrics; - private readonly HistorianDataSource? _historianDataSource; private readonly AuthenticationConfiguration _authConfig; private readonly IUserAuthenticationProvider? _authProvider; - private readonly SecurityProfileConfiguration _securityConfig; + + private readonly OpcUaConfiguration _config; + private readonly HistorianDataSource? _historianDataSource; + private readonly PerformanceMetrics _metrics; + private readonly IMxAccessClient _mxAccessClient; private readonly RedundancyConfiguration _redundancyConfig; + private readonly SecurityProfileConfiguration _securityConfig; private ApplicationInstance? _application; private LmxOpcUaServer? _server; /// - /// Gets the active node manager that holds the published Galaxy namespace. - /// - public LmxNodeManager? NodeManager => _server?.NodeManager; - - /// - /// Gets the number of currently connected OPC UA client sessions. - /// - public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0; - - /// - /// Gets a value indicating whether the OPC UA server has been started and not yet stopped. - /// - public bool IsRunning => _server != null; - - /// - /// Updates the OPC UA ServiceLevel based on current runtime health. - /// - public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) => - _server?.UpdateServiceLevel(mxAccessConnected, dbConnected); - - /// - /// Initializes a new host for the Galaxy-backed OPC UA server instance. + /// Initializes a new host for the Galaxy-backed OPC UA server instance. /// /// The endpoint and session settings for the OPC UA host. /// The runtime client used by the node manager for live reads, writes, and subscriptions. @@ -75,7 +54,39 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured endpoint. + /// Gets the active node manager that holds the published Galaxy namespace. + /// + public LmxNodeManager? NodeManager => _server?.NodeManager; + + /// + /// Gets the number of currently connected OPC UA client sessions. + /// + public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0; + + /// + /// Gets a value indicating whether the OPC UA server has been started and not yet stopped. + /// + public bool IsRunning => _server != null; + + /// + /// Stops the host and releases server resources. + /// + public void Dispose() + { + Stop(); + } + + /// + /// Updates the OPC UA ServiceLevel based on current runtime health. + /// + public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) + { + _server?.UpdateServiceLevel(mxAccessConnected, dbConnected); + } + + /// + /// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured + /// endpoint. /// public async Task StartAsync() { @@ -85,12 +96,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Resolve configured security profiles var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles); foreach (var sp in securityPolicies) - { Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode); - } // Build PKI paths - var pkiRoot = _securityConfig.PkiRootPath ?? System.IO.Path.Combine( + var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OPC Foundation", "pki"); var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost"; @@ -111,23 +120,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, - StorePath = System.IO.Path.Combine(pkiRoot, "own"), + StorePath = Path.Combine(pkiRoot, "own"), SubjectName = certSubject }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = System.IO.Path.Combine(pkiRoot, "issuer") + StorePath = Path.Combine(pkiRoot, "issuer") }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = System.IO.Path.Combine(pkiRoot, "trusted") + StorePath = Path.Combine(pkiRoot, "trusted") }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, - StorePath = System.IO.Path.Combine(pkiRoot, "rejected") + StorePath = Path.Combine(pkiRoot, "rejected") }, AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates, RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates, @@ -176,7 +185,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Check/create application certificate var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize; - bool certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize); + var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize); if (!certOk) { Log.Warning("Application certificate check failed, attempting to create..."); @@ -187,7 +196,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri); await _application.Start(_server); - Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})", + Log.Information( + "OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})", _config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri); } @@ -206,7 +216,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } /// - /// Stops the OPC UA application instance and releases its in-memory server objects. + /// Stops the OPC UA application instance and releases its in-memory server objects. /// public void Stop() { @@ -242,10 +252,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa return policies; } - - /// - /// Stops the host and releases server resources. - /// - public void Dispose() => Stop(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/RedundancyModeResolver.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/RedundancyModeResolver.cs index fb22d7e..9996c0c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/RedundancyModeResolver.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/RedundancyModeResolver.cs @@ -1,19 +1,18 @@ -using System; using Opc.Ua; using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Maps a configured redundancy mode string to the OPC UA enum. + /// Maps a configured redundancy mode string to the OPC UA enum. /// public static class RedundancyModeResolver { private static readonly ILogger Log = Serilog.Log.ForContext(typeof(RedundancyModeResolver)); /// - /// Resolves the configured mode string to a value. - /// Returns when redundancy is disabled or the mode is unrecognized. + /// Resolves the configured mode string to a value. + /// Returns when redundancy is disabled or the mode is unrecognized. /// /// The mode string from configuration (e.g., "Warm", "Hot"). /// Whether redundancy is enabled. @@ -31,11 +30,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa }; if (resolved == RedundancySupport.None) - { - Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot", mode); - } + Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot", + mode); return resolved; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs index c4a1133..b5bd67f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs @@ -2,20 +2,19 @@ using System; using System.Collections.Generic; using System.Linq; using Opc.Ua; -using Opc.Ua.Server; using Serilog; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Maps configured security profile names to OPC UA instances. + /// Maps configured security profile names to OPC UA instances. /// public static class SecurityProfileResolver { private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver)); private static readonly Dictionary KnownProfiles = - new Dictionary(StringComparer.OrdinalIgnoreCase) + new(StringComparer.OrdinalIgnoreCase) { ["None"] = new ServerSecurityPolicy { @@ -35,8 +34,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa }; /// - /// Resolves the configured profile names to entries. - /// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to None. + /// Gets the list of valid profile names for validation and documentation. + /// + public static IReadOnlyCollection ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly(); + + /// + /// Resolves the configured profile names to entries. + /// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to None. /// /// The profile names from configuration. /// A deduplicated list of server security policies. @@ -59,14 +63,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } if (KnownProfiles.TryGetValue(trimmed, out var policy)) - { resolved.Add(policy); - } else - { Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}", trimmed, string.Join(", ", KnownProfiles.Keys)); - } } if (resolved.Count == 0) @@ -77,10 +77,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa return resolved; } - - /// - /// Gets the list of valid profile names for validation and documentation. - /// - public static IReadOnlyCollection ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly(); } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/ServiceLevelCalculator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/ServiceLevelCalculator.cs index 30d81a2..037ccee 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/ServiceLevelCalculator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/ServiceLevelCalculator.cs @@ -3,12 +3,12 @@ using System; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs. + /// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs. /// public sealed class ServiceLevelCalculator { /// - /// Calculates the current ServiceLevel from a role-adjusted baseline and health state. + /// Calculates the current ServiceLevel from a role-adjusted baseline and health state. /// /// The role-adjusted baseline (e.g., 200 for primary, 150 for secondary). /// Whether the MXAccess runtime connection is healthy. @@ -19,7 +19,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (!mxAccessConnected && !dbConnected) return 0; - int level = baseLevel; + var level = baseLevel; if (!mxAccessConnected) level -= 100; @@ -30,4 +30,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa return (byte)Math.Max(0, Math.Min(level, 255)); } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index 00af714..ad42c2b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -1,10 +1,12 @@ using System; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository; +using ZB.MOM.WW.LmxOpcUa.Host.Historian; using ZB.MOM.WW.LmxOpcUa.Host.Metrics; using ZB.MOM.WW.LmxOpcUa.Host.MxAccess; using ZB.MOM.WW.LmxOpcUa.Host.OpcUa; @@ -13,41 +15,36 @@ using ZB.MOM.WW.LmxOpcUa.Host.Status; namespace ZB.MOM.WW.LmxOpcUa.Host { /// - /// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006) + /// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006) /// internal sealed class OpcUaService { private static readonly ILogger Log = Serilog.Log.ForContext(); + private readonly IUserAuthenticationProvider? _authProviderOverride; private readonly AppConfiguration _config; - private readonly IMxProxy? _mxProxy; private readonly IGalaxyRepository? _galaxyRepository; - private readonly IMxAccessClient? _mxAccessClientOverride; - private readonly bool _hasMxAccessClientOverride; - private readonly IUserAuthenticationProvider? _authProviderOverride; private readonly bool _hasAuthProviderOverride; + private readonly bool _hasMxAccessClientOverride; + private readonly IMxAccessClient? _mxAccessClientOverride; + private readonly IMxProxy? _mxProxy; private CancellationTokenSource? _cts; - private PerformanceMetrics? _metrics; - private StaComThread? _staThread; + private HealthCheckService? _healthCheck; private MxAccessClient? _mxAccessClient; private IMxAccessClient? _mxAccessClientForWiring; - private ChangeDetectionService? _changeDetection; - private OpcUaServerHost? _serverHost; - private LmxNodeManager? _nodeManager; - private HealthCheckService? _healthCheck; - private StatusReportService? _statusReport; - private StatusWebServer? _statusWebServer; - private GalaxyRepositoryStats? _galaxyStats; + private StaComThread? _staThread; /// - /// Production constructor. Loads configuration from appsettings.json. + /// Production constructor. Loads configuration from appsettings.json. /// public OpcUaService() { var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: false) - .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true) + .AddJsonFile("appsettings.json", false) + .AddJsonFile( + $"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", + true) .AddEnvironmentVariables() .Build(); @@ -68,13 +65,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Test constructor. Accepts injected dependencies. + /// Test constructor. Accepts injected dependencies. /// - /// The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard behavior during the test run. + /// + /// The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard + /// behavior during the test run. + /// /// The MXAccess proxy substitute used when a test wants to exercise COM-style wiring. - /// The repository substitute that supplies Galaxy hierarchy and deploy metadata for address-space builds. - /// An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop. - /// A value indicating whether the override client should be used instead of creating a client from . + /// + /// The repository substitute that supplies Galaxy hierarchy and deploy metadata for + /// address-space builds. + /// + /// + /// An optional direct MXAccess client substitute that bypasses STA thread setup and + /// COM interop. + /// + /// + /// A value indicating whether the override client should be used instead of + /// creating a client from . + /// internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository, IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false, IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false) @@ -88,8 +97,50 @@ namespace ZB.MOM.WW.LmxOpcUa.Host _hasAuthProviderOverride = hasAuthProviderOverride; } + // Accessors for testing /// - /// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA address space, and optionally hosting the status dashboard. + /// Gets the MXAccess client instance currently wired into the service for test inspection. + /// + internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring; + + /// + /// Gets the metrics collector that tracks bridge operation timings during the service lifetime. + /// + internal PerformanceMetrics? Metrics { get; private set; } + + /// + /// Gets the OPC UA server host that owns the runtime endpoint. + /// + internal OpcUaServerHost? ServerHost { get; private set; } + + /// + /// Gets the node manager instance that holds the current Galaxy-derived address space. + /// + internal LmxNodeManager? NodeManagerInstance { get; private set; } + + /// + /// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild. + /// + internal ChangeDetectionService? ChangeDetectionInstance { get; private set; } + + /// + /// Gets the hosted status web server when the dashboard is enabled. + /// + internal StatusWebServer? StatusWeb { get; private set; } + + /// + /// Gets the dashboard report generator used to assemble operator-facing status snapshots. + /// + internal StatusReportService? StatusReportInstance { get; private set; } + + /// + /// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds. + /// + internal GalaxyRepositoryStats? GalaxyStatsInstance { get; private set; } + + /// + /// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA + /// address space, and optionally hosting the status dashboard. /// public void Start() { @@ -109,7 +160,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host // Step 4: Create PerformanceMetrics _cts = new CancellationTokenSource(); - _metrics = new PerformanceMetrics(); + Metrics = new PerformanceMetrics(); // Step 5: Create MxAccessClient → Connect if (_hasMxAccessClientOverride) @@ -117,9 +168,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host // Test path: use injected IMxAccessClient directly (skips STA thread + COM) _mxAccessClientForWiring = _mxAccessClientOverride; if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected) - { _mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult(); - } } else if (_mxProxy != null) { @@ -127,14 +176,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host { _staThread = new StaComThread(); _staThread.Start(); - _mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, _metrics); + _mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, Metrics); try { _mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult(); } catch (Exception ex) { - Log.Warning(ex, "MxAccess connection failed at startup - monitor will continue retrying in the background"); + Log.Warning(ex, + "MxAccess connection failed at startup - monitor will continue retrying in the background"); } // Step 6: Start monitor loop even if initial connect failed @@ -151,97 +201,101 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } // Step 7: Create GalaxyRepositoryService → TestConnection - _galaxyStats = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName }; + GalaxyStatsInstance = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName }; if (_galaxyRepository != null) { var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult(); - _galaxyStats.DbConnected = dbOk; + GalaxyStatsInstance.DbConnected = dbOk; if (!dbOk) Log.Warning("Galaxy repository database connection failed — continuing without initial data"); } // Step 8: Create OPC UA server host + node manager - var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring ?? new NullMxAccessClient(); + var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ?? + _mxAccessClientForWiring ?? new NullMxAccessClient(); var historianDataSource = _config.Historian.Enabled - ? new Historian.HistorianDataSource(_config.Historian) + ? new HistorianDataSource(_config.Historian) : null; - Domain.IUserAuthenticationProvider? authProvider = null; + IUserAuthenticationProvider? authProvider = null; if (_hasAuthProviderOverride) { authProvider = _authProviderOverride; } else if (_config.Authentication.Ldap.Enabled) { - authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap); + authProvider = new LdapAuthenticationProvider(_config.Authentication.Ldap); Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})", - _config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, _config.Authentication.Ldap.BaseDN); + _config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, + _config.Authentication.Ldap.BaseDN); } - _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource, + + ServerHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, Metrics, historianDataSource, _config.Authentication, authProvider, _config.Security, _config.Redundancy); // Step 9-10: Query hierarchy, start server, build address space DateTime? initialDeployTime = null; - if (_galaxyRepository != null && _galaxyStats.DbConnected) + if (_galaxyRepository != null && GalaxyStatsInstance.DbConnected) { try { - initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter().GetResult(); + initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter() + .GetResult(); var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult(); var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult(); - _galaxyStats.ObjectCount = hierarchy.Count; - _galaxyStats.AttributeCount = attributes.Count; + GalaxyStatsInstance.ObjectCount = hierarchy.Count; + GalaxyStatsInstance.AttributeCount = attributes.Count; - _serverHost.StartAsync().GetAwaiter().GetResult(); - _nodeManager = _serverHost.NodeManager; + ServerHost.StartAsync().GetAwaiter().GetResult(); + NodeManagerInstance = ServerHost.NodeManager; - if (_nodeManager != null) + if (NodeManagerInstance != null) { - _nodeManager.BuildAddressSpace(hierarchy, attributes); - _galaxyStats.LastRebuildTime = DateTime.UtcNow; + NodeManagerInstance.BuildAddressSpace(hierarchy, attributes); + GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow; } } catch (Exception ex) { Log.Warning(ex, "Failed to build initial address space"); - if (!_serverHost.IsRunning) + if (!ServerHost.IsRunning) { - _serverHost.StartAsync().GetAwaiter().GetResult(); - _nodeManager = _serverHost.NodeManager; + ServerHost.StartAsync().GetAwaiter().GetResult(); + NodeManagerInstance = ServerHost.NodeManager; } } } else { - _serverHost.StartAsync().GetAwaiter().GetResult(); - _nodeManager = _serverHost.NodeManager; + ServerHost.StartAsync().GetAwaiter().GetResult(); + NodeManagerInstance = ServerHost.NodeManager; } // Step 11-12: Change detection wired to rebuild if (_galaxyRepository != null) { - _changeDetection = new ChangeDetectionService(_galaxyRepository, _config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime); - _changeDetection.OnGalaxyChanged += OnGalaxyChanged; - _changeDetection.Start(); + ChangeDetectionInstance = new ChangeDetectionService(_galaxyRepository, + _config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime); + ChangeDetectionInstance.OnGalaxyChanged += OnGalaxyChanged; + ChangeDetectionInstance.Start(); } // Step 13: Dashboard _healthCheck = new HealthCheckService(); - _statusReport = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds); - _statusReport.SetComponents(effectiveMxClient, _metrics, _galaxyStats, _serverHost, _nodeManager, + StatusReportInstance = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds); + StatusReportInstance.SetComponents(effectiveMxClient, Metrics, GalaxyStatsInstance, ServerHost, + NodeManagerInstance, _config.Redundancy, _config.OpcUa.ApplicationUri); if (_config.Dashboard.Enabled) { - _statusWebServer = new StatusWebServer(_statusReport, _config.Dashboard.Port); - _statusWebServer.Start(); + StatusWeb = new StatusWebServer(StatusReportInstance, _config.Dashboard.Port); + StatusWeb.Start(); } // Wire ServiceLevel updates from MXAccess health changes if (_config.Redundancy.Enabled) - { effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel; - } // Step 14 Log.Information("LmxOpcUa service started successfully"); @@ -254,7 +308,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in shutdown order. + /// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in + /// shutdown order. /// public void Stop() { @@ -263,8 +318,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host try { _cts?.Cancel(); - _changeDetection?.Stop(); - _serverHost?.Stop(); + ChangeDetectionInstance?.Stop(); + ServerHost?.Stop(); if (_mxAccessClient != null) { @@ -272,11 +327,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host _mxAccessClient.DisconnectAsync().GetAwaiter().GetResult(); _mxAccessClient.Dispose(); } + _staThread?.Dispose(); - _statusWebServer?.Dispose(); - _metrics?.Dispose(); - _changeDetection?.Dispose(); + StatusWeb?.Dispose(); + Metrics?.Dispose(); + ChangeDetectionInstance?.Dispose(); _cts?.Dispose(); AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; @@ -294,19 +350,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host Log.Information("Galaxy change detected — rebuilding address space"); try { - if (_galaxyRepository == null || _nodeManager == null) return; + if (_galaxyRepository == null || NodeManagerInstance == null) return; var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult(); var attributes = _galaxyRepository.GetAttributesAsync().GetAwaiter().GetResult(); - _nodeManager.RebuildAddressSpace(hierarchy, attributes); + NodeManagerInstance.RebuildAddressSpace(hierarchy, attributes); - if (_galaxyStats != null) + if (GalaxyStatsInstance != null) { - _galaxyStats.ObjectCount = hierarchy.Count; - _galaxyStats.AttributeCount = attributes.Count; - _galaxyStats.LastRebuildTime = DateTime.UtcNow; - _galaxyStats.LastDeployTime = _changeDetection?.LastKnownDeployTime; + GalaxyStatsInstance.ObjectCount = hierarchy.Count; + GalaxyStatsInstance.AttributeCount = attributes.Count; + GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow; + GalaxyStatsInstance.LastDeployTime = ChangeDetectionInstance?.LastKnownDeployTime; } } catch (Exception ex) @@ -315,140 +371,123 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } } - private void OnMxAccessStateChangedForServiceLevel(object? sender, Domain.ConnectionStateChangedEventArgs e) + private void OnMxAccessStateChangedForServiceLevel(object? sender, ConnectionStateChangedEventArgs e) { - var mxConnected = e.CurrentState == Domain.ConnectionState.Connected; - var dbConnected = _galaxyStats?.DbConnected ?? false; - _serverHost?.UpdateServiceLevel(mxConnected, dbConnected); + var mxConnected = e.CurrentState == ConnectionState.Connected; + var dbConnected = GalaxyStatsInstance?.DbConnected ?? false; + ServerHost?.UpdateServiceLevel(mxConnected, dbConnected); Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected); } private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { - Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating); + Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", + e.IsTerminating); } /// - /// Triggers an address space rebuild from the current Galaxy repository data. For testing. + /// Triggers an address space rebuild from the current Galaxy repository data. For testing. /// - internal void TriggerRebuild() => OnGalaxyChanged(); - - // Accessors for testing - /// - /// Gets the MXAccess client instance currently wired into the service for test inspection. - /// - internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring; - - /// - /// Gets the metrics collector that tracks bridge operation timings during the service lifetime. - /// - internal PerformanceMetrics? Metrics => _metrics; - - /// - /// Gets the OPC UA server host that owns the runtime endpoint. - /// - internal OpcUaServerHost? ServerHost => _serverHost; - - /// - /// Gets the node manager instance that holds the current Galaxy-derived address space. - /// - internal LmxNodeManager? NodeManagerInstance => _nodeManager; - - /// - /// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild. - /// - internal ChangeDetectionService? ChangeDetectionInstance => _changeDetection; - - /// - /// Gets the hosted status web server when the dashboard is enabled. - /// - internal StatusWebServer? StatusWeb => _statusWebServer; - - /// - /// Gets the dashboard report generator used to assemble operator-facing status snapshots. - /// - internal StatusReportService? StatusReportInstance => _statusReport; - - /// - /// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds. - /// - internal GalaxyRepositoryStats? GalaxyStatsInstance => _galaxyStats; + internal void TriggerRebuild() + { + OnGalaxyChanged(); + } } /// - /// Null implementation of IMxAccessClient for when MXAccess is not available. + /// Null implementation of IMxAccessClient for when MXAccess is not available. /// internal sealed class NullMxAccessClient : IMxAccessClient { /// - /// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity. + /// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity. /// public ConnectionState State => ConnectionState.Disconnected; /// - /// Gets the active subscription count, which is always zero for the null runtime client. + /// Gets the active subscription count, which is always zero for the null runtime client. /// public int ActiveSubscriptionCount => 0; /// - /// Gets the reconnect count, which is always zero because the null client never establishes a session. + /// Gets the reconnect count, which is always zero because the null client never establishes a session. /// public int ReconnectCount => 0; /// - /// Occurs when the runtime connection state changes. The null client never raises this event. + /// Occurs when the runtime connection state changes. The null client never raises this event. /// public event EventHandler? ConnectionStateChanged; /// - /// Occurs when a subscribed tag value changes. The null client never raises this event. + /// Occurs when a subscribed tag value changes. The null client never raises this event. /// public event Action? OnTagValueChanged; /// - /// Completes immediately because no live runtime connection is available or required. + /// Completes immediately because no live runtime connection is available or required. /// /// A cancellation token that is ignored by the null implementation. - public System.Threading.Tasks.Task ConnectAsync(CancellationToken ct = default) => System.Threading.Tasks.Task.CompletedTask; + public Task ConnectAsync(CancellationToken ct = default) + { + return Task.CompletedTask; + } /// - /// Completes immediately because there is no live runtime session to close. + /// Completes immediately because there is no live runtime session to close. /// - public System.Threading.Tasks.Task DisconnectAsync() => System.Threading.Tasks.Task.CompletedTask; + public Task DisconnectAsync() + { + return Task.CompletedTask; + } /// - /// Completes immediately because the null client does not subscribe to live Galaxy attributes. + /// Completes immediately because the null client does not subscribe to live Galaxy attributes. /// /// The tag reference that would have been subscribed. /// The callback that would have received runtime value changes. - public System.Threading.Tasks.Task SubscribeAsync(string fullTagReference, Action callback) => System.Threading.Tasks.Task.CompletedTask; + public Task SubscribeAsync(string fullTagReference, Action callback) + { + return Task.CompletedTask; + } /// - /// Completes immediately because the null client does not maintain runtime subscriptions. + /// Completes immediately because the null client does not maintain runtime subscriptions. /// /// The tag reference that would have been unsubscribed. - public System.Threading.Tasks.Task UnsubscribeAsync(string fullTagReference) => System.Threading.Tasks.Task.CompletedTask; + public Task UnsubscribeAsync(string fullTagReference) + { + return Task.CompletedTask; + } /// - /// Returns a bad-quality value because no live runtime source exists. + /// Returns a bad-quality value because no live runtime source exists. /// /// The tag reference that would have been read from the runtime. /// A cancellation token that is ignored by the null implementation. /// A bad-quality VTQ indicating that runtime data is unavailable. - public System.Threading.Tasks.Task ReadAsync(string fullTagReference, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(Vtq.Bad()); + public Task ReadAsync(string fullTagReference, CancellationToken ct = default) + { + return Task.FromResult(Vtq.Bad()); + } /// - /// Rejects writes because there is no live runtime endpoint behind the null client. + /// Rejects writes because there is no live runtime endpoint behind the null client. /// /// The tag reference that would have been written. /// The value that would have been sent to the runtime. /// A cancellation token that is ignored by the null implementation. - /// A completed task returning . - public System.Threading.Tasks.Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(false); + /// A completed task returning . + public Task WriteAsync(string fullTagReference, object value, CancellationToken ct = default) + { + return Task.FromResult(false); + } /// - /// Releases the null client. No unmanaged runtime resources exist. + /// Releases the null client. No unmanaged runtime resources exist. /// - public void Dispose() { } + public void Dispose() + { + } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs index 6b9459b..79f4f1d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs @@ -1,27 +1,30 @@ +using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host { /// - /// Fluent builder for constructing OpcUaService with dependency overrides. - /// Used by integration tests to substitute fakes for COM/DB components. + /// Fluent builder for constructing OpcUaService with dependency overrides. + /// Used by integration tests to substitute fakes for COM/DB components. /// internal class OpcUaServiceBuilder { - private AppConfiguration _config = new AppConfiguration(); - private IMxProxy? _mxProxy; - private IGalaxyRepository? _galaxyRepository; - private IMxAccessClient? _mxAccessClient; - private bool _mxProxySet; - private bool _galaxyRepositorySet; - private bool _mxAccessClientSet; private IUserAuthenticationProvider? _authProvider; private bool _authProviderSet; + private AppConfiguration _config = new(); + private IGalaxyRepository? _galaxyRepository; + private bool _galaxyRepositorySet; + private IMxAccessClient? _mxAccessClient; + private bool _mxAccessClientSet; + private IMxProxy? _mxProxy; + private bool _mxProxySet; /// - /// Replaces the default service configuration used by the test host. + /// Replaces the default service configuration used by the test host. /// /// The full configuration snapshot to inject into the service under test. /// The current builder so additional overrides can be chained. @@ -32,7 +35,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Sets the OPC UA port used by the test host so multiple integration runs can coexist. + /// Sets the OPC UA port used by the test host so multiple integration runs can coexist. /// /// The TCP port to expose for the test server. /// The current builder so additional overrides can be chained. @@ -43,7 +46,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Sets the Galaxy name represented by the test address space. + /// Sets the Galaxy name represented by the test address space. /// /// The Galaxy name to expose through OPC UA and diagnostics. /// The current builder so additional overrides can be chained. @@ -54,7 +57,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path. + /// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path. /// /// The proxy fake or stub to supply to the service. /// The current builder so additional overrides can be chained. @@ -66,7 +69,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata. + /// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata. /// /// The repository fake or stub to supply to the service. /// The current builder so additional overrides can be chained. @@ -78,8 +81,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Override the MxAccessClient directly, skipping STA thread and COM interop entirely. - /// When set, the service will use this client instead of creating one from IMxProxy. + /// Override the MxAccessClient directly, skipping STA thread and COM interop entirely. + /// When set, the service will use this client instead of creating one from IMxProxy. /// /// The direct MXAccess client substitute to inject into the service. /// The current builder so additional overrides can be chained. @@ -91,7 +94,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests. + /// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests. /// /// The object hierarchy to expose through the test OPC UA namespace. /// The attribute rows to attach to the hierarchy. @@ -115,11 +118,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener. + /// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener. /// /// The current builder so additional overrides can be chained. /// - /// Injects a custom authentication provider for tests that need deterministic role resolution. + /// Injects a custom authentication provider for tests that need deterministic role resolution. /// public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider) { @@ -129,7 +132,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Sets the authentication configuration for the test host. + /// Sets the authentication configuration for the test host. /// public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig) { @@ -144,7 +147,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Sets the redundancy configuration for the test host. + /// Sets the redundancy configuration for the test host. /// /// The redundancy configuration to inject. /// The current builder so additional overrides can be chained. @@ -155,7 +158,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Sets the application URI for the test host, distinct from the namespace URI. + /// Sets the application URI for the test host, distinct from the namespace URI. /// /// The unique application URI for this server instance. /// The current builder so additional overrides can be chained. @@ -166,7 +169,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Sets the security profile configuration for the test host. + /// Sets the security profile configuration for the test host. /// /// The security profile configuration to inject. /// The current builder so additional overrides can be chained. @@ -177,7 +180,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations. + /// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations. /// /// The current builder so additional overrides can be chained. public OpcUaServiceBuilder DisableChangeDetection() @@ -187,7 +190,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Creates an using the accumulated test doubles and configuration overrides. + /// Creates an using the accumulated test doubles and configuration overrides. /// /// A service instance ready for integration-style testing. public OpcUaService Build() @@ -203,56 +206,64 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } /// - /// Internal fake repository used by WithHierarchy for convenience. + /// Internal fake repository used by WithHierarchy for convenience. /// private class FakeBuilderGalaxyRepository : IGalaxyRepository { /// - /// Occurs when the fake repository wants to simulate a Galaxy deploy change. - /// - public event System.Action? OnGalaxyChanged; - - /// - /// Gets or sets the hierarchy rows that the fake repository returns to the service. + /// Gets or sets the hierarchy rows that the fake repository returns to the service. /// public List Hierarchy { get; set; } = new(); /// - /// Gets or sets the attribute rows that the fake repository returns to the service. + /// Gets or sets the attribute rows that the fake repository returns to the service. /// public List Attributes { get; set; } = new(); /// - /// Returns the seeded hierarchy rows for address-space construction. + /// Occurs when the fake repository wants to simulate a Galaxy deploy change. + /// + public event Action? OnGalaxyChanged; + + /// + /// Returns the seeded hierarchy rows for address-space construction. /// /// A cancellation token that is ignored by the in-memory fake. /// The configured hierarchy rows. - public System.Threading.Tasks.Task> GetHierarchyAsync(System.Threading.CancellationToken ct = default) - => System.Threading.Tasks.Task.FromResult(Hierarchy); + public Task> GetHierarchyAsync(CancellationToken ct = default) + { + return Task.FromResult(Hierarchy); + } /// - /// Returns the seeded attribute rows for address-space construction. + /// Returns the seeded attribute rows for address-space construction. /// /// A cancellation token that is ignored by the in-memory fake. /// The configured attribute rows. - public System.Threading.Tasks.Task> GetAttributesAsync(System.Threading.CancellationToken ct = default) - => System.Threading.Tasks.Task.FromResult(Attributes); + public Task> GetAttributesAsync(CancellationToken ct = default) + { + return Task.FromResult(Attributes); + } /// - /// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against. + /// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against. /// /// A cancellation token that is ignored by the in-memory fake. /// The current UTC time. - public System.Threading.Tasks.Task GetLastDeployTimeAsync(System.Threading.CancellationToken ct = default) - => System.Threading.Tasks.Task.FromResult(System.DateTime.UtcNow); + public Task GetLastDeployTimeAsync(CancellationToken ct = default) + { + return Task.FromResult(DateTime.UtcNow); + } /// - /// Reports a healthy repository connection for builder-based test setups. + /// Reports a healthy repository connection for builder-based test setups. /// /// A cancellation token that is ignored by the in-memory fake. - /// A completed task returning . - public System.Threading.Tasks.Task TestConnectionAsync(System.Threading.CancellationToken ct = default) - => System.Threading.Tasks.Task.FromResult(true); + /// A completed task returning . + public Task TestConnectionAsync(CancellationToken ct = default) + { + return Task.FromResult(true); + } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Program.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Program.cs index 4aa9367..5f5ee34 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Program.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Program.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host { internal static class Program { - static int Main(string[] args) + private static int Main(string[] args) { // Set working directory to exe location so relative log paths resolve correctly // (Windows services default to System32) @@ -16,7 +16,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host .MinimumLevel.Information() .WriteTo.Console() .WriteTo.File( - path: "logs/lmxopcua-.log", + "logs/lmxopcua-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 31) .CreateLogger(); @@ -54,4 +54,4 @@ namespace ZB.MOM.WW.LmxOpcUa.Host } } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/HealthCheckService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/HealthCheckService.cs index cd221a7..74e5525 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/HealthCheckService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/HealthCheckService.cs @@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.Status { /// - /// Determines health status based on connection state and operation success rates. (DASH-003) + /// Determines health status based on connection state and operation success rates. (DASH-003) /// public class HealthCheckService { /// - /// Evaluates bridge health from runtime connectivity and recorded performance metrics. + /// Evaluates bridge health from runtime connectivity and recorded performance metrics. /// /// The current MXAccess connection state. /// The recorded performance metrics, if available. @@ -18,31 +18,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status { // Rule 1: Not connected → Unhealthy if (connectionState != ConnectionState.Connected) - { return new HealthInfo { Status = "Unhealthy", Message = $"MXAccess not connected (state: {connectionState})", Color = "red" }; - } // Rule 2: Success rate < 50% with > 100 ops → Degraded if (metrics != null) { var stats = metrics.GetStatistics(); foreach (var kvp in stats) - { if (kvp.Value.TotalCount > 100 && kvp.Value.SuccessRate < 0.5) - { return new HealthInfo { Status = "Degraded", - Message = $"{kvp.Key} success rate is {kvp.Value.SuccessRate:P0} ({kvp.Value.TotalCount} ops)", + Message = + $"{kvp.Key} success rate is {kvp.Value.SuccessRate:P0} ({kvp.Value.TotalCount} ops)", Color = "yellow" }; - } - } } // Rule 3: All good @@ -55,15 +50,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Determines whether the bridge should currently be treated as healthy. + /// Determines whether the bridge should currently be treated as healthy. /// /// The current MXAccess connection state. /// The recorded performance metrics, if available. - /// when the bridge is not unhealthy; otherwise, . + /// when the bridge is not unhealthy; otherwise, . public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics) { var health = CheckHealth(connectionState, metrics); return health.Status != "Unhealthy"; } } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs index ee82c15..ad57111 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs @@ -5,282 +5,282 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.Status { /// - /// DTO containing all dashboard data. (DASH-001 through DASH-009) + /// DTO containing all dashboard data. (DASH-001 through DASH-009) /// public class StatusData { /// - /// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard. + /// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard. /// public ConnectionInfo Connection { get; set; } = new(); /// - /// Gets or sets the overall health state communicated to operators. + /// Gets or sets the overall health state communicated to operators. /// public HealthInfo Health { get; set; } = new(); /// - /// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining. + /// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining. /// public SubscriptionInfo Subscriptions { get; set; } = new(); /// - /// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts. + /// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts. /// public GalaxyInfo Galaxy { get; set; } = new(); /// - /// Gets or sets MXAccess data change dispatch queue metrics. + /// Gets or sets MXAccess data change dispatch queue metrics. /// public DataChangeInfo DataChange { get; set; } = new(); /// - /// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency. + /// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency. /// public Dictionary Operations { get; set; } = new(); /// - /// Gets or sets the redundancy state when redundancy is enabled. + /// Gets or sets the redundancy state when redundancy is enabled. /// public RedundancyInfo? Redundancy { get; set; } /// - /// Gets or sets footer details such as the snapshot timestamp and service version. + /// Gets or sets footer details such as the snapshot timestamp and service version. /// public FooterInfo Footer { get; set; } = new(); } /// - /// Dashboard model for current runtime connection details. + /// Dashboard model for current runtime connection details. /// public class ConnectionInfo { /// - /// Gets or sets the current MXAccess connection state shown to operators. + /// Gets or sets the current MXAccess connection state shown to operators. /// public string State { get; set; } = "Disconnected"; /// - /// Gets or sets how many reconnect attempts have occurred since the service started. + /// Gets or sets how many reconnect attempts have occurred since the service started. /// public int ReconnectCount { get; set; } /// - /// Gets or sets the number of active OPC UA sessions connected to the bridge. + /// Gets or sets the number of active OPC UA sessions connected to the bridge. /// public int ActiveSessions { get; set; } } /// - /// Dashboard model for the overall health banner. + /// Dashboard model for the overall health banner. /// public class HealthInfo { /// - /// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy. + /// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy. /// public string Status { get; set; } = "Unknown"; /// - /// Gets or sets the operator-facing explanation for the current health state. + /// Gets or sets the operator-facing explanation for the current health state. /// public string Message { get; set; } = ""; /// - /// Gets or sets the color token used by the dashboard UI to render the health banner. + /// Gets or sets the color token used by the dashboard UI to render the health banner. /// public string Color { get; set; } = "gray"; } /// - /// Dashboard model for subscription load. + /// Dashboard model for subscription load. /// public class SubscriptionInfo { /// - /// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA. + /// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA. /// public int ActiveCount { get; set; } } /// - /// Dashboard model for Galaxy metadata and rebuild status. + /// Dashboard model for Galaxy metadata and rebuild status. /// public class GalaxyInfo { /// - /// Gets or sets the Galaxy name currently being bridged into OPC UA. + /// Gets or sets the Galaxy name currently being bridged into OPC UA. /// public string GalaxyName { get; set; } = ""; /// - /// Gets or sets a value indicating whether the repository database is currently reachable. + /// Gets or sets a value indicating whether the repository database is currently reachable. /// public bool DbConnected { get; set; } /// - /// Gets or sets the most recent deploy timestamp observed in the Galaxy repository. + /// Gets or sets the most recent deploy timestamp observed in the Galaxy repository. /// public DateTime? LastDeployTime { get; set; } /// - /// Gets or sets the number of Galaxy objects currently represented in the address space. + /// Gets or sets the number of Galaxy objects currently represented in the address space. /// public int ObjectCount { get; set; } /// - /// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables. + /// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables. /// public int AttributeCount { get; set; } /// - /// Gets or sets the UTC timestamp of the last completed address-space rebuild. + /// Gets or sets the UTC timestamp of the last completed address-space rebuild. /// public DateTime? LastRebuildTime { get; set; } } /// - /// Dashboard model for MXAccess data change dispatch metrics. + /// Dashboard model for MXAccess data change dispatch metrics. /// public class DataChangeInfo { /// - /// Gets or sets the rate of MXAccess data change events received per second. + /// Gets or sets the rate of MXAccess data change events received per second. /// public double EventsPerSecond { get; set; } /// - /// Gets or sets the average number of items processed per dispatch cycle. + /// Gets or sets the average number of items processed per dispatch cycle. /// public double AvgBatchSize { get; set; } /// - /// Gets or sets the number of items currently waiting in the dispatch queue. + /// Gets or sets the number of items currently waiting in the dispatch queue. /// public int PendingItems { get; set; } /// - /// Gets or sets the total MXAccess data change events received since startup. + /// Gets or sets the total MXAccess data change events received since startup. /// public long TotalEvents { get; set; } } /// - /// Dashboard model for redundancy state. Only populated when redundancy is enabled. + /// Dashboard model for redundancy state. Only populated when redundancy is enabled. /// public class RedundancyInfo { /// - /// Gets or sets whether redundancy is enabled. + /// Gets or sets whether redundancy is enabled. /// public bool Enabled { get; set; } /// - /// Gets or sets the redundancy mode (e.g., "Warm", "Hot"). + /// Gets or sets the redundancy mode (e.g., "Warm", "Hot"). /// public string Mode { get; set; } = ""; /// - /// Gets or sets this instance's role ("Primary" or "Secondary"). + /// Gets or sets this instance's role ("Primary" or "Secondary"). /// public string Role { get; set; } = ""; /// - /// Gets or sets the current ServiceLevel byte. + /// Gets or sets the current ServiceLevel byte. /// public byte ServiceLevel { get; set; } /// - /// Gets or sets this instance's ApplicationUri. + /// Gets or sets this instance's ApplicationUri. /// public string ApplicationUri { get; set; } = ""; /// - /// Gets or sets the list of all server URIs in the redundant set. + /// Gets or sets the list of all server URIs in the redundant set. /// public List ServerUris { get; set; } = new(); } /// - /// DTO for the /api/health endpoint. Includes component-level health, ServiceLevel, and redundancy state. + /// DTO for the /api/health endpoint. Includes component-level health, ServiceLevel, and redundancy state. /// public class HealthEndpointData { /// - /// Gets or sets the overall health status: Healthy, Degraded, or Unhealthy. + /// Gets or sets the overall health status: Healthy, Degraded, or Unhealthy. /// public string Status { get; set; } = "Unknown"; /// - /// Gets or sets the computed OPC UA ServiceLevel byte (0-255). Only meaningful when redundancy is enabled. + /// Gets or sets the computed OPC UA ServiceLevel byte (0-255). Only meaningful when redundancy is enabled. /// public byte ServiceLevel { get; set; } /// - /// Gets or sets whether redundancy is enabled. + /// Gets or sets whether redundancy is enabled. /// public bool RedundancyEnabled { get; set; } /// - /// Gets or sets this instance's redundancy role when enabled (Primary/Secondary), or null when disabled. + /// Gets or sets this instance's redundancy role when enabled (Primary/Secondary), or null when disabled. /// public string? RedundancyRole { get; set; } /// - /// Gets or sets the redundancy mode when enabled (Warm/Hot), or null when disabled. + /// Gets or sets the redundancy mode when enabled (Warm/Hot), or null when disabled. /// public string? RedundancyMode { get; set; } /// - /// Gets or sets the per-component health breakdown. + /// Gets or sets the per-component health breakdown. /// public ComponentHealth Components { get; set; } = new(); /// - /// Gets or sets the server uptime since the health endpoint was initialized. + /// Gets or sets the server uptime since the health endpoint was initialized. /// public string Uptime { get; set; } = ""; /// - /// Gets or sets the UTC timestamp of this health snapshot. + /// Gets or sets the UTC timestamp of this health snapshot. /// public DateTime Timestamp { get; set; } = DateTime.UtcNow; } /// - /// Per-component health breakdown for the health endpoint. + /// Per-component health breakdown for the health endpoint. /// public class ComponentHealth { /// - /// Gets or sets MXAccess runtime connectivity status. + /// Gets or sets MXAccess runtime connectivity status. /// public string MxAccess { get; set; } = "Disconnected"; /// - /// Gets or sets Galaxy repository database connectivity status. + /// Gets or sets Galaxy repository database connectivity status. /// public string Database { get; set; } = "Disconnected"; /// - /// Gets or sets OPC UA server status. + /// Gets or sets OPC UA server status. /// public string OpcUaServer { get; set; } = "Stopped"; } /// - /// Dashboard model for the status page footer. + /// Dashboard model for the status page footer. /// public class FooterInfo { /// - /// Gets or sets the UTC time when the status snapshot was generated. + /// Gets or sets the UTC time when the status snapshot was generated. /// public DateTime Timestamp { get; set; } = DateTime.UtcNow; /// - /// Gets or sets the service version displayed to operators for support and traceability. + /// Gets or sets the service version displayed to operators for support and traceability. /// public string Version { get; set; } = ""; } -} +} \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs index b82a42b..c1c66b6 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text; using System.Text.Json; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; @@ -10,24 +11,25 @@ using ZB.MOM.WW.LmxOpcUa.Host.OpcUa; namespace ZB.MOM.WW.LmxOpcUa.Host.Status { /// - /// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009) + /// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009) /// public class StatusReportService { private readonly HealthCheckService _healthCheck; private readonly int _refreshIntervalSeconds; private readonly DateTime _startTime = DateTime.UtcNow; + private string? _applicationUri; + private GalaxyRepositoryStats? _galaxyStats; + private PerformanceMetrics? _metrics; private IMxAccessClient? _mxAccessClient; - private PerformanceMetrics? _metrics; - private GalaxyRepositoryStats? _galaxyStats; - private OpcUaServerHost? _serverHost; private LmxNodeManager? _nodeManager; private RedundancyConfiguration? _redundancyConfig; - private string? _applicationUri; + private OpcUaServerHost? _serverHost; /// - /// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh interval. + /// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh + /// interval. /// /// The health-check component used to derive the overall dashboard health status. /// The HTML auto-refresh interval, in seconds, for the dashboard page. @@ -38,13 +40,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots. + /// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots. /// /// The runtime client whose connection and subscription state should be reported. /// The performance metrics collector whose operation statistics should be reported. /// The Galaxy repository statistics to surface on the dashboard. /// The OPC UA server host whose active session count should be reported. - /// The node manager whose queue depth and MXAccess event throughput should be surfaced on the dashboard. + /// + /// The node manager whose queue depth and MXAccess event throughput should be surfaced on the + /// dashboard. + /// public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics, GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost, LmxNodeManager? nodeManager = null, @@ -60,7 +65,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers. + /// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers. /// /// The current dashboard status data for the bridge. public StatusData GetStatusData() @@ -96,7 +101,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status PendingItems = _nodeManager?.PendingDataChangeCount ?? 0, TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0 }, - Operations = _metrics?.GetStatistics() ?? new(), + Operations = _metrics?.GetStatistics() ?? new Dictionary(), Redundancy = BuildRedundancyInfo(), Footer = new FooterInfo { @@ -126,12 +131,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status Role = _redundancyConfig.Role, ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected), ApplicationUri = _applicationUri ?? "", - ServerUris = new System.Collections.Generic.List(_redundancyConfig.ServerUris) + ServerUris = new List(_redundancyConfig.ServerUris) }; } /// - /// Generates the operator-facing HTML dashboard for the current bridge status. + /// Generates the operator-facing HTML dashboard for the current bridge status. /// /// An HTML document containing the latest dashboard snapshot. public string GenerateHtml() @@ -146,16 +151,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status sb.AppendLine(""); sb.AppendLine("

LmxOpcUa Status Dashboard

"); // Connection panel - var connColor = data.Connection.State == "Connected" ? "green" : data.Connection.State == "Connecting" ? "yellow" : "red"; + var connColor = data.Connection.State == "Connected" ? "green" : + data.Connection.State == "Connecting" ? "yellow" : "red"; sb.AppendLine($"

Connection

"); - sb.AppendLine($"

State: {data.Connection.State} | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}

"); + sb.AppendLine( + $"

State: {data.Connection.State} | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}

"); sb.AppendLine("
"); // Health panel @@ -168,7 +177,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status { var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow"; sb.AppendLine($"

Redundancy

"); - sb.AppendLine($"

Mode: {data.Redundancy.Mode} | Role: {data.Redundancy.Role} | Service Level: {data.Redundancy.ServiceLevel}

"); + sb.AppendLine( + $"

Mode: {data.Redundancy.Mode} | Role: {data.Redundancy.Role} | Service Level: {data.Redundancy.ServiceLevel}

"); sb.AppendLine($"

Application URI: {data.Redundancy.ApplicationUri}

"); sb.AppendLine($"

Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}

"); sb.AppendLine("
"); @@ -181,25 +191,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status // Data Change Dispatch panel sb.AppendLine("

Data Change Dispatch

"); - sb.AppendLine($"

Events/sec: {data.DataChange.EventsPerSecond:F1} | Avg Batch Size: {data.DataChange.AvgBatchSize:F1} | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}

"); + sb.AppendLine( + $"

Events/sec: {data.DataChange.EventsPerSecond:F1} | Avg Batch Size: {data.DataChange.AvgBatchSize:F1} | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}

"); sb.AppendLine("
"); // Galaxy Info panel sb.AppendLine("

Galaxy Info

"); - sb.AppendLine($"

Galaxy: {data.Galaxy.GalaxyName} | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}

"); - sb.AppendLine($"

Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}

"); + sb.AppendLine( + $"

Galaxy: {data.Galaxy.GalaxyName} | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}

"); + sb.AppendLine( + $"

Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}

"); sb.AppendLine($"

Last Rebuild: {data.Galaxy.LastRebuildTime:O}

"); sb.AppendLine("
"); // Operations table sb.AppendLine("

Operations

"); - sb.AppendLine(""); + sb.AppendLine( + "
OperationCountSuccess RateAvg (ms)Min (ms)Max (ms)P95 (ms)
"); foreach (var kvp in data.Operations) { var s = kvp.Value; sb.AppendLine($"" + - $""); + $""); } + sb.AppendLine("
OperationCountSuccess RateAvg (ms)Min (ms)Max (ms)P95 (ms)
{kvp.Key}{s.TotalCount}{s.SuccessRate:P1}{s.AverageMilliseconds:F1}{s.MinMilliseconds:F1}{s.MaxMilliseconds:F1}{s.Percentile95Milliseconds:F1}
{s.AverageMilliseconds:F1}{s.MinMilliseconds:F1}{s.MaxMilliseconds:F1}{s.Percentile95Milliseconds:F1}
"); // Footer @@ -212,7 +227,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Generates an indented JSON status payload for API consumers. + /// Generates an indented JSON status payload for API consumers. /// /// A JSON representation of the current dashboard snapshot. public string GenerateJson() @@ -222,9 +237,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint. + /// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint. /// - /// when the bridge meets the health policy; otherwise, . + /// when the bridge meets the health policy; otherwise, . public bool IsHealthy() { var state = _mxAccessClient?.State ?? ConnectionState.Disconnected; @@ -232,7 +247,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state. + /// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state. /// public HealthEndpointData GetHealthData() { @@ -250,7 +265,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status { MxAccess = connectionState.ToString(), Database = dbConnected ? "Connected" : "Disconnected", - OpcUaServer = (_serverHost?.IsRunning ?? false) ? "Running" : "Stopped" + OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped" }, Uptime = FormatUptime(uptime), Timestamp = DateTime.UtcNow @@ -278,7 +293,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Generates the JSON payload for the /api/health endpoint. + /// Generates the JSON payload for the /api/health endpoint. /// public string GenerateHealthJson() { @@ -287,7 +302,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status } /// - /// Generates a focused health status HTML page for operators and monitoring dashboards. + /// Generates a focused health status HTML page for operators and monitoring dashboards. /// public string GenerateHealthHtml() { @@ -304,13 +319,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status sb.AppendLine($""); sb.AppendLine("LmxOpcUa Health"); sb.AppendLine("