Apply code style formatting and restore partial modifiers on Avalonia views
Linter/formatter pass across the full codebase. Restores required partial keyword on AXAML code-behind classes that the formatter incorrectly removed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,8 @@ public abstract class CommandBase : ICommand
|
|||||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||||
public string? Password { get; init; }
|
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";
|
public string Security { get; init; } = "none";
|
||||||
|
|
||||||
[CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")]
|
[CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")]
|
||||||
@@ -40,6 +41,8 @@ public abstract class CommandBase : ICommand
|
|||||||
[CommandOption("verbose", Description = "Enable verbose/debug logging")]
|
[CommandOption("verbose", Description = "Enable verbose/debug logging")]
|
||||||
public bool Verbose { get; init; }
|
public bool Verbose { get; init; }
|
||||||
|
|
||||||
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
|
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -67,7 +70,8 @@ public abstract class CommandBase : ICommand
|
|||||||
/// Creates a new <see cref="IOpcUaClientService" />, connects it using the common options,
|
/// Creates a new <see cref="IOpcUaClientService" />, connects it using the common options,
|
||||||
/// and returns both the service and the connection info.
|
/// and returns both the service and the connection info.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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 service = _factory.Create();
|
||||||
var settings = CreateConnectionSettings();
|
var settings = CreateConnectionSettings();
|
||||||
@@ -82,18 +86,12 @@ public abstract class CommandBase : ICommand
|
|||||||
{
|
{
|
||||||
var config = new LoggerConfiguration();
|
var config = new LoggerConfiguration();
|
||||||
if (Verbose)
|
if (Verbose)
|
||||||
{
|
|
||||||
config.MinimumLevel.Debug()
|
config.MinimumLevel.Debug()
|
||||||
.WriteTo.Console();
|
.WriteTo.Console();
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
config.MinimumLevel.Warning()
|
config.MinimumLevel.Warning()
|
||||||
.WriteTo.Console();
|
.WriteTo.Console();
|
||||||
}
|
|
||||||
|
|
||||||
Log.Logger = config.CreateLogger();
|
Log.Logger = config.CreateLogger();
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
|||||||
[Command("alarms", Description = "Subscribe to alarm events")]
|
[Command("alarms", Description = "Subscribe to alarm events")]
|
||||||
public class AlarmsCommand : CommandBase
|
public class AlarmsCommand : CommandBase
|
||||||
{
|
{
|
||||||
|
public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
[CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
|
[CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
|
||||||
public string? NodeId { get; init; }
|
public string? NodeId { get; init; }
|
||||||
|
|
||||||
@@ -17,8 +21,6 @@ public class AlarmsCommand : CommandBase
|
|||||||
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
|
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
|
||||||
public bool Refresh { get; init; }
|
public bool Refresh { get; init; }
|
||||||
|
|
||||||
public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
@@ -49,7 +51,6 @@ public class AlarmsCommand : CommandBase
|
|||||||
$"Subscribed to alarm events (interval: {Interval}ms). Press Ctrl+C to stop.");
|
$"Subscribed to alarm events (interval: {Interval}ms). Press Ctrl+C to stop.");
|
||||||
|
|
||||||
if (Refresh)
|
if (Refresh)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await service.RequestConditionRefreshAsync(ct);
|
await service.RequestConditionRefreshAsync(ct);
|
||||||
@@ -59,7 +60,6 @@ public class AlarmsCommand : CommandBase
|
|||||||
{
|
{
|
||||||
await console.Output.WriteLineAsync($"Condition refresh not supported: {ex.Message}");
|
await console.Output.WriteLineAsync($"Condition refresh not supported: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Wait until cancellation
|
// Wait until cancellation
|
||||||
try
|
try
|
||||||
@@ -71,7 +71,7 @@ public class AlarmsCommand : CommandBase
|
|||||||
// Expected on Ctrl+C
|
// Expected on Ctrl+C
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.UnsubscribeAlarmsAsync(default);
|
await service.UnsubscribeAlarmsAsync();
|
||||||
await console.Output.WriteLineAsync("Unsubscribed.");
|
await console.Output.WriteLineAsync("Unsubscribed.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ using CliFx.Infrastructure;
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
|
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
|
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;
|
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
||||||
|
|
||||||
[Command("browse", Description = "Browse the OPC UA address space")]
|
[Command("browse", Description = "Browse the OPC UA address space")]
|
||||||
public class BrowseCommand : CommandBase
|
public class BrowseCommand : CommandBase
|
||||||
{
|
{
|
||||||
|
public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
[CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
|
[CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
|
||||||
public string? NodeId { get; init; }
|
public string? NodeId { get; init; }
|
||||||
|
|
||||||
@@ -19,8 +22,6 @@ public class BrowseCommand : CommandBase
|
|||||||
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
|
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
|
||||||
public bool Recursive { get; init; }
|
public bool Recursive { get; init; }
|
||||||
|
|
||||||
public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
|||||||
[Command("connect", Description = "Test connection to an OPC UA server")]
|
[Command("connect", Description = "Test connection to an OPC UA server")]
|
||||||
public class ConnectCommand : CommandBase
|
public class ConnectCommand : CommandBase
|
||||||
{
|
{
|
||||||
public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
|||||||
[Command("historyread", Description = "Read historical data from a node")]
|
[Command("historyread", Description = "Read historical data from a node")]
|
||||||
public class HistoryReadCommand : CommandBase
|
public class HistoryReadCommand : CommandBase
|
||||||
{
|
{
|
||||||
|
public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
||||||
public string NodeId { get; init; } = default!;
|
public string NodeId { get; init; } = default!;
|
||||||
|
|
||||||
@@ -28,8 +32,6 @@ public class HistoryReadCommand : CommandBase
|
|||||||
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
|
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
|
||||||
public double IntervalMs { get; init; } = 3600000;
|
public double IntervalMs { get; init; } = 3600000;
|
||||||
|
|
||||||
public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
|||||||
[Command("read", Description = "Read a value from a node")]
|
[Command("read", Description = "Read a value from a node")]
|
||||||
public class ReadCommand : CommandBase
|
public class ReadCommand : CommandBase
|
||||||
{
|
{
|
||||||
|
public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
||||||
public string NodeId { get; init; } = default!;
|
public string NodeId { get; init; } = default!;
|
||||||
|
|
||||||
public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
|||||||
[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
|
[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
|
||||||
public class RedundancyCommand : CommandBase
|
public class RedundancyCommand : CommandBase
|
||||||
{
|
{
|
||||||
public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
@@ -26,10 +28,7 @@ public class RedundancyCommand : CommandBase
|
|||||||
if (info.ServerUris.Length > 0)
|
if (info.ServerUris.Length > 0)
|
||||||
{
|
{
|
||||||
await console.Output.WriteLineAsync("Server URIs:");
|
await console.Output.WriteLineAsync("Server URIs:");
|
||||||
foreach (var uri in info.ServerUris)
|
foreach (var uri in info.ServerUris) await console.Output.WriteLineAsync($" - {uri}");
|
||||||
{
|
|
||||||
await console.Output.WriteLineAsync($" - {uri}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Application URI: {info.ApplicationUri}");
|
await console.Output.WriteLineAsync($"Application URI: {info.ApplicationUri}");
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
|||||||
[Command("subscribe", Description = "Monitor a node for value changes")]
|
[Command("subscribe", Description = "Monitor a node for value changes")]
|
||||||
public class SubscribeCommand : CommandBase
|
public class SubscribeCommand : CommandBase
|
||||||
{
|
{
|
||||||
|
public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
[CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
|
[CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
|
||||||
public string NodeId { get; init; } = default!;
|
public string NodeId { get; init; } = default!;
|
||||||
|
|
||||||
[CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")]
|
[CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")]
|
||||||
public int Interval { get; init; } = 1000;
|
public int Interval { get; init; } = 1000;
|
||||||
|
|
||||||
public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
@@ -47,7 +49,7 @@ public class SubscribeCommand : CommandBase
|
|||||||
// Expected on Ctrl+C
|
// Expected on Ctrl+C
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.UnsubscribeAsync(nodeId, default);
|
await service.UnsubscribeAsync(nodeId);
|
||||||
await console.Output.WriteLineAsync("Unsubscribed.");
|
await console.Output.WriteLineAsync("Unsubscribed.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
|||||||
[Command("write", Description = "Write a value to a node")]
|
[Command("write", Description = "Write a value to a node")]
|
||||||
public class WriteCommand : CommandBase
|
public class WriteCommand : CommandBase
|
||||||
{
|
{
|
||||||
|
public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
|
||||||
public string NodeId { get; init; } = default!;
|
public string NodeId { get; init; } = default!;
|
||||||
|
|
||||||
[CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
|
[CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
|
||||||
public string Value { get; init; } = default!;
|
public string Value { get; init; } = default!;
|
||||||
|
|
||||||
public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ public static class NodeIdParser
|
|||||||
// Standard OPC UA format: ns=X;s=..., ns=X;i=..., ns=X;g=..., ns=X;b=...
|
// Standard OPC UA format: ns=X;s=..., ns=X;i=..., ns=X;g=..., ns=X;b=...
|
||||||
// Also: s=..., i=..., g=..., b=... (namespace 0 implied)
|
// Also: s=..., i=..., g=..., b=... (namespace 0 implied)
|
||||||
if (trimmed.Contains('='))
|
if (trimmed.Contains('='))
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return NodeId.Parse(trimmed);
|
return NodeId.Parse(trimmed);
|
||||||
@@ -33,15 +32,12 @@ public static class NodeIdParser
|
|||||||
{
|
{
|
||||||
throw new FormatException($"Invalid node ID format: '{nodeIdString}'", ex);
|
throw new FormatException($"Invalid node ID format: '{nodeIdString}'", ex);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Bare numeric: treat as namespace 0, numeric identifier
|
// Bare numeric: treat as namespace 0, numeric identifier
|
||||||
if (uint.TryParse(trimmed, out var numericId))
|
if (uint.TryParse(trimmed, out var numericId)) return new NodeId(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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ return await new CliApplicationBuilder()
|
|||||||
.UseTypeActivator(type =>
|
.UseTypeActivator(type =>
|
||||||
{
|
{
|
||||||
// Inject the default factory into commands that derive from CommandBase
|
// Inject the default factory into commands that derive from CommandBase
|
||||||
if (type.IsSubclassOf(typeof(CommandBase)))
|
if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
|
||||||
{
|
|
||||||
return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
|
|
||||||
}
|
|
||||||
return Activator.CreateInstance(type)!;
|
return Activator.CreateInstance(type)!;
|
||||||
})
|
})
|
||||||
.SetExecutableName("lmxopcua-cli")
|
.SetExecutableName("lmxopcua-cli")
|
||||||
|
|||||||
@@ -54,11 +54,9 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
|||||||
await config.Validate(ApplicationType.Client);
|
await config.Validate(ApplicationType.Client);
|
||||||
|
|
||||||
if (settings.AutoAcceptCertificates)
|
if (settings.AutoAcceptCertificates)
|
||||||
{
|
|
||||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.SecurityMode != Models.SecurityMode.None)
|
if (settings.SecurityMode != SecurityMode.None)
|
||||||
{
|
{
|
||||||
var app = new ApplicationInstance
|
var app = new ApplicationInstance
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
|||||||
{
|
{
|
||||||
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
|
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
|
||||||
|
|
||||||
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode)
|
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||||
|
MessageSecurityMode requestedMode)
|
||||||
{
|
{
|
||||||
if (requestedMode == MessageSecurityMode.None)
|
if (requestedMode == MessageSecurityMode.None)
|
||||||
{
|
{
|
||||||
@@ -54,7 +55,8 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
|||||||
{
|
{
|
||||||
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host };
|
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host };
|
||||||
best.EndpointUrl = builder.ToString();
|
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;
|
return best;
|
||||||
|
|||||||
@@ -67,14 +67,14 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
true,
|
true,
|
||||||
nodeClassMask);
|
nodeClassMask);
|
||||||
|
|
||||||
return (continuationPoint, references ?? new ReferenceDescriptionCollection());
|
return (continuationPoint, references ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||||
byte[] continuationPoint, CancellationToken ct)
|
byte[] continuationPoint, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint);
|
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint);
|
||||||
return (nextCp, nextRefs ?? new ReferenceDescriptionCollection());
|
return (nextCp, nextRefs ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
|
public async Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
|
||||||
@@ -134,26 +134,24 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
|
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
|
||||||
{
|
|
||||||
allValues.AddRange(historyData.DataValues);
|
allValues.AddRange(historyData.DataValues);
|
||||||
}
|
|
||||||
|
|
||||||
continuationPoint = result.ContinuationPoint;
|
continuationPoint = result.ContinuationPoint;
|
||||||
}
|
} while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
|
||||||
while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
|
|
||||||
|
|
||||||
return allValues;
|
return allValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
|
public async Task<IReadOnlyList<DataValue>> 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
|
var details = new ReadProcessedDetails
|
||||||
{
|
{
|
||||||
StartTime = startTime,
|
StartTime = startTime,
|
||||||
EndTime = endTime,
|
EndTime = endTime,
|
||||||
ProcessingInterval = intervalMs,
|
ProcessingInterval = intervalMs,
|
||||||
AggregateType = new NodeIdCollection { aggregateId }
|
AggregateType = [aggregateId]
|
||||||
};
|
};
|
||||||
|
|
||||||
var nodesToRead = new HistoryReadValueIdCollection
|
var nodesToRead = new HistoryReadValueIdCollection
|
||||||
@@ -178,10 +176,8 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
if (!StatusCode.IsBad(result.StatusCode) &&
|
if (!StatusCode.IsBad(result.StatusCode) &&
|
||||||
result.HistoryData is ExtensionObject ext &&
|
result.HistoryData is ExtensionObject ext &&
|
||||||
ext.Body is HistoryData historyData)
|
ext.Body is HistoryData historyData)
|
||||||
{
|
|
||||||
allValues.AddRange(historyData.DataValues);
|
allValues.AddRange(historyData.DataValues);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return allValues;
|
return allValues;
|
||||||
}
|
}
|
||||||
@@ -204,10 +200,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_session.Connected)
|
if (_session.Connected) _session.Close();
|
||||||
{
|
|
||||||
_session.Close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -219,12 +212,12 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_session.Connected)
|
if (_session.Connected) _session.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
{
|
{
|
||||||
_session.Close();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
_session.Dispose();
|
_session.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
|
|||||||
internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||||
{
|
{
|
||||||
private static readonly ILogger Logger = Log.ForContext<DefaultSubscriptionAdapter>();
|
private static readonly ILogger Logger = Log.ForContext<DefaultSubscriptionAdapter>();
|
||||||
private readonly Subscription _subscription;
|
|
||||||
private readonly Dictionary<uint, MonitoredItem> _monitoredItems = new();
|
private readonly Dictionary<uint, MonitoredItem> _monitoredItems = new();
|
||||||
|
private readonly Subscription _subscription;
|
||||||
|
|
||||||
public DefaultSubscriptionAdapter(Subscription subscription)
|
public DefaultSubscriptionAdapter(Subscription subscription)
|
||||||
{
|
{
|
||||||
@@ -33,9 +33,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
item.Notification += (_, e) =>
|
item.Notification += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||||
{
|
|
||||||
onDataChange(nodeId.ToString(), notification.Value);
|
onDataChange(nodeId.ToString(), notification.Value);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_subscription.AddItem(item);
|
_subscription.AddItem(item);
|
||||||
@@ -75,10 +73,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
|
|
||||||
item.Notification += (_, e) =>
|
item.Notification += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.NotificationValue is EventFieldList eventFields)
|
if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
|
||||||
{
|
|
||||||
onEvent(eventFields);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_subscription.AddItem(item);
|
_subscription.AddItem(item);
|
||||||
@@ -106,6 +101,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
{
|
{
|
||||||
Logger.Warning(ex, "Error deleting subscription");
|
Logger.Warning(ex, "Error deleting subscription");
|
||||||
}
|
}
|
||||||
|
|
||||||
_monitoredItems.Clear();
|
_monitoredItems.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +111,10 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
|||||||
{
|
{
|
||||||
_subscription.Delete(true);
|
_subscription.Delete(true);
|
||||||
}
|
}
|
||||||
catch { }
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
_monitoredItems.Clear();
|
_monitoredItems.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,6 @@ internal interface IEndpointDiscovery
|
|||||||
/// Discovers endpoints at the given URL and returns the best match for the requested security mode.
|
/// 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.
|
/// Also rewrites the endpoint URL hostname to match the requested URL when they differ.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode);
|
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||||
|
MessageSecurityMode requestedMode);
|
||||||
}
|
}
|
||||||
@@ -45,12 +45,14 @@ internal interface ISessionAdapter : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads raw historical data.
|
/// Reads raw historical data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default);
|
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
|
int maxValues, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads processed/aggregate historical data.
|
/// Reads processed/aggregate historical data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, NodeId aggregateId, double intervalMs, CancellationToken ct = default);
|
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
|
NodeId aggregateId, double intervalMs, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a subscription adapter for this session.
|
/// Creates a subscription adapter for this session.
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ internal interface ISubscriptionAdapter : IDisposable
|
|||||||
/// <param name="onDataChange">Callback when data changes. Receives (nodeIdString, DataValue).</param>
|
/// <param name="onDataChange">Callback when data changes. Receives (nodeIdString, DataValue).</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>A client handle that can be used to remove the item.</returns>
|
/// <returns>A client handle that can be used to remove the item.</returns>
|
||||||
Task<uint> AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, Action<string, DataValue> onDataChange, CancellationToken ct = default);
|
Task<uint> AddDataChangeMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs,
|
||||||
|
Action<string, DataValue> onDataChange, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a previously added monitored item by its client handle.
|
/// Removes a previously added monitored item by its client handle.
|
||||||
@@ -33,7 +34,8 @@ internal interface ISubscriptionAdapter : IDisposable
|
|||||||
/// <param name="onEvent">Callback when events arrive. Receives the event field list.</param>
|
/// <param name="onEvent">Callback when events arrive. Receives the event field list.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>A client handle for the monitored item.</returns>
|
/// <returns>A client handle for the monitored item.</returns>
|
||||||
Task<uint> AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter, Action<EventFieldList> onEvent, CancellationToken ct = default);
|
Task<uint> AddEventMonitoredItemAsync(NodeId nodeId, int samplingIntervalMs, EventFilter filter,
|
||||||
|
Action<EventFieldList> onEvent, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Requests a condition refresh for this subscription.
|
/// Requests a condition refresh for this subscription.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public static class FailoverUrlParser
|
|||||||
public static string[] Parse(string primaryUrl, string? failoverCsv)
|
public static string[] Parse(string primaryUrl, string? failoverCsv)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(failoverCsv))
|
if (string.IsNullOrWhiteSpace(failoverCsv))
|
||||||
return new[] { primaryUrl };
|
return [primaryUrl];
|
||||||
|
|
||||||
var urls = new List<string> { primaryUrl };
|
var urls = new List<string> { primaryUrl };
|
||||||
foreach (var url in failoverCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
foreach (var url in failoverCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
@@ -24,6 +24,7 @@ public static class FailoverUrlParser
|
|||||||
if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
|
||||||
urls.Add(trimmed);
|
urls.Add(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls.ToArray();
|
return urls.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ public static class FailoverUrlParser
|
|||||||
public static string[] Parse(string primaryUrl, string[]? failoverUrls)
|
public static string[] Parse(string primaryUrl, string[]? failoverUrls)
|
||||||
{
|
{
|
||||||
if (failoverUrls == null || failoverUrls.Length == 0)
|
if (failoverUrls == null || failoverUrls.Length == 0)
|
||||||
return new[] { primaryUrl };
|
return [primaryUrl];
|
||||||
|
|
||||||
var urls = new List<string> { primaryUrl };
|
var urls = new List<string> { primaryUrl };
|
||||||
foreach (var url in failoverUrls)
|
foreach (var url in failoverUrls)
|
||||||
@@ -45,6 +46,7 @@ public static class FailoverUrlParser
|
|||||||
if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
|
||||||
urls.Add(trimmed);
|
urls.Add(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls.ToArray();
|
return urls.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
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;
|
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
|
||||||
|
|
||||||
@@ -8,15 +9,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IOpcUaClientService : IDisposable
|
public interface IOpcUaClientService : IDisposable
|
||||||
{
|
{
|
||||||
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
|
|
||||||
Task DisconnectAsync(CancellationToken ct = default);
|
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
ConnectionInfo? CurrentConnectionInfo { get; }
|
ConnectionInfo? CurrentConnectionInfo { get; }
|
||||||
|
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
|
||||||
|
Task DisconnectAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
|
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
|
||||||
Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
|
Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<Models.BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
|
Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
|
||||||
|
|
||||||
Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
|
Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
|
||||||
Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
|
Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
|
||||||
@@ -25,8 +26,11 @@ public interface IOpcUaClientService : IDisposable
|
|||||||
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
|
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
|
||||||
Task RequestConditionRefreshAsync(CancellationToken ct = default);
|
Task RequestConditionRefreshAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default);
|
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
|
int maxValues = 1000, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
|
||||||
|
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
|
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AlarmEventArgs : EventArgs
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The name of the source object that raised the alarm.</summary>
|
/// <summary>The name of the source object that raised the alarm.</summary>
|
||||||
public string SourceName { get; }
|
public string SourceName { get; }
|
||||||
|
|
||||||
@@ -28,24 +48,4 @@ public sealed class AlarmEventArgs : EventArgs
|
|||||||
|
|
||||||
/// <summary>The time the event occurred.</summary>
|
/// <summary>The time the event occurred.</summary>
|
||||||
public DateTime Time { get; }
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BrowseResult
|
public sealed class BrowseResult
|
||||||
{
|
{
|
||||||
|
public BrowseResult(string nodeId, string displayName, string nodeClass, bool hasChildren)
|
||||||
|
{
|
||||||
|
NodeId = nodeId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
NodeClass = nodeClass;
|
||||||
|
HasChildren = hasChildren;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The string representation of the node's NodeId.
|
/// The string representation of the node's NodeId.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -24,12 +32,4 @@ public sealed class BrowseResult
|
|||||||
/// Whether the node has child references.
|
/// Whether the node has child references.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasChildren { get; }
|
public bool HasChildren { get; }
|
||||||
|
|
||||||
public BrowseResult(string nodeId, string displayName, string nodeClass, bool hasChildren)
|
|
||||||
{
|
|
||||||
NodeId = nodeId;
|
|
||||||
DisplayName = displayName;
|
|
||||||
NodeClass = nodeClass;
|
|
||||||
HasChildren = hasChildren;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ConnectionInfo
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The endpoint URL of the connected server.</summary>
|
/// <summary>The endpoint URL of the connected server.</summary>
|
||||||
public string EndpointUrl { get; }
|
public string EndpointUrl { get; }
|
||||||
|
|
||||||
@@ -22,20 +38,4 @@ public sealed class ConnectionInfo
|
|||||||
|
|
||||||
/// <summary>The session name.</summary>
|
/// <summary>The session name.</summary>
|
||||||
public string SessionName { get; }
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,8 @@ public sealed class ConnectionSettings
|
|||||||
throw new ArgumentException("EndpointUrl must not be null or empty.", nameof(EndpointUrl));
|
throw new ArgumentException("EndpointUrl must not be null or empty.", nameof(EndpointUrl));
|
||||||
|
|
||||||
if (SessionTimeoutSeconds <= 0)
|
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)
|
if (SessionTimeoutSeconds > 3600)
|
||||||
throw new ArgumentException("SessionTimeoutSeconds must not exceed 3600.", nameof(SessionTimeoutSeconds));
|
throw new ArgumentException("SessionTimeoutSeconds must not exceed 3600.", nameof(SessionTimeoutSeconds));
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ConnectionStateChangedEventArgs : EventArgs
|
public sealed class ConnectionStateChangedEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
|
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string endpointUrl)
|
||||||
|
{
|
||||||
|
OldState = oldState;
|
||||||
|
NewState = newState;
|
||||||
|
EndpointUrl = endpointUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The previous connection state.</summary>
|
/// <summary>The previous connection state.</summary>
|
||||||
public ConnectionState OldState { get; }
|
public ConnectionState OldState { get; }
|
||||||
|
|
||||||
@@ -13,11 +20,4 @@ public sealed class ConnectionStateChangedEventArgs : EventArgs
|
|||||||
|
|
||||||
/// <summary>The endpoint URL associated with the state change.</summary>
|
/// <summary>The endpoint URL associated with the state change.</summary>
|
||||||
public string EndpointUrl { get; }
|
public string EndpointUrl { get; }
|
||||||
|
|
||||||
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string endpointUrl)
|
|
||||||
{
|
|
||||||
OldState = oldState;
|
|
||||||
NewState = newState;
|
|
||||||
EndpointUrl = endpointUrl;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DataChangedEventArgs : EventArgs
|
public sealed class DataChangedEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>The string representation of the node that changed.</summary>
|
|
||||||
public string NodeId { get; }
|
|
||||||
|
|
||||||
/// <summary>The new data value from the server.</summary>
|
|
||||||
public DataValue Value { get; }
|
|
||||||
|
|
||||||
public DataChangedEventArgs(string nodeId, DataValue value)
|
public DataChangedEventArgs(string nodeId, DataValue value)
|
||||||
{
|
{
|
||||||
NodeId = nodeId;
|
NodeId = nodeId;
|
||||||
Value = value;
|
Value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The string representation of the node that changed.</summary>
|
||||||
|
public string NodeId { get; }
|
||||||
|
|
||||||
|
/// <summary>The new data value from the server.</summary>
|
||||||
|
public DataValue Value { get; }
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RedundancyInfo
|
public sealed class RedundancyInfo
|
||||||
{
|
{
|
||||||
|
public RedundancyInfo(string mode, byte serviceLevel, string[] serverUris, string applicationUri)
|
||||||
|
{
|
||||||
|
Mode = mode;
|
||||||
|
ServiceLevel = serviceLevel;
|
||||||
|
ServerUris = serverUris;
|
||||||
|
ApplicationUri = applicationUri;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The redundancy mode (e.g., "None", "Cold", "Warm", "Hot").</summary>
|
/// <summary>The redundancy mode (e.g., "None", "Cold", "Warm", "Hot").</summary>
|
||||||
public string Mode { get; }
|
public string Mode { get; }
|
||||||
|
|
||||||
@@ -16,12 +24,4 @@ public sealed class RedundancyInfo
|
|||||||
|
|
||||||
/// <summary>The application URI of the connected server.</summary>
|
/// <summary>The application URI of the connected server.</summary>
|
||||||
public string ApplicationUri { get; }
|
public string ApplicationUri { get; }
|
||||||
|
|
||||||
public RedundancyInfo(string mode, byte serviceLevel, string[] serverUris, string applicationUri)
|
|
||||||
{
|
|
||||||
Mode = mode;
|
|
||||||
ServiceLevel = serviceLevel;
|
|
||||||
ServerUris = serverUris;
|
|
||||||
ApplicationUri = applicationUri;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
using System.Text;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
|
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
|
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
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;
|
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
|
||||||
|
|
||||||
@@ -13,30 +15,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
{
|
{
|
||||||
private static readonly ILogger Logger = Log.ForContext<OpcUaClientService>();
|
private static readonly ILogger Logger = Log.ForContext<OpcUaClientService>();
|
||||||
|
|
||||||
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
|
// Track active data subscriptions for replay after failover
|
||||||
private readonly Dictionary<string, (NodeId NodeId, int IntervalMs, uint Handle)> _activeDataSubscriptions = new();
|
private readonly Dictionary<string, (NodeId NodeId, int IntervalMs, uint Handle)> _activeDataSubscriptions = new();
|
||||||
|
|
||||||
|
private readonly IApplicationConfigurationFactory _configFactory;
|
||||||
|
private readonly IEndpointDiscovery _endpointDiscovery;
|
||||||
|
|
||||||
|
private readonly ISessionFactory _sessionFactory;
|
||||||
|
|
||||||
// Track alarm subscription state for replay after failover
|
// Track alarm subscription state for replay after failover
|
||||||
private (NodeId? SourceNodeId, int IntervalMs)? _activeAlarmSubscription;
|
private (NodeId? SourceNodeId, int IntervalMs)? _activeAlarmSubscription;
|
||||||
|
private ISubscriptionAdapter? _alarmSubscription;
|
||||||
|
private string[]? _allEndpointUrls;
|
||||||
|
private int _currentEndpointIndex;
|
||||||
|
private ISubscriptionAdapter? _dataSubscription;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
public event EventHandler<DataChangedEventArgs>? DataChanged;
|
private ISessionAdapter? _session;
|
||||||
public event EventHandler<AlarmEventArgs>? AlarmEvent;
|
private ConnectionSettings? _settings;
|
||||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
private ConnectionState _state = ConnectionState.Disconnected;
|
||||||
|
|
||||||
public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true;
|
|
||||||
public ConnectionInfo? CurrentConnectionInfo { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new OpcUaClientService with the specified adapter dependencies.
|
/// Creates a new OpcUaClientService with the specified adapter dependencies.
|
||||||
@@ -62,6 +59,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public event EventHandler<DataChangedEventArgs>? DataChanged;
|
||||||
|
public event EventHandler<AlarmEventArgs>? AlarmEvent;
|
||||||
|
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||||
|
|
||||||
|
public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true;
|
||||||
|
public ConnectionInfo? CurrentConnectionInfo { get; private set; }
|
||||||
|
|
||||||
public async Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
|
public async Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
@@ -80,10 +84,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
|
|
||||||
session.RegisterKeepAliveHandler(isGood =>
|
session.RegisterKeepAliveHandler(isGood =>
|
||||||
{
|
{
|
||||||
if (!isGood)
|
if (!isGood) _ = HandleKeepAliveFailureAsync();
|
||||||
{
|
|
||||||
_ = HandleKeepAliveFailureAsync();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||||
@@ -112,11 +113,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
await _dataSubscription.DeleteAsync(ct);
|
await _dataSubscription.DeleteAsync(ct);
|
||||||
_dataSubscription = null;
|
_dataSubscription = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_alarmSubscription != null)
|
if (_alarmSubscription != null)
|
||||||
{
|
{
|
||||||
await _alarmSubscription.DeleteAsync(ct);
|
await _alarmSubscription.DeleteAsync(ct);
|
||||||
_alarmSubscription = null;
|
_alarmSubscription = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_session != null)
|
if (_session != null)
|
||||||
{
|
{
|
||||||
await _session.CloseAsync(ct);
|
await _session.CloseAsync(ct);
|
||||||
@@ -150,7 +153,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
ThrowIfNotConnected();
|
ThrowIfNotConnected();
|
||||||
|
|
||||||
// Read current value for type coercion when value is a string
|
// Read current value for type coercion when value is a string
|
||||||
object typedValue = value;
|
var typedValue = value;
|
||||||
if (value is string rawString)
|
if (value is string rawString)
|
||||||
{
|
{
|
||||||
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
||||||
@@ -161,14 +164,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
|
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Models.BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
|
public async Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
ThrowIfNotConnected();
|
ThrowIfNotConnected();
|
||||||
|
|
||||||
var startNode = parentNodeId ?? ObjectIds.ObjectsFolder;
|
var startNode = parentNodeId ?? ObjectIds.ObjectsFolder;
|
||||||
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
|
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
|
||||||
var results = new List<Models.BrowseResult>();
|
var results = new List<BrowseResult>();
|
||||||
|
|
||||||
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
|
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
|
||||||
|
|
||||||
@@ -180,7 +184,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
var hasChildren = reference.NodeClass == NodeClass.Object &&
|
var hasChildren = reference.NodeClass == NodeClass.Object &&
|
||||||
await _session.HasChildrenAsync(childNodeId, ct);
|
await _session.HasChildrenAsync(childNodeId, ct);
|
||||||
|
|
||||||
results.Add(new Models.BrowseResult(
|
results.Add(new BrowseResult(
|
||||||
reference.NodeId.ToString(),
|
reference.NodeId.ToString(),
|
||||||
reference.DisplayName?.Text ?? string.Empty,
|
reference.DisplayName?.Text ?? string.Empty,
|
||||||
reference.NodeClass.ToString(),
|
reference.NodeClass.ToString(),
|
||||||
@@ -188,14 +192,10 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (continuationPoint != null && continuationPoint.Length > 0)
|
if (continuationPoint != null && continuationPoint.Length > 0)
|
||||||
{
|
|
||||||
(continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct);
|
(continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -209,10 +209,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
|
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
|
||||||
return; // Already subscribed
|
return; // Already subscribed
|
||||||
|
|
||||||
if (_dataSubscription == null)
|
if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
|
||||||
{
|
|
||||||
_dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
|
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
nodeId, intervalMs, OnDataChangeNotification, ct);
|
||||||
@@ -229,16 +226,14 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out var sub))
|
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out var sub))
|
||||||
return; // Not subscribed, safe to ignore
|
return; // Not subscribed, safe to ignore
|
||||||
|
|
||||||
if (_dataSubscription != null)
|
if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
|
||||||
{
|
|
||||||
await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
_activeDataSubscriptions.Remove(nodeIdStr);
|
_activeDataSubscriptions.Remove(nodeIdStr);
|
||||||
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
|
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();
|
ThrowIfDisposed();
|
||||||
ThrowIfNotConnected();
|
ThrowIfNotConnected();
|
||||||
@@ -305,16 +300,18 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
ThrowIfNotConnected();
|
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 redundancyMode = ((RedundancySupport)(int)redundancySupportValue.Value).ToString();
|
||||||
|
|
||||||
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
||||||
var serviceLevel = (byte)serviceLevelValue.Value;
|
var serviceLevel = (byte)serviceLevelValue.Value;
|
||||||
|
|
||||||
string[] serverUris = Array.Empty<string>();
|
string[] serverUris = [];
|
||||||
try
|
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)
|
if (serverUriArrayValue.Value is string[] uris)
|
||||||
serverUris = uris;
|
serverUris = uris;
|
||||||
}
|
}
|
||||||
@@ -323,7 +320,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
// ServerUriArray may not be present when RedundancySupport is None
|
// ServerUriArray may not be present when RedundancySupport is None
|
||||||
}
|
}
|
||||||
|
|
||||||
string applicationUri = string.Empty;
|
var applicationUri = string.Empty;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
|
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
|
||||||
@@ -354,7 +351,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
|
|
||||||
// --- Private helpers ---
|
// --- Private helpers ---
|
||||||
|
|
||||||
private async Task<ISessionAdapter> ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl, CancellationToken ct)
|
private async Task<ISessionAdapter> ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Create a settings copy with the current endpoint URL
|
// Create a settings copy with the current endpoint URL
|
||||||
var effectiveSettings = new ConnectionSettings
|
var effectiveSettings = new ConnectionSettings
|
||||||
@@ -372,12 +370,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
||||||
var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);
|
var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);
|
||||||
|
|
||||||
UserIdentity identity = settings.Username != null
|
var identity = settings.Username != null
|
||||||
? new UserIdentity(settings.Username, System.Text.Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||||
: new UserIdentity();
|
: new UserIdentity();
|
||||||
|
|
||||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
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()
|
private async Task HandleKeepAliveFailureAsync()
|
||||||
@@ -392,9 +391,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
// Close old session
|
// Close old session
|
||||||
if (_session != null)
|
if (_session != null)
|
||||||
{
|
{
|
||||||
try { _session.Dispose(); } catch { }
|
try
|
||||||
|
{
|
||||||
|
_session.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
_session = null;
|
_session = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_dataSubscription = null;
|
_dataSubscription = null;
|
||||||
_alarmSubscription = null;
|
_alarmSubscription = null;
|
||||||
|
|
||||||
@@ -405,7 +412,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try each endpoint
|
// Try each endpoint
|
||||||
for (int attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
|
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
|
||||||
{
|
{
|
||||||
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
|
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
|
||||||
var url = _allEndpointUrls[_currentEndpointIndex];
|
var url = _allEndpointUrls[_currentEndpointIndex];
|
||||||
@@ -418,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
|
|
||||||
session.RegisterKeepAliveHandler(isGood =>
|
session.RegisterKeepAliveHandler(isGood =>
|
||||||
{
|
{
|
||||||
if (!isGood) { _ = HandleKeepAliveFailureAsync(); }
|
if (!isGood) _ = HandleKeepAliveFailureAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||||
@@ -448,7 +455,6 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
_activeDataSubscriptions.Clear();
|
_activeDataSubscriptions.Clear();
|
||||||
|
|
||||||
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
|
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_dataSubscription == null)
|
if (_dataSubscription == null)
|
||||||
@@ -463,7 +469,6 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr);
|
Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Replay alarm subscription
|
// Replay alarm subscription
|
||||||
if (_activeAlarmSubscription.HasValue)
|
if (_activeAlarmSubscription.HasValue)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
|
namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
|
||||||
|
|
||||||
public partial class App : Application
|
public class App : Application
|
||||||
{
|
{
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ using Avalonia;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
|
namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
|
||||||
|
|
||||||
class Program
|
internal class Program
|
||||||
{
|
{
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
BuildAvaloniaApp()
|
||||||
.StartWithClassicDesktopLifetime(args);
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
=> AppBuilder.Configure<App>()
|
{
|
||||||
|
return AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a single alarm event row.
|
/// Represents a single alarm event row.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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(
|
public AlarmEventViewModel(
|
||||||
string sourceName,
|
string sourceName,
|
||||||
string conditionName,
|
string conditionName,
|
||||||
@@ -35,4 +26,13 @@ public partial class AlarmEventViewModel : ObservableObject
|
|||||||
AckedState = ackedState;
|
AckedState = ackedState;
|
||||||
Time = time;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -13,23 +13,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AlarmsViewModel : ObservableObject
|
public partial class AlarmsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IOpcUaClientService _service;
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly IOpcUaClientService _service;
|
||||||
|
|
||||||
/// <summary>Received alarm events.</summary>
|
[ObservableProperty] private int _interval = 1000;
|
||||||
public ObservableCollection<AlarmEventViewModel> 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]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
|
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
|
||||||
@@ -37,6 +24,14 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
[NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
|
[NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
|
||||||
private bool _isConnected;
|
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)
|
public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
_service = service;
|
_service = service;
|
||||||
@@ -44,6 +39,9 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
_service.AlarmEvent += OnAlarmEvent;
|
_service.AlarmEvent += OnAlarmEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Received alarm events.</summary>
|
||||||
|
public ObservableCollection<AlarmEventViewModel> AlarmEvents { get; } = [];
|
||||||
|
|
||||||
private void OnAlarmEvent(object? sender, AlarmEventArgs e)
|
private void OnAlarmEvent(object? sender, AlarmEventArgs e)
|
||||||
{
|
{
|
||||||
_dispatcher.Post(() =>
|
_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))]
|
[RelayCommand(CanExecute = nameof(CanSubscribe))]
|
||||||
private async Task SubscribeAsync()
|
private async Task SubscribeAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||||
? null
|
? null
|
||||||
: NodeId.Parse(MonitoredNodeIdText);
|
: 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))]
|
[RelayCommand(CanExecute = nameof(CanUnsubscribe))]
|
||||||
private async Task UnsubscribeAsync()
|
private async Task UnsubscribeAsync()
|
||||||
|
|||||||
@@ -8,13 +8,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// ViewModel for the OPC UA browse tree panel.
|
/// ViewModel for the OPC UA browse tree panel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class BrowseTreeViewModel : ObservableObject
|
public class BrowseTreeViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IOpcUaClientService _service;
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly IOpcUaClientService _service;
|
||||||
/// <summary>Top-level nodes in the browse tree.</summary>
|
|
||||||
public ObservableCollection<TreeNodeViewModel> RootNodes { get; } = new();
|
|
||||||
|
|
||||||
public BrowseTreeViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
public BrowseTreeViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
@@ -22,18 +19,20 @@ public partial class BrowseTreeViewModel : ObservableObject
|
|||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Top-level nodes in the browse tree.</summary>
|
||||||
|
public ObservableCollection<TreeNodeViewModel> RootNodes { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads root nodes by browsing with a null parent.
|
/// Loads root nodes by browsing with a null parent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task LoadRootsAsync()
|
public async Task LoadRootsAsync()
|
||||||
{
|
{
|
||||||
var results = await _service.BrowseAsync(null);
|
var results = await _service.BrowseAsync();
|
||||||
|
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() =>
|
||||||
{
|
{
|
||||||
RootNodes.Clear();
|
RootNodes.Clear();
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
{
|
|
||||||
RootNodes.Add(new TreeNodeViewModel(
|
RootNodes.Add(new TreeNodeViewModel(
|
||||||
result.NodeId,
|
result.NodeId,
|
||||||
result.DisplayName,
|
result.DisplayName,
|
||||||
@@ -41,7 +40,6 @@ public partial class BrowseTreeViewModel : ObservableObject
|
|||||||
result.HasChildren,
|
result.HasChildren,
|
||||||
_service,
|
_service,
|
||||||
_dispatcher));
|
_dispatcher));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a single historical value row.
|
/// Represents a single historical value row.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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)
|
public HistoryValueViewModel(string value, string status, string sourceTimestamp, string serverTimestamp)
|
||||||
{
|
{
|
||||||
Value = value;
|
Value = value;
|
||||||
@@ -19,4 +14,9 @@ public partial class HistoryValueViewModel : ObservableObject
|
|||||||
SourceTimestamp = sourceTimestamp;
|
SourceTimestamp = sourceTimestamp;
|
||||||
ServerTimestamp = serverTimestamp;
|
ServerTimestamp = serverTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
public string Status { get; }
|
||||||
|
public string SourceTimestamp { get; }
|
||||||
|
public string ServerTimestamp { get; }
|
||||||
}
|
}
|
||||||
@@ -13,51 +13,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class HistoryViewModel : ObservableObject
|
public partial class HistoryViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IOpcUaClientService _service;
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly IOpcUaClientService _service;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private DateTimeOffset _endTime = DateTimeOffset.UtcNow;
|
||||||
[NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
|
|
||||||
private string? _selectedNodeId;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private double _intervalMs = 3600000;
|
||||||
private DateTimeOffset _startTime = DateTimeOffset.UtcNow.AddHours(-1);
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
|
||||||
private DateTimeOffset _endTime = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _maxValues = 1000;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private AggregateType? _selectedAggregateType;
|
|
||||||
|
|
||||||
/// <summary>Available aggregate types (null means "Raw").</summary>
|
|
||||||
public IReadOnlyList<AggregateType?> 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))]
|
|
||||||
private bool _isConnected;
|
private bool _isConnected;
|
||||||
|
|
||||||
/// <summary>History read results.</summary>
|
[ObservableProperty] private bool _isLoading;
|
||||||
public ObservableCollection<HistoryValueViewModel> Results { get; } = new();
|
|
||||||
|
[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)
|
public HistoryViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
@@ -65,12 +40,32 @@ public partial class HistoryViewModel : ObservableObject
|
|||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Available aggregate types (null means "Raw").</summary>
|
||||||
|
public IReadOnlyList<AggregateType?> AggregateTypes { get; } =
|
||||||
|
[
|
||||||
|
null,
|
||||||
|
AggregateType.Average,
|
||||||
|
AggregateType.Minimum,
|
||||||
|
AggregateType.Maximum,
|
||||||
|
AggregateType.Count,
|
||||||
|
AggregateType.Start,
|
||||||
|
AggregateType.End
|
||||||
|
];
|
||||||
|
|
||||||
|
public bool IsAggregateRead => SelectedAggregateType != null;
|
||||||
|
|
||||||
|
/// <summary>History read results.</summary>
|
||||||
|
public ObservableCollection<HistoryValueViewModel> Results { get; } = [];
|
||||||
|
|
||||||
partial void OnSelectedAggregateTypeChanged(AggregateType? value)
|
partial void OnSelectedAggregateTypeChanged(AggregateType? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsAggregateRead));
|
OnPropertyChanged(nameof(IsAggregateRead));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanReadHistory() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
|
private bool CanReadHistory()
|
||||||
|
{
|
||||||
|
return IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanReadHistory))]
|
[RelayCommand(CanExecute = nameof(CanReadHistory))]
|
||||||
private async Task ReadHistoryAsync()
|
private async Task ReadHistoryAsync()
|
||||||
@@ -86,33 +81,27 @@ public partial class HistoryViewModel : ObservableObject
|
|||||||
IReadOnlyList<DataValue> values;
|
IReadOnlyList<DataValue> values;
|
||||||
|
|
||||||
if (SelectedAggregateType != null)
|
if (SelectedAggregateType != null)
|
||||||
{
|
|
||||||
values = await _service.HistoryReadAggregateAsync(
|
values = await _service.HistoryReadAggregateAsync(
|
||||||
nodeId,
|
nodeId,
|
||||||
StartTime.UtcDateTime,
|
StartTime.UtcDateTime,
|
||||||
EndTime.UtcDateTime,
|
EndTime.UtcDateTime,
|
||||||
SelectedAggregateType.Value,
|
SelectedAggregateType.Value,
|
||||||
IntervalMs);
|
IntervalMs);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
values = await _service.HistoryReadRawAsync(
|
values = await _service.HistoryReadRawAsync(
|
||||||
nodeId,
|
nodeId,
|
||||||
StartTime.UtcDateTime,
|
StartTime.UtcDateTime,
|
||||||
EndTime.UtcDateTime,
|
EndTime.UtcDateTime,
|
||||||
MaxValues);
|
MaxValues);
|
||||||
}
|
|
||||||
|
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() =>
|
||||||
{
|
{
|
||||||
foreach (var dv in values)
|
foreach (var dv in values)
|
||||||
{
|
|
||||||
Results.Add(new HistoryValueViewModel(
|
Results.Add(new HistoryValueViewModel(
|
||||||
dv.Value?.ToString() ?? "(null)",
|
dv.Value?.ToString() ?? "(null)",
|
||||||
dv.StatusCode.ToString(),
|
dv.StatusCode.ToString(),
|
||||||
dv.SourceTimestamp.ToString("O"),
|
dv.SourceTimestamp.ToString("O"),
|
||||||
dv.ServerTimestamp.ToString("O")));
|
dv.ServerTimestamp.ToString("O")));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -12,74 +12,45 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainWindowViewModel : ObservableObject
|
public partial class MainWindowViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IOpcUaClientService _service;
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly IOpcUaClientService _service;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private bool _autoAcceptCertificates = true;
|
||||||
private string _endpointUrl = "opc.tcp://localhost:4840";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string _certificateStorePath = Path.Combine(
|
||||||
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(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"LmxOpcUaClient", "pki");
|
"LmxOpcUaClient", "pki");
|
||||||
|
|
||||||
/// <summary>All available security modes.</summary>
|
|
||||||
public IReadOnlyList<SecurityMode> SecurityModes { get; } = Enum.GetValues<SecurityMode>();
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(DisconnectCommand))]
|
[NotifyCanExecuteChangedFor(nameof(DisconnectCommand))]
|
||||||
private ConnectionState _connectionState = ConnectionState.Disconnected;
|
private ConnectionState _connectionState = ConnectionState.Disconnected;
|
||||||
|
|
||||||
public bool IsConnected => ConnectionState == ConnectionState.Connected;
|
[ObservableProperty] private string _endpointUrl = "opc.tcp://localhost:4840";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string? _failoverUrls;
|
||||||
private TreeNodeViewModel? _selectedTreeNode;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private bool _isHistoryEnabledForSelection;
|
||||||
private RedundancyInfo? _redundancyInfo;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string? _password;
|
||||||
private string _statusMessage = "Disconnected";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private RedundancyInfo? _redundancyInfo;
|
||||||
private string _sessionLabel = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private SecurityMode _selectedSecurityMode = SecurityMode.None;
|
||||||
private int _subscriptionCount;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private int _selectedTabIndex;
|
||||||
private int _selectedTabIndex;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private TreeNodeViewModel? _selectedTreeNode;
|
||||||
private bool _isHistoryEnabledForSelection;
|
|
||||||
|
|
||||||
/// <summary>The currently selected tree nodes (supports multi-select).</summary>
|
[ObservableProperty] private string _sessionLabel = string.Empty;
|
||||||
public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = new();
|
|
||||||
|
|
||||||
public BrowseTreeViewModel BrowseTree { get; }
|
[ObservableProperty] private int _sessionTimeoutSeconds = 60;
|
||||||
public ReadWriteViewModel ReadWrite { get; }
|
|
||||||
public SubscriptionsViewModel Subscriptions { get; }
|
[ObservableProperty] private string _statusMessage = "Disconnected";
|
||||||
public AlarmsViewModel Alarms { get; }
|
|
||||||
public HistoryViewModel History { get; }
|
[ObservableProperty] private int _subscriptionCount;
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _username;
|
||||||
|
|
||||||
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher)
|
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
@@ -95,12 +66,23 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
_service.ConnectionStateChanged += OnConnectionStateChanged;
|
_service.ConnectionStateChanged += OnConnectionStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>All available security modes.</summary>
|
||||||
|
public IReadOnlyList<SecurityMode> SecurityModes { get; } = Enum.GetValues<SecurityMode>();
|
||||||
|
|
||||||
|
public bool IsConnected => ConnectionState == ConnectionState.Connected;
|
||||||
|
|
||||||
|
/// <summary>The currently selected tree nodes (supports multi-select).</summary>
|
||||||
|
public ObservableCollection<TreeNodeViewModel> 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)
|
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
|
||||||
{
|
{
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() => { ConnectionState = e.NewState; });
|
||||||
{
|
|
||||||
ConnectionState = e.NewState;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnConnectionStateChanged(ConnectionState value)
|
partial void OnConnectionStateChanged(ConnectionState value)
|
||||||
@@ -144,7 +126,10 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
History.SelectedNodeId = value?.NodeId;
|
History.SelectedNodeId = value?.NodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanConnect() => ConnectionState == ConnectionState.Disconnected;
|
private bool CanConnect()
|
||||||
|
{
|
||||||
|
return ConnectionState == ConnectionState.Disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanConnect))]
|
[RelayCommand(CanExecute = nameof(CanConnect))]
|
||||||
private async Task ConnectAsync()
|
private async Task ConnectAsync()
|
||||||
@@ -199,8 +184,11 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanDisconnect() => ConnectionState == ConnectionState.Connected
|
private bool CanDisconnect()
|
||||||
|
{
|
||||||
|
return ConnectionState == ConnectionState.Connected
|
||||||
|| ConnectionState == ConnectionState.Reconnecting;
|
|| ConnectionState == ConnectionState.Reconnecting;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanDisconnect))]
|
[RelayCommand(CanExecute = nameof(CanDisconnect))]
|
||||||
private async Task DisconnectAsync()
|
private async Task DisconnectAsync()
|
||||||
@@ -217,10 +205,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() => { ConnectionState = ConnectionState.Disconnected; });
|
||||||
{
|
|
||||||
ConnectionState = ConnectionState.Disconnected;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,10 +218,7 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
|
if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
|
||||||
|
|
||||||
var nodes = SelectedTreeNodes.ToList();
|
var nodes = SelectedTreeNodes.ToList();
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes) await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId);
|
||||||
{
|
|
||||||
await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
SubscriptionCount = Subscriptions.SubscriptionCount;
|
SubscriptionCount = Subscriptions.SubscriptionCount;
|
||||||
SelectedTabIndex = 1; // Subscriptions tab
|
SelectedTabIndex = 1; // Subscriptions tab
|
||||||
|
|||||||
@@ -11,38 +11,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ReadWriteViewModel : ObservableObject
|
public partial class ReadWriteViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IOpcUaClientService _service;
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly IOpcUaClientService _service;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string? _currentStatus;
|
||||||
[NotifyCanExecuteChangedFor(nameof(ReadCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(WriteCommand))]
|
|
||||||
private string? _selectedNodeId;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string? _currentValue;
|
||||||
private string? _currentValue;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string? _currentStatus;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string? _sourceTimestamp;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string? _serverTimestamp;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string? _writeValue;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string? _writeStatus;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ReadCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ReadCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(WriteCommand))]
|
[NotifyCanExecuteChangedFor(nameof(WriteCommand))]
|
||||||
private bool _isConnected;
|
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)
|
public ReadWriteViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
@@ -50,16 +42,18 @@ public partial class ReadWriteViewModel : ObservableObject
|
|||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsNodeSelected => !string.IsNullOrEmpty(SelectedNodeId);
|
||||||
|
|
||||||
partial void OnSelectedNodeIdChanged(string? value)
|
partial void OnSelectedNodeIdChanged(string? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsNodeSelected));
|
OnPropertyChanged(nameof(IsNodeSelected));
|
||||||
if (!string.IsNullOrEmpty(value) && IsConnected)
|
if (!string.IsNullOrEmpty(value) && IsConnected) _ = ExecuteReadAsync();
|
||||||
{
|
|
||||||
_ = ExecuteReadAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanReadOrWrite() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
|
private bool CanReadOrWrite()
|
||||||
|
{
|
||||||
|
return IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanReadOrWrite))]
|
[RelayCommand(CanExecute = nameof(CanReadOrWrite))]
|
||||||
private async Task ReadAsync()
|
private async Task ReadAsync()
|
||||||
@@ -106,17 +100,11 @@ public partial class ReadWriteViewModel : ObservableObject
|
|||||||
var nodeId = NodeId.Parse(SelectedNodeId);
|
var nodeId = NodeId.Parse(SelectedNodeId);
|
||||||
var statusCode = await _service.WriteValueAsync(nodeId, WriteValue);
|
var statusCode = await _service.WriteValueAsync(nodeId, WriteValue);
|
||||||
|
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() => { WriteStatus = statusCode.ToString(); });
|
||||||
{
|
|
||||||
WriteStatus = statusCode.ToString();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() => { WriteStatus = $"Error: {ex.Message}"; });
|
||||||
{
|
|
||||||
WriteStatus = $"Error: {ex.Message}";
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,24 +7,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class SubscriptionItemViewModel : ObservableObject
|
public partial class SubscriptionItemViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
/// <summary>The monitored NodeId.</summary>
|
[ObservableProperty] private string? _status;
|
||||||
public string NodeId { get; }
|
|
||||||
|
|
||||||
/// <summary>The subscription interval in milliseconds.</summary>
|
[ObservableProperty] private string? _timestamp;
|
||||||
public int IntervalMs { get; }
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string? _value;
|
||||||
private string? _value;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string? _status;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string? _timestamp;
|
|
||||||
|
|
||||||
public SubscriptionItemViewModel(string nodeId, int intervalMs)
|
public SubscriptionItemViewModel(string nodeId, int intervalMs)
|
||||||
{
|
{
|
||||||
NodeId = nodeId;
|
NodeId = nodeId;
|
||||||
IntervalMs = intervalMs;
|
IntervalMs = intervalMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The monitored NodeId.</summary>
|
||||||
|
public string NodeId { get; }
|
||||||
|
|
||||||
|
/// <summary>The subscription interval in milliseconds.</summary>
|
||||||
|
public int IntervalMs { get; }
|
||||||
}
|
}
|
||||||
@@ -13,31 +13,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class SubscriptionsViewModel : ObservableObject
|
public partial class SubscriptionsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IOpcUaClientService _service;
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly IOpcUaClientService _service;
|
||||||
/// <summary>Currently active subscriptions.</summary>
|
|
||||||
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
|
||||||
private string? _newNodeIdText;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private int _newInterval = 1000;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
||||||
private bool _isConnected;
|
private bool _isConnected;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] private int _newInterval = 1000;
|
||||||
private int _subscriptionCount;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
private string? _newNodeIdText;
|
||||||
|
|
||||||
|
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
||||||
private SubscriptionItemViewModel? _selectedSubscription;
|
private SubscriptionItemViewModel? _selectedSubscription;
|
||||||
|
|
||||||
|
[ObservableProperty] private int _subscriptionCount;
|
||||||
|
|
||||||
public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
_service = service;
|
_service = service;
|
||||||
@@ -45,23 +38,27 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
_service.DataChanged += OnDataChanged;
|
_service.DataChanged += OnDataChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Currently active subscriptions.</summary>
|
||||||
|
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
|
||||||
|
|
||||||
private void OnDataChanged(object? sender, DataChangedEventArgs e)
|
private void OnDataChanged(object? sender, DataChangedEventArgs e)
|
||||||
{
|
{
|
||||||
_dispatcher.Post(() =>
|
_dispatcher.Post(() =>
|
||||||
{
|
{
|
||||||
foreach (var item in ActiveSubscriptions)
|
foreach (var item in ActiveSubscriptions)
|
||||||
{
|
|
||||||
if (item.NodeId == e.NodeId)
|
if (item.NodeId == e.NodeId)
|
||||||
{
|
{
|
||||||
item.Value = e.Value.Value?.ToString() ?? "(null)";
|
item.Value = e.Value.Value?.ToString() ?? "(null)";
|
||||||
item.Status = e.Value.StatusCode.ToString();
|
item.Status = e.Value.StatusCode.ToString();
|
||||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
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))]
|
[RelayCommand(CanExecute = nameof(CanAddSubscription))]
|
||||||
private async Task AddSubscriptionAsync()
|
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))]
|
[RelayCommand(CanExecute = nameof(CanRemoveSubscription))]
|
||||||
private async Task RemoveSubscriptionAsync()
|
private async Task RemoveSubscriptionAsync()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Opc.Ua;
|
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
|
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
|
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
|
||||||
|
|
||||||
@@ -12,31 +11,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
|||||||
public partial class TreeNodeViewModel : ObservableObject
|
public partial class TreeNodeViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private static readonly TreeNodeViewModel PlaceholderSentinel = new();
|
private static readonly TreeNodeViewModel PlaceholderSentinel = new();
|
||||||
|
private readonly IUiDispatcher? _dispatcher;
|
||||||
|
|
||||||
private readonly IOpcUaClientService? _service;
|
private readonly IOpcUaClientService? _service;
|
||||||
private readonly IUiDispatcher? _dispatcher;
|
|
||||||
private bool _hasLoadedChildren;
|
private bool _hasLoadedChildren;
|
||||||
|
|
||||||
/// <summary>The string NodeId of this node.</summary>
|
[ObservableProperty] private bool _isExpanded;
|
||||||
public string NodeId { get; }
|
|
||||||
|
|
||||||
/// <summary>The display name shown in the tree.</summary>
|
[ObservableProperty] private bool _isLoading;
|
||||||
public string DisplayName { get; }
|
|
||||||
|
|
||||||
/// <summary>The OPC UA node class (Object, Variable, etc.).</summary>
|
|
||||||
public string NodeClass { get; }
|
|
||||||
|
|
||||||
/// <summary>Whether this node has child references.</summary>
|
|
||||||
public bool HasChildren { get; }
|
|
||||||
|
|
||||||
/// <summary>Child nodes (may contain a placeholder sentinel before first expand).</summary>
|
|
||||||
public ObservableCollection<TreeNodeViewModel> Children { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool _isExpanded;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool _isLoading;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Private constructor for the placeholder sentinel only.
|
/// Private constructor for the placeholder sentinel only.
|
||||||
@@ -64,18 +46,32 @@ public partial class TreeNodeViewModel : ObservableObject
|
|||||||
_service = service;
|
_service = service;
|
||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
|
|
||||||
if (hasChildren)
|
if (hasChildren) Children.Add(PlaceholderSentinel);
|
||||||
{
|
|
||||||
Children.Add(PlaceholderSentinel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The string NodeId of this node.</summary>
|
||||||
|
public string NodeId { get; }
|
||||||
|
|
||||||
|
/// <summary>The display name shown in the tree.</summary>
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
/// <summary>The OPC UA node class (Object, Variable, etc.).</summary>
|
||||||
|
public string NodeClass { get; }
|
||||||
|
|
||||||
|
/// <summary>Whether this node has child references.</summary>
|
||||||
|
public bool HasChildren { get; }
|
||||||
|
|
||||||
|
/// <summary>Child nodes (may contain a placeholder sentinel before first expand).</summary>
|
||||||
|
public ObservableCollection<TreeNodeViewModel> Children { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns whether this node instance is the placeholder sentinel.
|
||||||
|
/// </summary>
|
||||||
|
internal bool IsPlaceholder => ReferenceEquals(this, PlaceholderSentinel);
|
||||||
|
|
||||||
partial void OnIsExpandedChanged(bool value)
|
partial void OnIsExpandedChanged(bool value)
|
||||||
{
|
{
|
||||||
if (value && !_hasLoadedChildren && HasChildren)
|
if (value && !_hasLoadedChildren && HasChildren) _ = LoadChildrenAsync();
|
||||||
{
|
|
||||||
_ = LoadChildrenAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadChildrenAsync()
|
private async Task LoadChildrenAsync()
|
||||||
@@ -94,7 +90,6 @@ public partial class TreeNodeViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Children.Clear();
|
Children.Clear();
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
{
|
|
||||||
Children.Add(new TreeNodeViewModel(
|
Children.Add(new TreeNodeViewModel(
|
||||||
result.NodeId,
|
result.NodeId,
|
||||||
result.DisplayName,
|
result.DisplayName,
|
||||||
@@ -102,7 +97,6 @@ public partial class TreeNodeViewModel : ObservableObject
|
|||||||
result.HasChildren,
|
result.HasChildren,
|
||||||
_service,
|
_service,
|
||||||
_dispatcher));
|
_dispatcher));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -114,9 +108,4 @@ public partial class TreeNodeViewModel : ObservableObject
|
|||||||
_dispatcher.Post(() => IsLoading = false);
|
_dispatcher.Post(() => IsLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns whether this node instance is the placeholder sentinel.
|
|
||||||
/// </summary>
|
|
||||||
internal bool IsPlaceholder => ReferenceEquals(this, PlaceholderSentinel);
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
||||||
@@ -17,17 +18,11 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
var browseTreeView = this.FindControl<BrowseTreeView>("BrowseTreePanel");
|
var browseTreeView = this.FindControl<BrowseTreeView>("BrowseTreePanel");
|
||||||
var treeView = browseTreeView?.FindControl<TreeView>("BrowseTree");
|
var treeView = browseTreeView?.FindControl<TreeView>("BrowseTree");
|
||||||
if (treeView != null)
|
if (treeView != null) treeView.SelectionChanged += OnTreeSelectionChanged;
|
||||||
{
|
|
||||||
treeView.SelectionChanged += OnTreeSelectionChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire up context menu opening to sync selection and check history
|
// Wire up context menu opening to sync selection and check history
|
||||||
var contextMenu = this.FindControl<ContextMenu>("TreeContextMenu");
|
var contextMenu = this.FindControl<ContextMenu>("TreeContextMenu");
|
||||||
if (contextMenu != null)
|
if (contextMenu != null) contextMenu.Opening += OnTreeContextMenuOpening;
|
||||||
{
|
|
||||||
contextMenu.Opening += OnTreeContextMenuOpening;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
@@ -40,19 +35,12 @@ public partial class MainWindow : Window
|
|||||||
// Sync multi-selection collection
|
// Sync multi-selection collection
|
||||||
vm.SelectedTreeNodes.Clear();
|
vm.SelectedTreeNodes.Clear();
|
||||||
foreach (var item in treeView.SelectedItems)
|
foreach (var item in treeView.SelectedItems)
|
||||||
{
|
|
||||||
if (item is TreeNodeViewModel node)
|
if (item is TreeNodeViewModel node)
|
||||||
{
|
|
||||||
vm.SelectedTreeNodes.Add(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)
|
if (DataContext is MainWindowViewModel vm) vm.UpdateHistoryEnabledForSelection();
|
||||||
{
|
|
||||||
vm.UpdateHistoryEnabledForSelection();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,41 +8,41 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public OpcUaConfiguration OpcUa { get; set; } = new OpcUaConfiguration();
|
public OpcUaConfiguration OpcUa { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public MxAccessConfiguration MxAccess { get; set; } = new MxAccessConfiguration();
|
public MxAccessConfiguration MxAccess { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new GalaxyRepositoryConfiguration();
|
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DashboardConfiguration Dashboard { get; set; } = new DashboardConfiguration();
|
public DashboardConfiguration Dashboard { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HistorianConfiguration Historian { get; set; } = new HistorianConfiguration();
|
public HistorianConfiguration Historian { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the authentication and role-based access control settings.
|
/// Gets or sets the authentication and role-based access control settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration();
|
public AuthenticationConfiguration Authentication { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration();
|
public SecurityProfileConfiguration Security { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RedundancyConfiguration Redundancy { get; set; } = new RedundancyConfiguration();
|
public RedundancyConfiguration Redundancy { get; set; } = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is 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.
|
/// credentials are validated against the LDAP server and group membership determines permissions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LdapConfiguration Ldap { get; set; } = new LdapConfiguration();
|
public LdapConfiguration Ldap { get; set; } = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Opc.Ua;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||||
|
|
||||||
@@ -12,19 +14,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
|
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="config">The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries, and dashboard behavior.</param>
|
/// <param name="config">
|
||||||
/// <returns><see langword="true"/> when the required settings are present and within supported bounds; otherwise, <see langword="false"/>.</returns>
|
/// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries,
|
||||||
|
/// and dashboard behavior.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="true" /> when the required settings are present and within supported bounds; otherwise,
|
||||||
|
/// <see langword="false" />.
|
||||||
|
/// </returns>
|
||||||
public static bool ValidateAndLog(AppConfiguration config)
|
public static bool ValidateAndLog(AppConfiguration config)
|
||||||
{
|
{
|
||||||
bool valid = true;
|
var valid = true;
|
||||||
|
|
||||||
Log.Information("=== Effective Configuration ===");
|
Log.Information("=== Effective Configuration ===");
|
||||||
|
|
||||||
// OPC UA
|
// OPC UA
|
||||||
Log.Information("OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
|
Log.Information(
|
||||||
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName);
|
"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}",
|
Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
|
||||||
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
|
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
|
||||||
|
|
||||||
@@ -41,10 +52,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MxAccess
|
// 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.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds,
|
||||||
config.MxAccess.MaxConcurrentOperations);
|
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.MonitorIntervalSeconds, config.MxAccess.AutoReconnect,
|
||||||
config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
|
config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
|
||||||
|
|
||||||
@@ -55,7 +68,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Galaxy Repository
|
// 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.ConnectionString, config.GalaxyRepository.ChangeDetectionIntervalSeconds,
|
||||||
config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
|
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);
|
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
|
||||||
|
|
||||||
// Security
|
// 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,
|
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
|
||||||
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
|
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);
|
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject);
|
||||||
|
|
||||||
var unknownProfiles = config.Security.Profiles
|
var unknownProfiles = config.Security.Profiles
|
||||||
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, System.StringComparer.OrdinalIgnoreCase))
|
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (unknownProfiles.Count > 0)
|
if (unknownProfiles.Count > 0)
|
||||||
{
|
|
||||||
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
|
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
|
||||||
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
|
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
|
||||||
}
|
|
||||||
|
|
||||||
if (config.Security.MinimumCertificateKeySize < 2048)
|
if (config.Security.MinimumCertificateKeySize < 2048)
|
||||||
{
|
{
|
||||||
@@ -95,14 +108,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.Security.AutoAcceptClientCertificates)
|
if (config.Security.AutoAcceptClientCertificates)
|
||||||
{
|
Log.Warning(
|
||||||
Log.Warning("Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
|
"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");
|
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
|
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
|
||||||
@@ -111,51 +122,53 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
if (config.Authentication.Ldap.Enabled)
|
if (config.Authentication.Ldap.Enabled)
|
||||||
{
|
{
|
||||||
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
|
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
|
||||||
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN);
|
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port,
|
||||||
Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
|
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.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
|
||||||
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
|
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
|
||||||
config.Authentication.Ldap.AlarmAckGroup);
|
config.Authentication.Ldap.AlarmAckGroup);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
|
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
|
||||||
{
|
|
||||||
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
|
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Redundancy
|
// Redundancy
|
||||||
if (config.OpcUa.ApplicationUri != null)
|
if (config.OpcUa.ApplicationUri != null)
|
||||||
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
|
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
|
||||||
|
|
||||||
Log.Information("Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
|
Log.Information(
|
||||||
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, config.Redundancy.ServiceLevelBase);
|
"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)
|
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 (config.Redundancy.Enabled)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
|
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;
|
valid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.Redundancy.ServerUris.Count < 2)
|
if (config.Redundancy.ServerUris.Count < 2)
|
||||||
{
|
Log.Warning(
|
||||||
Log.Warning("Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
|
"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))
|
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);
|
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris",
|
||||||
}
|
config.OpcUa.ApplicationUri);
|
||||||
|
|
||||||
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
|
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
|
||||||
if (mode == Opc.Ua.RedundancySupport.None)
|
if (mode == RedundancySupport.None)
|
||||||
{
|
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None",
|
||||||
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", config.Redundancy.Mode);
|
config.Redundancy.Mode);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
|
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
|
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
|
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
public int MonitorIntervalSeconds { get; set; } = 5;
|
public int MonitorIntervalSeconds { get; set; } = 5;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AutoReconnect { get; set; } = true;
|
public bool AutoReconnect { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
|
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
|
||||||
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
|
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> ServerUris { get; set; } = new List<string>();
|
public List<string> ServerUris { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the base ServiceLevel when the server is fully healthy.
|
/// Gets or sets the base ServiceLevel when the server is fully healthy.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ using System.Collections.Generic;
|
|||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SecurityProfileConfiguration
|
public class SecurityProfileConfiguration
|
||||||
{
|
{
|
||||||
@@ -12,7 +13,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
|||||||
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
|
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
|
||||||
/// Defaults to ["None"] for backward compatibility.
|
/// Defaults to ["None"] for backward compatibility.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Profiles { get; set; } = new List<string> { "None" };
|
public List<string> Profiles { get; set; } = new() { "None" };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether the server automatically accepts client certificates
|
/// Gets or sets a value indicating whether the server automatically accepts client certificates
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConnectionStateChangedEventArgs : EventArgs
|
public class ConnectionStateChangedEventArgs : EventArgs
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="previous">The connection state being exited.</param>
|
||||||
|
/// <param name="current">The connection state being entered.</param>
|
||||||
|
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
|
||||||
|
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
|
||||||
|
{
|
||||||
|
PreviousState = previous;
|
||||||
|
CurrentState = current;
|
||||||
|
Message = message ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the previous MXAccess connection state before the transition was raised.
|
/// Gets the previous MXAccess connection state before the transition was raised.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -21,18 +34,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// Gets an operator-facing message that explains why the connection state changed.
|
/// Gets an operator-facing message that explains why the connection state changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Message { get; }
|
public string Message { get; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="previous">The connection state being exited.</param>
|
|
||||||
/// <param name="current">The connection state being entered.</param>
|
|
||||||
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
|
|
||||||
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
|
|
||||||
{
|
|
||||||
PreviousState = previous;
|
|
||||||
CurrentState = current;
|
|
||||||
Message = message ?? "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
public string PrimitiveName { get; set; } = "";
|
public string PrimitiveName { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string AttributeSource { get; set; } = "";
|
public string AttributeSource { get; set; } = "";
|
||||||
|
|
||||||
@@ -62,7 +63,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
public int SecurityClassification { get; set; } = 1;
|
public int SecurityClassification { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsHistorized { get; set; }
|
public bool IsHistorized { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
ConnectionState State { get; }
|
ConnectionState State { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
|
||||||
|
/// </summary>
|
||||||
|
int ActiveSubscriptionCount { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of reconnect cycles attempted since the client was created.
|
||||||
|
/// </summary>
|
||||||
|
int ReconnectCount { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
|
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -65,15 +75,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// <param name="ct">A token that cancels the write.</param>
|
/// <param name="ct">A token that cancels the write.</param>
|
||||||
/// <returns><see langword="true" /> when the write is accepted by the runtime; otherwise, <see langword="false" />.</returns>
|
/// <returns><see langword="true" /> when the write is accepted by the runtime; otherwise, <see langword="false" />.</returns>
|
||||||
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
|
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
|
|
||||||
/// </summary>
|
|
||||||
int ActiveSubscriptionCount { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of reconnect cycles attempted since the client was created.
|
|
||||||
/// </summary>
|
|
||||||
int ReconnectCount { get; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using ArchestrA.MxAccess;
|
using ArchestrA.MxAccess;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ using System.Collections.Generic;
|
|||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IUserAuthenticationProvider
|
public interface IUserAuthenticationProvider
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.DirectoryServices.Protocols;
|
using System.DirectoryServices.Protocols;
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||||
@@ -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<string> GetUserRoles(string username)
|
public IReadOnlyList<string> GetUserRoles(string username)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -87,15 +62,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
}
|
}
|
||||||
|
|
||||||
var roles = new List<string>();
|
var roles = new List<string>();
|
||||||
for (int i = 0; i < memberOf.Count; i++)
|
for (var i = 0; i < memberOf.Count; i++)
|
||||||
{
|
{
|
||||||
var dn = memberOf[i]?.ToString() ?? "";
|
var dn = memberOf[i]?.ToString() ?? "";
|
||||||
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
|
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
|
||||||
var groupName = ExtractGroupName(dn);
|
var groupName = ExtractGroupName(dn);
|
||||||
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role))
|
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role);
|
||||||
{
|
|
||||||
roles.Add(role);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roles.Count == 0)
|
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()
|
private LdapConnection CreateConnection()
|
||||||
{
|
{
|
||||||
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
|
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
|
||||||
|
|||||||
@@ -10,30 +10,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// No valid process value is available.
|
/// No valid process value is available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Bad = 0,
|
Bad = 0,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
BadConfigError = 4,
|
BadConfigError = 4,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The bridge is not currently connected to the Galaxy runtime.
|
/// The bridge is not currently connected to the Galaxy runtime.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
BadNotConnected = 8,
|
BadNotConnected = 8,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The runtime device or adapter failed while obtaining the value.
|
/// The runtime device or adapter failed while obtaining the value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
BadDeviceFailure = 12,
|
BadDeviceFailure = 12,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The underlying field source reported a bad sensor condition.
|
/// The underlying field source reported a bad sensor condition.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
BadSensorFailure = 16,
|
BadSensorFailure = 16,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Communication with the runtime failed while retrieving the value.
|
/// Communication with the runtime failed while retrieving the value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
BadCommFailure = 20,
|
BadCommFailure = 20,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
BadOutOfService = 24,
|
BadOutOfService = 24,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -44,18 +51,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// A value is available, but it should be treated cautiously.
|
/// A value is available, but it should be treated cautiously.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Uncertain = 64,
|
Uncertain = 64,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
UncertainLastUsable = 68,
|
UncertainLastUsable = 68,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The sensor or source is providing a value with reduced accuracy.
|
/// The sensor or source is providing a value with reduced accuracy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
UncertainSensorNotAccurate = 80,
|
UncertainSensorNotAccurate = 80,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The value exceeds its engineered limits.
|
/// The value exceeds its engineered limits.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
UncertainEuExceeded = 84,
|
UncertainEuExceeded = 84,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The source is operating in a degraded or subnormal state.
|
/// The source is operating in a degraded or subnormal state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -66,6 +77,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// The value is current and suitable for normal client use.
|
/// The value is current and suitable for normal client use.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Good = 192,
|
Good = 192,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -82,20 +94,29 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="q">The quality code to inspect.</param>
|
/// <param name="q">The quality code to inspect.</param>
|
||||||
/// <returns><see langword="true" /> when the value is in the good quality range; otherwise, <see langword="false" />.</returns>
|
/// <returns><see langword="true" /> when the value is in the good quality range; otherwise, <see langword="false" />.</returns>
|
||||||
public static bool IsGood(this Quality q) => (byte)q >= 192;
|
public static bool IsGood(this Quality q)
|
||||||
|
{
|
||||||
|
return (byte)q >= 192;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="q">The quality code to inspect.</param>
|
/// <param name="q">The quality code to inspect.</param>
|
||||||
/// <returns><see langword="true" /> when the value is in the uncertain range; otherwise, <see langword="false" />.</returns>
|
/// <returns><see langword="true" /> when the value is in the uncertain range; otherwise, <see langword="false" />.</returns>
|
||||||
public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 192;
|
public static bool IsUncertain(this Quality q)
|
||||||
|
{
|
||||||
|
return (byte)q >= 64 && (byte)q < 192;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="q">The quality code to inspect.</param>
|
/// <param name="q">The quality code to inspect.</param>
|
||||||
/// <returns><see langword="true" /> when the value is in the bad range; otherwise, <see langword="false" />.</returns>
|
/// <returns><see langword="true" /> when the value is in the bad range; otherwise, <see langword="false" />.</returns>
|
||||||
public static bool IsBad(this Quality q) => (byte)q < 64;
|
public static bool IsBad(this Quality q)
|
||||||
|
{
|
||||||
|
return (byte)q < 64;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -16,7 +18,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
var b = (byte)(mxQuality & 0xFF);
|
var b = (byte)(mxQuality & 0xFF);
|
||||||
|
|
||||||
// Try exact match first
|
// Try exact match first
|
||||||
if (System.Enum.IsDefined(typeof(Quality), b))
|
if (Enum.IsDefined(typeof(Quality), b))
|
||||||
return (Quality)b;
|
return (Quality)b;
|
||||||
|
|
||||||
// Fall back to category
|
// Fall back to category
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// Determines whether an attribute with the given security classification should allow writes.
|
/// Determines whether an attribute with the given security classification should allow writes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="securityClassification">The Galaxy security classification value.</param>
|
/// <param name="securityClassification">The Galaxy security classification value.</param>
|
||||||
/// <returns><see langword="true"/> for FreeAccess (0), Operate (1), Tune (4), Configure (5);
|
/// <returns>
|
||||||
/// <see langword="false"/> for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).</returns>
|
/// <see langword="true" /> for FreeAccess (0), Operate (1), Tune (4), Configure (5);
|
||||||
|
/// <see langword="false" /> for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).
|
||||||
|
/// </returns>
|
||||||
public static bool IsWritable(int securityClassification)
|
public static bool IsWritable(int securityClassification)
|
||||||
{
|
{
|
||||||
switch (securityClassification)
|
switch (securityClassification)
|
||||||
|
|||||||
@@ -40,37 +40,57 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The runtime value to wrap.</param>
|
/// <param name="value">The runtime value to wrap.</param>
|
||||||
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
|
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
|
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
|
||||||
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
|
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The runtime value to wrap.</param>
|
/// <param name="value">The runtime value to wrap.</param>
|
||||||
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
|
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
|
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="other">The other VTQ snapshot to compare.</param>
|
/// <param name="other">The other VTQ snapshot to compare.</param>
|
||||||
/// <returns><see langword="true" /> when all fields match; otherwise, <see langword="false" />.</returns>
|
/// <returns><see langword="true" /> when all fields match; otherwise, <see langword="false" />.</returns>
|
||||||
public bool Equals(Vtq other) =>
|
public bool Equals(Vtq other)
|
||||||
Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
|
{
|
||||||
|
return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override int GetHashCode() => HashCode.Combine(Value, Timestamp, Quality);
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
return HashCode.Combine(Value, Timestamp, Quality);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override string ToString() => $"Vtq({Value}, {Timestamp:O}, {Quality})";
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Vtq({Value}, {Timestamp:O}, {Quality})";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
||||||
@@ -13,21 +12,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
|||||||
public class ChangeDetectionService : IDisposable
|
public class ChangeDetectionService : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<ChangeDetectionService>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<ChangeDetectionService>();
|
||||||
|
private readonly int _intervalSeconds;
|
||||||
|
|
||||||
private readonly IGalaxyRepository _repository;
|
private readonly IGalaxyRepository _repository;
|
||||||
private readonly int _intervalSeconds;
|
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private DateTime? _lastKnownDeployTime;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
|
|
||||||
/// </summary>
|
|
||||||
public event Action? OnGalaxyChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the last deploy timestamp observed by the polling loop.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastKnownDeployTime => _lastKnownDeployTime;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new change detector for Galaxy deploy timestamps.
|
/// Initializes a new change detector for Galaxy deploy timestamps.
|
||||||
@@ -35,13 +23,33 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
|||||||
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
|
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
|
||||||
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
|
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
|
||||||
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
|
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
|
||||||
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds, DateTime? initialDeployTime = null)
|
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds,
|
||||||
|
DateTime? initialDeployTime = null)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_intervalSeconds = intervalSeconds;
|
_intervalSeconds = intervalSeconds;
|
||||||
_lastKnownDeployTime = initialDeployTime;
|
LastKnownDeployTime = initialDeployTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last deploy timestamp observed by the polling loop.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastKnownDeployTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the polling loop and disposes the underlying cancellation resources.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
_cts?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnGalaxyChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts the background polling loop that watches for Galaxy deploy changes.
|
/// Starts the background polling loop that watches for Galaxy deploy changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -64,7 +72,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
|||||||
private async Task PollLoopAsync(CancellationToken ct)
|
private async Task PollLoopAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// If no initial deploy time was provided, first poll triggers unconditionally
|
// If no initial deploy time was provided, first poll triggers unconditionally
|
||||||
bool firstPoll = _lastKnownDeployTime == null;
|
var firstPoll = LastKnownDeployTime == null;
|
||||||
|
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -75,15 +83,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
|||||||
if (firstPoll)
|
if (firstPoll)
|
||||||
{
|
{
|
||||||
firstPoll = false;
|
firstPoll = false;
|
||||||
_lastKnownDeployTime = deployTime;
|
LastKnownDeployTime = deployTime;
|
||||||
Log.Information("Initial deploy time: {DeployTime}", deployTime);
|
Log.Information("Initial deploy time: {DeployTime}", deployTime);
|
||||||
OnGalaxyChanged?.Invoke();
|
OnGalaxyChanged?.Invoke();
|
||||||
}
|
}
|
||||||
else if (deployTime != _lastKnownDeployTime)
|
else if (deployTime != LastKnownDeployTime)
|
||||||
{
|
{
|
||||||
Log.Information("Galaxy deployment change detected: {Previous} → {Current}",
|
Log.Information("Galaxy deployment change detected: {Previous} → {Current}",
|
||||||
_lastKnownDeployTime, deployTime);
|
LastKnownDeployTime, deployTime);
|
||||||
_lastKnownDeployTime = deployTime;
|
LastKnownDeployTime = deployTime;
|
||||||
OnGalaxyChanged?.Invoke();
|
OnGalaxyChanged?.Invoke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,14 +114,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the polling loop and disposes the underlying cancellation resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
_cts?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,11 +18,179 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
|||||||
|
|
||||||
private readonly GalaxyRepositoryConfiguration _config;
|
private readonly GalaxyRepositoryConfiguration _config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
|
||||||
|
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
|
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnGalaxyChanged;
|
public event Action? OnGalaxyChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">A token that cancels the database query.</param>
|
||||||
|
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
|
||||||
|
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var results = new List<GalaxyObjectInfo>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">A token that cancels the database query.</param>
|
||||||
|
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
|
||||||
|
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var results = new List<GalaxyAttributeInfo>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">A token that cancels the database query.</param>
|
||||||
|
/// <returns>The most recent deploy timestamp, or <see langword="null" /> when none is available.</returns>
|
||||||
|
public async Task<DateTime?> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a lightweight query to confirm that the repository database is reachable.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">A token that cancels the connectivity check.</param>
|
||||||
|
/// <returns><see langword="true" /> when the query succeeds; otherwise, <see langword="false" />.</returns>
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
|
||||||
|
/// </summary>
|
||||||
|
public void RaiseGalaxyChanged()
|
||||||
|
{
|
||||||
|
OnGalaxyChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
#region SQL Queries (GR-006: const string, no dynamic SQL)
|
#region SQL Queries (GR-006: const string, no dynamic SQL)
|
||||||
|
|
||||||
private const string HierarchySql = @"
|
private const string HierarchySql = @"
|
||||||
@@ -263,172 +431,5 @@ ORDER BY tag_name, primitive_name, attribute_name";
|
|||||||
private const string TestConnectionSql = "SELECT 1";
|
private const string TestConnectionSql = "SELECT 1";
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
|
|
||||||
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the database query.</param>
|
|
||||||
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
|
|
||||||
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<GalaxyObjectInfo>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the database query.</param>
|
|
||||||
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
|
|
||||||
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<GalaxyAttributeInfo>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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
|
|
||||||
/// </summary>
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the database query.</param>
|
|
||||||
/// <returns>The most recent deploy timestamp, or <see langword="null"/> when none is available.</returns>
|
|
||||||
public async Task<DateTime?> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes a lightweight query to confirm that the repository database is reachable.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the connectivity check.</param>
|
|
||||||
/// <returns><see langword="true"/> when the query succeeds; otherwise, <see langword="false"/>.</returns>
|
|
||||||
public async Task<bool> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
|
|
||||||
/// </summary>
|
|
||||||
public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,13 +66,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class OperationMetrics
|
public class OperationMetrics
|
||||||
{
|
{
|
||||||
private readonly List<double> _durations = new List<double>();
|
private readonly List<double> _durations = new();
|
||||||
private readonly object _lock = new object();
|
private readonly object _lock = new();
|
||||||
private long _totalCount;
|
|
||||||
private long _successCount;
|
|
||||||
private double _totalMilliseconds;
|
|
||||||
private double _minMilliseconds = double.MaxValue;
|
|
||||||
private double _maxMilliseconds;
|
private double _maxMilliseconds;
|
||||||
|
private double _minMilliseconds = double.MaxValue;
|
||||||
|
private long _successCount;
|
||||||
|
private long _totalCount;
|
||||||
|
private double _totalMilliseconds;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records the outcome and duration of a single bridge operation invocation.
|
/// Records the outcome and duration of a single bridge operation invocation.
|
||||||
@@ -132,8 +132,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
|
|||||||
{
|
{
|
||||||
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
|
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics
|
private readonly ConcurrentDictionary<string, OperationMetrics>
|
||||||
= new ConcurrentDictionary<string, OperationMetrics>(StringComparer.OrdinalIgnoreCase);
|
_metrics = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private readonly Timer _reportingTimer;
|
private readonly Timer _reportingTimer;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -147,6 +147,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
|
|||||||
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops periodic reporting and emits a final metrics snapshot.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_reportingTimer.Dispose();
|
||||||
|
ReportMetrics(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a completed bridge operation under the specified metrics bucket.
|
/// Records a completed bridge operation under the specified metrics bucket.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -207,17 +218,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops periodic reporting and emits a final metrics snapshot.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
_disposed = true;
|
|
||||||
_reportingTimer.Dispose();
|
|
||||||
ReportMetrics(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Timing scope that records one operation result into the owning metrics collector.
|
/// Timing scope that records one operation result into the owning metrics collector.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -226,8 +226,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
|
|||||||
private readonly PerformanceMetrics _metrics;
|
private readonly PerformanceMetrics _metrics;
|
||||||
private readonly string _operationName;
|
private readonly string _operationName;
|
||||||
private readonly Stopwatch _stopwatch;
|
private readonly Stopwatch _stopwatch;
|
||||||
private bool _success = true;
|
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
private bool _success = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a timing scope for a named bridge operation.
|
/// Initializes a timing scope for a named bridge operation.
|
||||||
@@ -245,7 +245,10 @@ 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="success">A value indicating whether the operation succeeded.</param>
|
/// <param name="success">A value indicating whether the operation succeeded.</param>
|
||||||
public void SetSuccess(bool success) => _success = success;
|
public void SetSuccess(bool success)
|
||||||
|
{
|
||||||
|
_success = success;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stops timing and records the operation result once.
|
/// Stops timing and records the operation result once.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
||||||
@@ -72,7 +70,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
{
|
{
|
||||||
// UnAdvise + RemoveItem for all active subscriptions
|
// UnAdvise + RemoveItem for all active subscriptions
|
||||||
foreach (var kvp in _addressToHandle)
|
foreach (var kvp in _addressToHandle)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value);
|
_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);
|
Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Unwire events before unregister
|
// Unwire events before unregister
|
||||||
DetachProxyEvents();
|
DetachProxyEvents();
|
||||||
|
|
||||||
// Unregister
|
// Unregister
|
||||||
try { _proxy.Unregister(_connectionHandle); }
|
try
|
||||||
catch (Exception ex) { Log.Warning(ex, "Error during Unregister"); }
|
{
|
||||||
|
_proxy.Unregister(_connectionHandle);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error during Unregister");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_handleToAddress.Clear();
|
_handleToAddress.Clear();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using ArchestrA.MxAccess;
|
using ArchestrA.MxAccess;
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
||||||
@@ -31,30 +30,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
|
|
||||||
// Check MXSTATUS_PROXY — if success is false, use more specific quality
|
// Check MXSTATUS_PROXY — if success is false, use more specific quality
|
||||||
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
|
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
|
||||||
{
|
|
||||||
quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail);
|
quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail);
|
||||||
}
|
|
||||||
|
|
||||||
var timestamp = ConvertTimestamp(pftItemTimeStamp);
|
var timestamp = ConvertTimestamp(pftItemTimeStamp);
|
||||||
var vtq = new Vtq(pvItemValue, timestamp, quality);
|
var vtq = new Vtq(pvItemValue, timestamp, quality);
|
||||||
|
|
||||||
// Update probe timestamp
|
// Update probe timestamp
|
||||||
if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
|
||||||
_lastProbeValueTime = DateTime.UtcNow;
|
_lastProbeValueTime = DateTime.UtcNow;
|
||||||
}
|
|
||||||
|
|
||||||
// Invoke stored subscription callback
|
// Invoke stored subscription callback
|
||||||
if (_storedSubscriptions.TryGetValue(address, out var callback))
|
if (_storedSubscriptions.TryGetValue(address, out var callback)) callback(address, vtq);
|
||||||
{
|
|
||||||
callback(address, vtq);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads))
|
if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads))
|
||||||
{
|
|
||||||
foreach (var pendingRead in pendingReads.Values)
|
foreach (var pendingRead in pendingReads.Values)
|
||||||
pendingRead.TrySetResult(vtq);
|
pendingRead.TrySetResult(vtq);
|
||||||
}
|
|
||||||
|
|
||||||
// Global handler
|
// Global handler
|
||||||
OnTagValueChanged?.Invoke(address, vtq);
|
OnTagValueChanged?.Invoke(address, vtq);
|
||||||
@@ -77,7 +67,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
{
|
{
|
||||||
if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
|
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)
|
if (success)
|
||||||
{
|
{
|
||||||
tcs.TrySetResult(true);
|
tcs.TrySetResult(true);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
||||||
@@ -41,7 +40,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
|
|
||||||
try
|
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);
|
Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state);
|
||||||
await ReconnectAsync();
|
await ReconnectAsync();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
||||||
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
});
|
});
|
||||||
|
|
||||||
var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
|
var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
|
||||||
_ => new System.Collections.Concurrent.ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
|
_ => new ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
|
||||||
pendingReads[itemHandle] = tcs;
|
pendingReads[itemHandle] = tcs;
|
||||||
_handleToAddress[itemHandle] = fullTagReference;
|
_handleToAddress[itemHandle] = fullTagReference;
|
||||||
|
|
||||||
@@ -121,7 +121,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds));
|
cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds));
|
||||||
cts.Token.Register(() =>
|
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);
|
tcs.TrySetResult(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
||||||
@@ -38,7 +37,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
_handleToAddress.TryRemove(itemHandle, out _);
|
_handleToAddress.TryRemove(itemHandle, out _);
|
||||||
|
|
||||||
if (_state == ConnectionState.Connected)
|
if (_state == ConnectionState.Connected)
|
||||||
{
|
|
||||||
await _staThread.RunAsync(() =>
|
await _staThread.RunAsync(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -53,7 +51,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubscribeInternalAsync(string address)
|
private async Task SubscribeInternalAsync(string address)
|
||||||
{
|
{
|
||||||
@@ -95,7 +92,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
private async Task ReplayStoredSubscriptionsAsync()
|
private async Task ReplayStoredSubscriptionsAsync()
|
||||||
{
|
{
|
||||||
foreach (var kvp in _storedSubscriptions)
|
foreach (var kvp in _storedSubscriptions)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SubscribeInternalAsync(kvp.Key);
|
await SubscribeInternalAsync(kvp.Key);
|
||||||
@@ -104,7 +100,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key);
|
Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count);
|
Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -18,36 +17,55 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
public sealed partial class MxAccessClient : IMxAccessClient
|
public sealed partial class MxAccessClient : IMxAccessClient
|
||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
||||||
|
private readonly ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly StaComThread _staThread;
|
|
||||||
private readonly IMxProxy _proxy;
|
|
||||||
private readonly MxAccessConfiguration _config;
|
private readonly MxAccessConfiguration _config;
|
||||||
|
|
||||||
|
// Handle mappings
|
||||||
|
private readonly ConcurrentDictionary<int, string> _handleToAddress = new();
|
||||||
private readonly PerformanceMetrics _metrics;
|
private readonly PerformanceMetrics _metrics;
|
||||||
private readonly SemaphoreSlim _operationSemaphore;
|
private readonly SemaphoreSlim _operationSemaphore;
|
||||||
|
|
||||||
private int _connectionHandle;
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>
|
||||||
private volatile ConnectionState _state = ConnectionState.Disconnected;
|
_pendingReadsByAddress
|
||||||
private bool _proxyEventsAttached;
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
private CancellationTokenSource? _monitorCts;
|
|
||||||
|
|
||||||
// Handle mappings
|
// Pending writes
|
||||||
private readonly ConcurrentDictionary<int, string> _handleToAddress = new ConcurrentDictionary<int, string>();
|
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites = new();
|
||||||
private readonly ConcurrentDictionary<string, int> _addressToHandle = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
private readonly IMxProxy _proxy;
|
||||||
|
|
||||||
|
private readonly StaComThread _staThread;
|
||||||
|
|
||||||
// Subscription storage
|
// Subscription storage
|
||||||
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _storedSubscriptions
|
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _storedSubscriptions
|
||||||
= new ConcurrentDictionary<string, Action<string, Vtq>>(StringComparer.OrdinalIgnoreCase);
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Pending writes
|
private int _connectionHandle;
|
||||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites
|
private DateTime _lastProbeValueTime = DateTime.UtcNow;
|
||||||
= new ConcurrentDictionary<int, TaskCompletionSource<bool>>();
|
private CancellationTokenSource? _monitorCts;
|
||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>> _pendingReadsByAddress
|
|
||||||
= new ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Probe
|
// Probe
|
||||||
private string? _probeTag;
|
private string? _probeTag;
|
||||||
private DateTime _lastProbeValueTime = DateTime.UtcNow;
|
private bool _proxyEventsAttached;
|
||||||
private int _reconnectCount;
|
private int _reconnectCount;
|
||||||
|
private volatile ConnectionState _state = ConnectionState.Disconnected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
|
||||||
|
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
|
||||||
|
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
|
||||||
|
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
|
||||||
|
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config,
|
||||||
|
PerformanceMetrics metrics)
|
||||||
|
{
|
||||||
|
_staThread = staThread;
|
||||||
|
_proxy = proxy;
|
||||||
|
_config = config;
|
||||||
|
_metrics = metrics;
|
||||||
|
_operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current runtime connection state for the MXAccess client.
|
/// Gets the current runtime connection state for the MXAccess client.
|
||||||
@@ -74,31 +92,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, Vtq>? OnTagValueChanged;
|
public event Action<string, Vtq>? OnTagValueChanged;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
|
|
||||||
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
|
|
||||||
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
|
|
||||||
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
|
|
||||||
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config, PerformanceMetrics metrics)
|
|
||||||
{
|
|
||||||
_staThread = staThread;
|
|
||||||
_proxy = proxy;
|
|
||||||
_config = config;
|
|
||||||
_metrics = metrics;
|
|
||||||
_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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
|
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -119,5 +112,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
_monitorCts?.Dispose();
|
_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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
public void Unregister(int handle)
|
public void Unregister(int handle)
|
||||||
{
|
{
|
||||||
if (_lmxProxy != null)
|
if (_lmxProxy != null)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_lmxProxy.OnDataChange -= ProxyOnDataChange;
|
_lmxProxy.OnDataChange -= ProxyOnDataChange;
|
||||||
@@ -62,7 +61,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
_lmxProxy = null;
|
_lmxProxy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
@@ -70,28 +68,40 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
/// <param name="handle">The runtime connection handle.</param>
|
/// <param name="handle">The runtime connection handle.</param>
|
||||||
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
|
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
|
||||||
/// <returns>The item handle assigned by the COM proxy.</returns>
|
/// <returns>The item handle assigned by the COM proxy.</returns>
|
||||||
public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address);
|
public int AddItem(int handle, string address)
|
||||||
|
{
|
||||||
|
return _lmxProxy!.AddItem(handle, address);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes an item handle from the active COM proxy session.
|
/// Removes an item handle from the active COM proxy session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
/// <param name="handle">The runtime connection handle.</param>
|
||||||
/// <param name="itemHandle">The item handle to remove.</param>
|
/// <param name="itemHandle">The item handle to remove.</param>
|
||||||
public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle);
|
public void RemoveItem(int handle, int itemHandle)
|
||||||
|
{
|
||||||
|
_lmxProxy!.RemoveItem(handle, itemHandle);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enables supervisory callbacks for the specified runtime item.
|
/// Enables supervisory callbacks for the specified runtime item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
/// <param name="handle">The runtime connection handle.</param>
|
||||||
/// <param name="itemHandle">The item handle to monitor.</param>
|
/// <param name="itemHandle">The item handle to monitor.</param>
|
||||||
public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle);
|
public void AdviseSupervisory(int handle, int itemHandle)
|
||||||
|
{
|
||||||
|
_lmxProxy!.AdviseSupervisory(handle, itemHandle);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disables supervisory callbacks for the specified runtime item.
|
/// Disables supervisory callbacks for the specified runtime item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
/// <param name="handle">The runtime connection handle.</param>
|
||||||
/// <param name="itemHandle">The item handle to stop monitoring.</param>
|
/// <param name="itemHandle">The item handle to stop monitoring.</param>
|
||||||
public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle);
|
public void UnAdviseSupervisory(int handle, int itemHandle)
|
||||||
|
{
|
||||||
|
_lmxProxy!.UnAdvise(handle, itemHandle);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes a value to the specified runtime item through the COM proxy.
|
/// Writes a value to the specified runtime item through the COM proxy.
|
||||||
@@ -101,12 +111,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
/// <param name="value">The value to send to the runtime.</param>
|
/// <param name="value">The value to send to the runtime.</param>
|
||||||
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
|
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
|
||||||
public void Write(int handle, int itemHandle, object value, int securityClassification)
|
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,
|
private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
|
||||||
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
|
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)
|
private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
|
||||||
|
|||||||
@@ -18,18 +18,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
|
||||||
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
|
||||||
|
private readonly TaskCompletionSource<bool> _ready = new();
|
||||||
|
|
||||||
private readonly Thread _thread;
|
private readonly Thread _thread;
|
||||||
private readonly TaskCompletionSource<bool> _ready = new TaskCompletionSource<bool>();
|
private readonly ConcurrentQueue<Action> _workItems = new();
|
||||||
private readonly ConcurrentQueue<Action> _workItems = new ConcurrentQueue<Action>();
|
|
||||||
private volatile uint _nativeThreadId;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
private long _totalMessages;
|
|
||||||
private long _appMessages;
|
private long _appMessages;
|
||||||
private long _dispatchedMessages;
|
private long _dispatchedMessages;
|
||||||
private long _workItemsExecuted;
|
private bool _disposed;
|
||||||
private DateTime _lastLogTime;
|
private DateTime _lastLogTime;
|
||||||
|
private volatile uint _nativeThreadId;
|
||||||
|
|
||||||
|
private long _totalMessages;
|
||||||
|
private long _workItemsExecuted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
|
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
|
||||||
@@ -49,6 +49,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsRunning => _nativeThreadId != 0 && !_disposed;
|
public bool IsRunning => _nativeThreadId != 0 && !_disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the STA thread and releases the message-pump resources used for COM interop.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts the STA thread and waits until its message pump is ready for COM work.
|
/// Starts the STA thread and waits until its message pump is ready for COM work.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -111,28 +133,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
return tcs.Task;
|
return tcs.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the STA thread and releases the message-pump resources used for COM interop.
|
|
||||||
/// </summary>
|
|
||||||
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()
|
private void ThreadEntry()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -171,7 +171,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
LogPumpStatsIfDue();
|
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);
|
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -186,8 +187,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
while (_workItems.TryDequeue(out var workItem))
|
while (_workItems.TryDequeue(out var workItem))
|
||||||
{
|
{
|
||||||
_workItemsExecuted++;
|
_workItemsExecuted++;
|
||||||
try { workItem(); }
|
try
|
||||||
catch (Exception ex) { Log.Error(ex, "Unhandled exception in STA work item"); }
|
{
|
||||||
|
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;
|
var now = DateTime.UtcNow;
|
||||||
if (now - _lastLogTime < PumpLogInterval) return;
|
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);
|
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
|
||||||
_lastLogTime = now;
|
_lastLogTime = now;
|
||||||
}
|
}
|
||||||
@@ -239,7 +247,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[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")]
|
[DllImport("kernel32.dll")]
|
||||||
private static extern uint GetCurrentThreadId();
|
private static extern uint GetCurrentThreadId();
|
||||||
|
|||||||
@@ -14,6 +14,93 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<AddressSpaceBuilder>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<AddressSpaceBuilder>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes
|
||||||
|
/// nodes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
|
||||||
|
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
|
||||||
|
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
|
||||||
|
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||||
|
{
|
||||||
|
var model = new AddressSpaceModel();
|
||||||
|
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
||||||
|
|
||||||
|
var attrsByObject = attributes
|
||||||
|
.GroupBy(a => a.GobjectId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Build parent→children map
|
||||||
|
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Find root objects (parent not in hierarchy)
|
||||||
|
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
|
||||||
|
|
||||||
|
foreach (var obj in hierarchy)
|
||||||
|
{
|
||||||
|
var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
|
||||||
|
|
||||||
|
if (!knownIds.Contains(obj.ParentGobjectId))
|
||||||
|
model.RootNodes.Add(nodeInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
|
||||||
|
model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
|
||||||
|
Dictionary<int, List<GalaxyAttributeInfo>> attrsByObject,
|
||||||
|
Dictionary<int, List<GalaxyObjectInfo>> childrenByParent,
|
||||||
|
AddressSpaceModel model)
|
||||||
|
{
|
||||||
|
var node = new NodeInfo
|
||||||
|
{
|
||||||
|
GobjectId = obj.GobjectId,
|
||||||
|
TagName = obj.TagName,
|
||||||
|
BrowseName = obj.BrowseName,
|
||||||
|
ParentGobjectId = obj.ParentGobjectId,
|
||||||
|
IsArea = obj.IsArea
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!obj.IsArea)
|
||||||
|
model.ObjectCount++;
|
||||||
|
|
||||||
|
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
|
||||||
|
foreach (var attr in attrs)
|
||||||
|
{
|
||||||
|
node.Attributes.Add(new AttributeNodeInfo
|
||||||
|
{
|
||||||
|
AttributeName = attr.AttributeName,
|
||||||
|
FullTagReference = attr.FullTagReference,
|
||||||
|
MxDataType = attr.MxDataType,
|
||||||
|
IsArray = attr.IsArray,
|
||||||
|
ArrayDimension = attr.ArrayDimension,
|
||||||
|
PrimitiveName = attr.PrimitiveName ?? "",
|
||||||
|
SecurityClassification = attr.SecurityClassification,
|
||||||
|
IsHistorized = attr.IsHistorized,
|
||||||
|
IsAlarm = attr.IsAlarm
|
||||||
|
});
|
||||||
|
|
||||||
|
model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
|
||||||
|
model.VariableCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
|
||||||
|
{
|
||||||
|
if (!attr.IsArray)
|
||||||
|
return attr.FullTagReference;
|
||||||
|
|
||||||
|
return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
|
||||||
|
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
|
||||||
|
: attr.FullTagReference;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Node info for the address space tree.
|
/// Node info for the address space tree.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -120,7 +207,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
|
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, string> NodeIdToTagReference { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, string> NodeIdToTagReference { get; set; } =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the number of non-area Galaxy objects included in the model.
|
/// Gets or sets the number of non-area Galaxy objects included in the model.
|
||||||
@@ -132,93 +220,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int VariableCount { get; set; }
|
public int VariableCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes nodes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
|
|
||||||
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
|
|
||||||
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
|
|
||||||
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
|
||||||
{
|
|
||||||
var model = new AddressSpaceModel();
|
|
||||||
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
|
||||||
|
|
||||||
var attrsByObject = attributes
|
|
||||||
.GroupBy(a => a.GobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
|
|
||||||
// Build parent→children map
|
|
||||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
|
|
||||||
// Find root objects (parent not in hierarchy)
|
|
||||||
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
|
|
||||||
|
|
||||||
foreach (var obj in hierarchy)
|
|
||||||
{
|
|
||||||
var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
|
|
||||||
|
|
||||||
if (!knownIds.Contains(obj.ParentGobjectId))
|
|
||||||
model.RootNodes.Add(nodeInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
|
|
||||||
model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
|
|
||||||
Dictionary<int, List<GalaxyAttributeInfo>> attrsByObject,
|
|
||||||
Dictionary<int, List<GalaxyObjectInfo>> childrenByParent,
|
|
||||||
AddressSpaceModel model)
|
|
||||||
{
|
|
||||||
var node = new NodeInfo
|
|
||||||
{
|
|
||||||
GobjectId = obj.GobjectId,
|
|
||||||
TagName = obj.TagName,
|
|
||||||
BrowseName = obj.BrowseName,
|
|
||||||
ParentGobjectId = obj.ParentGobjectId,
|
|
||||||
IsArea = obj.IsArea
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!obj.IsArea)
|
|
||||||
model.ObjectCount++;
|
|
||||||
|
|
||||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
|
|
||||||
{
|
|
||||||
foreach (var attr in attrs)
|
|
||||||
{
|
|
||||||
node.Attributes.Add(new AttributeNodeInfo
|
|
||||||
{
|
|
||||||
AttributeName = attr.AttributeName,
|
|
||||||
FullTagReference = attr.FullTagReference,
|
|
||||||
MxDataType = attr.MxDataType,
|
|
||||||
IsArray = attr.IsArray,
|
|
||||||
ArrayDimension = attr.ArrayDimension,
|
|
||||||
PrimitiveName = attr.PrimitiveName ?? "",
|
|
||||||
SecurityClassification = attr.SecurityClassification,
|
|
||||||
IsHistorized = attr.IsHistorized,
|
|
||||||
IsAlarm = attr.IsAlarm
|
|
||||||
});
|
|
||||||
|
|
||||||
model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
|
|
||||||
model.VariableCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
|
|
||||||
{
|
|
||||||
if (!attr.IsArray)
|
|
||||||
return attr.FullTagReference;
|
|
||||||
|
|
||||||
return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
|
|
||||||
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
|
|
||||||
: attr.FullTagReference;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,24 +27,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
// Added objects
|
// Added objects
|
||||||
foreach (var id in newObjects.Keys)
|
foreach (var id in newObjects.Keys)
|
||||||
{
|
|
||||||
if (!oldObjects.ContainsKey(id))
|
if (!oldObjects.ContainsKey(id))
|
||||||
changed.Add(id);
|
changed.Add(id);
|
||||||
}
|
|
||||||
|
|
||||||
// Removed objects
|
// Removed objects
|
||||||
foreach (var id in oldObjects.Keys)
|
foreach (var id in oldObjects.Keys)
|
||||||
{
|
|
||||||
if (!newObjects.ContainsKey(id))
|
if (!newObjects.ContainsKey(id))
|
||||||
changed.Add(id);
|
changed.Add(id);
|
||||||
}
|
|
||||||
|
|
||||||
// Modified objects
|
// Modified objects
|
||||||
foreach (var kvp in newObjects)
|
foreach (var kvp in newObjects)
|
||||||
{
|
|
||||||
if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
|
if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
|
||||||
changed.Add(kvp.Key);
|
changed.Add(kvp.Key);
|
||||||
}
|
|
||||||
|
|
||||||
// Attribute changes — group by gobject_id and compare
|
// Attribute changes — group by gobject_id and compare
|
||||||
var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
|
var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
|
||||||
@@ -88,14 +82,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
var id = queue.Dequeue();
|
var id = queue.Dequeue();
|
||||||
if (childrenByParent.TryGetValue(id, out var children))
|
if (childrenByParent.TryGetValue(id, out var children))
|
||||||
{
|
|
||||||
foreach (var childId in children)
|
foreach (var childId in children)
|
||||||
{
|
|
||||||
if (expanded.Add(childId))
|
if (expanded.Add(childId))
|
||||||
queue.Enqueue(childId);
|
queue.Enqueue(childId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return expanded;
|
return expanded;
|
||||||
}
|
}
|
||||||
@@ -119,11 +109,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
||||||
var sortedB = b.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]))
|
if (!AttributesEqual(sortedA[i], sortedB[i]))
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
Value = ConvertToOpcUaValue(vtq.Value),
|
Value = ConvertToOpcUaValue(vtq.Value),
|
||||||
StatusCode = statusCode,
|
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
|
ServerTimestamp = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,170 +19,54 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
public class LmxNodeManager : CustomNodeManager2
|
public class LmxNodeManager : CustomNodeManager2
|
||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxNodeManager>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<LmxNodeManager>();
|
||||||
|
private readonly Dictionary<string, AlarmInfo> _alarmAckedTags = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly NodeId? _alarmAckRoleId;
|
||||||
|
|
||||||
private readonly IMxAccessClient _mxAccessClient;
|
// Alarm tracking: maps InAlarm tag reference → alarm source info
|
||||||
private readonly PerformanceMetrics _metrics;
|
private readonly Dictionary<string, AlarmInfo> _alarmInAlarmTags = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly HistorianDataSource? _historianDataSource;
|
|
||||||
private readonly bool _alarmTrackingEnabled;
|
private readonly bool _alarmTrackingEnabled;
|
||||||
private readonly bool _anonymousCanWrite;
|
private readonly bool _anonymousCanWrite;
|
||||||
private readonly NodeId? _writeOperateRoleId;
|
private readonly AutoResetEvent _dataChangeSignal = new(false);
|
||||||
private readonly NodeId? _writeTuneRoleId;
|
private readonly Dictionary<int, List<string>> _gobjectToTagRefs = new();
|
||||||
private readonly NodeId? _writeConfigureRoleId;
|
private readonly HistorianDataSource? _historianDataSource;
|
||||||
private readonly NodeId? _alarmAckRoleId;
|
private readonly PerformanceMetrics _metrics;
|
||||||
|
|
||||||
|
private readonly IMxAccessClient _mxAccessClient;
|
||||||
private readonly string _namespaceUri;
|
private readonly string _namespaceUri;
|
||||||
|
|
||||||
// NodeId → full_tag_reference for read/write resolution
|
// NodeId → full_tag_reference for read/write resolution
|
||||||
private readonly Dictionary<string, string> _nodeIdToTagReference = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, string> _nodeIdToTagReference = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Ref-counted MXAccess subscriptions
|
// Incremental sync: persistent node map and reverse lookup
|
||||||
private readonly Dictionary<string, int> _subscriptionRefCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<int, NodeState> _nodeMap = new();
|
||||||
private readonly Dictionary<string, BaseDataVariableState> _tagToVariableNode = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private readonly Dictionary<string, TagMetadata> _tagMetadata = new Dictionary<string, TagMetadata>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
|
|
||||||
|
|
||||||
// Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock
|
// Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock
|
||||||
private readonly ConcurrentDictionary<string, Vtq> _pendingDataChanges = new ConcurrentDictionary<string, Vtq>(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, Vtq> _pendingDataChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly AutoResetEvent _dataChangeSignal = new AutoResetEvent(false);
|
|
||||||
private Thread? _dispatchThread;
|
// Ref-counted MXAccess subscriptions
|
||||||
private volatile bool _dispatchRunning;
|
private readonly Dictionary<string, int> _subscriptionRefCounts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, TagMetadata> _tagMetadata = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private readonly Dictionary<string, BaseDataVariableState> _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 _dispatchDisposed;
|
||||||
|
private volatile bool _dispatchRunning;
|
||||||
|
private Thread? _dispatchThread;
|
||||||
|
|
||||||
|
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
|
||||||
|
private List<GalaxyAttributeInfo>? _lastAttributes;
|
||||||
|
private List<GalaxyObjectInfo>? _lastHierarchy;
|
||||||
|
private DateTime _lastMetricsReportTime = DateTime.UtcNow;
|
||||||
|
private long _lastReportedMxChangeEvents;
|
||||||
|
private long _totalDispatchBatchSize;
|
||||||
|
|
||||||
// Dispatch queue metrics
|
// Dispatch queue metrics
|
||||||
private long _totalMxChangeEvents;
|
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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants.
|
|
||||||
/// </summary>
|
|
||||||
public int MxDataType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsArray { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array.
|
|
||||||
/// </summary>
|
|
||||||
public int? ArrayDimension { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public int SecurityClassification { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alarm tracking: maps InAlarm tag reference → alarm source info
|
|
||||||
private readonly Dictionary<string, AlarmInfo> _alarmInAlarmTags = new Dictionary<string, AlarmInfo>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private readonly Dictionary<string, AlarmInfo> _alarmAckedTags = new Dictionary<string, AlarmInfo>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Incremental sync: persistent node map and reverse lookup
|
|
||||||
private readonly Dictionary<int, NodeState> _nodeMap = new Dictionary<int, NodeState>();
|
|
||||||
private readonly Dictionary<int, List<string>> _gobjectToTagRefs = new Dictionary<int, List<string>>();
|
|
||||||
private List<GalaxyObjectInfo>? _lastHierarchy;
|
|
||||||
private List<GalaxyAttributeInfo>? _lastAttributes;
|
|
||||||
|
|
||||||
private sealed class AlarmInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the full tag reference for the process value whose alarm state is tracked.
|
|
||||||
/// </summary>
|
|
||||||
public string SourceTagReference { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition.
|
|
||||||
/// </summary>
|
|
||||||
public NodeId SourceNodeId { get; set; } = NodeId.Null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the operator-facing source name used in generated alarm events.
|
|
||||||
/// </summary>
|
|
||||||
public string SourceName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued.
|
|
||||||
/// </summary>
|
|
||||||
public bool LastInAlarm { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the retained OPC UA condition node associated with the source alarm.
|
|
||||||
/// </summary>
|
|
||||||
public AlarmConditionState? ConditionNode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates.
|
|
||||||
/// </summary>
|
|
||||||
public string PriorityTagReference { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text.
|
|
||||||
/// </summary>
|
|
||||||
public string DescAttrNameTagReference { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the cached OPC UA severity derived from the latest alarm priority value.
|
|
||||||
/// </summary>
|
|
||||||
public ushort CachedSeverity { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the cached alarm message used when emitting active and cleared events.
|
|
||||||
/// </summary>
|
|
||||||
public string CachedMessage { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy tag reference for the alarm acknowledged state.
|
|
||||||
/// </summary>
|
|
||||||
public string AckedTagReference { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment.
|
|
||||||
/// </summary>
|
|
||||||
public string AckMsgTagReference { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyDictionary<string, string> NodeIdToTagReference => _nodeIdToTagReference;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of variable nodes currently published from Galaxy attributes.
|
|
||||||
/// </summary>
|
|
||||||
public int VariableNodeCount { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of non-area object nodes currently published from the Galaxy hierarchy.
|
|
||||||
/// </summary>
|
|
||||||
public int ObjectNodeCount { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of MXAccess data change events received since startup.
|
|
||||||
/// </summary>
|
|
||||||
public long TotalMxChangeEvents => Interlocked.Read(ref _totalMxChangeEvents);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of items currently waiting in the dispatch queue.
|
|
||||||
/// </summary>
|
|
||||||
public int PendingDataChangeCount => _pendingDataChanges.Count;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the most recently computed MXAccess data change events per second.
|
|
||||||
/// </summary>
|
|
||||||
public double MxChangeEventsPerSecond => _lastEventsPerSecond;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the most recently computed average dispatch batch size (proxy for queue depth under load).
|
|
||||||
/// </summary>
|
|
||||||
public double AverageDispatchBatchSize => _lastAvgBatchSize;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new node manager for the Galaxy-backed OPC UA namespace.
|
/// Initializes a new node manager for the Galaxy-backed OPC UA namespace.
|
||||||
@@ -227,6 +111,41 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
StartDispatchThread();
|
StartDispatchThread();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, string> NodeIdToTagReference => _nodeIdToTagReference;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of variable nodes currently published from Galaxy attributes.
|
||||||
|
/// </summary>
|
||||||
|
public int VariableNodeCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of non-area object nodes currently published from the Galaxy hierarchy.
|
||||||
|
/// </summary>
|
||||||
|
public int ObjectNodeCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the total number of MXAccess data change events received since startup.
|
||||||
|
/// </summary>
|
||||||
|
public long TotalMxChangeEvents => Interlocked.Read(ref _totalMxChangeEvents);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of items currently waiting in the dispatch queue.
|
||||||
|
/// </summary>
|
||||||
|
public int PendingDataChangeCount => _pendingDataChanges.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the most recently computed MXAccess data change events per second.
|
||||||
|
/// </summary>
|
||||||
|
public double MxChangeEventsPerSecond { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the most recently computed average dispatch batch size (proxy for queue depth under load).
|
||||||
|
/// </summary>
|
||||||
|
public double AverageDispatchBatchSize { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||||||
{
|
{
|
||||||
@@ -324,21 +243,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Track variable nodes created for direct attributes that also have primitive children
|
// Track variable nodes created for direct attributes that also have primitive children
|
||||||
var variableNodes = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
var variableNodes =
|
||||||
|
new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// First pass: create direct (root-level) attribute variables
|
// First pass: create direct (root-level) attribute variables
|
||||||
var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key));
|
var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key));
|
||||||
if (directGroup != null)
|
if (directGroup != null)
|
||||||
{
|
|
||||||
foreach (var attr in directGroup)
|
foreach (var attr in directGroup)
|
||||||
{
|
{
|
||||||
var variable = CreateAttributeVariable(node, attr);
|
var variable = CreateAttributeVariable(node, attr);
|
||||||
if (primitiveGroupNames.Contains(attr.AttributeName))
|
if (primitiveGroupNames.Contains(attr.AttributeName))
|
||||||
{
|
|
||||||
variableNodes[attr.AttributeName] = variable;
|
variableNodes[attr.AttributeName] = variable;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: add primitive child attributes under the matching variable node
|
// Second pass: add primitive child attributes under the matching variable node
|
||||||
foreach (var group in byPrimitive)
|
foreach (var group in byPrimitive)
|
||||||
@@ -361,22 +277,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
parentForAttrs = primNode;
|
parentForAttrs = primNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var attr in group)
|
foreach (var attr in group) CreateAttributeVariable(parentForAttrs, attr);
|
||||||
{
|
|
||||||
CreateAttributeVariable(parentForAttrs, attr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build alarm tracking: create AlarmConditionState for each alarm attribute
|
// Build alarm tracking: create AlarmConditionState for each alarm attribute
|
||||||
if (_alarmTrackingEnabled) foreach (var obj in sorted)
|
if (_alarmTrackingEnabled)
|
||||||
|
foreach (var obj in sorted)
|
||||||
{
|
{
|
||||||
if (obj.IsArea) continue;
|
if (obj.IsArea) continue;
|
||||||
if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue;
|
if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue;
|
||||||
|
|
||||||
var hasAlarms = false;
|
var hasAlarms = false;
|
||||||
var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList();
|
var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName))
|
||||||
|
.ToList();
|
||||||
foreach (var alarmAttr in alarmAttrs)
|
foreach (var alarmAttr in alarmAttrs)
|
||||||
{
|
{
|
||||||
var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm";
|
var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm";
|
||||||
@@ -450,7 +365,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
_lastHierarchy = new List<GalaxyObjectInfo>(hierarchy);
|
_lastHierarchy = new List<GalaxyObjectInfo>(hierarchy);
|
||||||
_lastAttributes = new List<GalaxyAttributeInfo>(attributes);
|
_lastAttributes = new List<GalaxyAttributeInfo>(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);
|
ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count, _alarmInAlarmTags.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,7 +376,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
foreach (var kvp in _alarmInAlarmTags)
|
foreach (var kvp in _alarmInAlarmTags)
|
||||||
{
|
{
|
||||||
// Subscribe to InAlarm, Priority, and DescAttrName for each alarm
|
// 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)
|
foreach (var tag in tagsToSubscribe)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag))
|
if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag))
|
||||||
@@ -510,9 +430,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
if (condition == null)
|
if (condition == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
ushort severity = info.CachedSeverity;
|
var severity = info.CachedSeverity;
|
||||||
string message = active
|
var message = active
|
||||||
? (!string.IsNullOrEmpty(info.CachedMessage) ? info.CachedMessage : $"Alarm active: {info.SourceName}")
|
? !string.IsNullOrEmpty(info.CachedMessage) ? info.CachedMessage : $"Alarm active: {info.SourceName}"
|
||||||
: $"Alarm cleared: {info.SourceName}";
|
: $"Alarm cleared: {info.SourceName}";
|
||||||
|
|
||||||
// Set a new EventId so clients can reference this event for acknowledge
|
// 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);
|
condition.SetSeverity(SystemContext, (EventSeverity)severity);
|
||||||
|
|
||||||
// Retain while active or unacknowledged
|
// 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
|
// Reset acknowledged state when alarm activates
|
||||||
if (active)
|
if (active)
|
||||||
@@ -584,16 +504,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
// Snapshot subscriptions for changed tags before teardown
|
// Snapshot subscriptions for changed tags before teardown
|
||||||
var affectedSubscriptions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
var affectedSubscriptions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var id in changedIds)
|
foreach (var id in changedIds)
|
||||||
{
|
|
||||||
if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs))
|
if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs))
|
||||||
{
|
|
||||||
foreach (var tagRef in tagRefs)
|
foreach (var tagRef in tagRefs)
|
||||||
{
|
|
||||||
if (_subscriptionRefCounts.TryGetValue(tagRef, out var count))
|
if (_subscriptionRefCounts.TryGetValue(tagRef, out var count))
|
||||||
affectedSubscriptions[tagRef] = count;
|
affectedSubscriptions[tagRef] = count;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tear down changed subtrees
|
// Tear down changed subtrees
|
||||||
TearDownGobjects(changedIds);
|
TearDownGobjects(changedIds);
|
||||||
@@ -640,8 +554,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
// Unsubscribe if actively subscribed
|
// Unsubscribe if actively subscribed
|
||||||
if (_subscriptionRefCounts.ContainsKey(tagRef))
|
if (_subscriptionRefCounts.ContainsKey(tagRef))
|
||||||
{
|
{
|
||||||
try { _mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult(); }
|
try
|
||||||
catch { /* ignore */ }
|
{
|
||||||
|
_mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
_subscriptionRefCounts.Remove(tagRef);
|
_subscriptionRefCounts.Remove(tagRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,14 +575,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
var info = _alarmInAlarmTags[alarmKey];
|
var info = _alarmInAlarmTags[alarmKey];
|
||||||
// Unsubscribe alarm auto-subscriptions
|
// 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))
|
if (!string.IsNullOrEmpty(alarmTag))
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try { _mxAccessClient.UnsubscribeAsync(alarmTag).GetAwaiter().GetResult(); }
|
_mxAccessClient.UnsubscribeAsync(alarmTag).GetAwaiter().GetResult();
|
||||||
catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
_alarmInAlarmTags.Remove(alarmKey);
|
_alarmInAlarmTags.Remove(alarmKey);
|
||||||
if (!string.IsNullOrEmpty(info.AckedTagReference))
|
if (!string.IsNullOrEmpty(info.AckedTagReference))
|
||||||
_alarmAckedTags.Remove(info.AckedTagReference);
|
_alarmAckedTags.Remove(info.AckedTagReference);
|
||||||
@@ -670,8 +595,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
// Delete variable node
|
// Delete variable node
|
||||||
if (_tagToVariableNode.TryGetValue(tagRef, out var variable))
|
if (_tagToVariableNode.TryGetValue(tagRef, out var variable))
|
||||||
{
|
{
|
||||||
try { DeleteNode(SystemContext, variable.NodeId); }
|
try
|
||||||
catch { /* ignore */ }
|
{
|
||||||
|
DeleteNode(SystemContext, variable.NodeId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
_tagToVariableNode.Remove(tagRef);
|
_tagToVariableNode.Remove(tagRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,14 +615,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
VariableNodeCount--;
|
VariableNodeCount--;
|
||||||
}
|
}
|
||||||
|
|
||||||
_gobjectToTagRefs.Remove(id);
|
_gobjectToTagRefs.Remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the object/folder node itself
|
// Delete the object/folder node itself
|
||||||
if (_nodeMap.TryGetValue(id, out var objNode))
|
if (_nodeMap.TryGetValue(id, out var objNode))
|
||||||
{
|
{
|
||||||
try { DeleteNode(SystemContext, objNode.NodeId); }
|
try
|
||||||
catch { /* ignore */ }
|
{
|
||||||
|
DeleteNode(SystemContext, objNode.NodeId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
_nodeMap.Remove(id);
|
_nodeMap.Remove(id);
|
||||||
if (!(objNode is FolderState))
|
if (!(objNode is FolderState))
|
||||||
ObjectNodeCount--;
|
ObjectNodeCount--;
|
||||||
@@ -782,14 +722,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key));
|
var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key));
|
||||||
if (directGroup != null)
|
if (directGroup != null)
|
||||||
{
|
|
||||||
foreach (var attr in directGroup)
|
foreach (var attr in directGroup)
|
||||||
{
|
{
|
||||||
var variable = CreateAttributeVariable(node, attr);
|
var variable = CreateAttributeVariable(node, attr);
|
||||||
if (primitiveGroupNames.Contains(attr.AttributeName))
|
if (primitiveGroupNames.Contains(attr.AttributeName))
|
||||||
variableNodes[attr.AttributeName] = variable;
|
variableNodes[attr.AttributeName] = variable;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var group in byPrimitive)
|
foreach (var group in byPrimitive)
|
||||||
{
|
{
|
||||||
@@ -809,10 +747,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
parentForAttrs = primNode;
|
parentForAttrs = primNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var attr in group)
|
foreach (var attr in group) CreateAttributeVariable(parentForAttrs, attr);
|
||||||
{
|
|
||||||
CreateAttributeVariable(parentForAttrs, attr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -897,12 +832,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
if (ownerAttr == null || !gobjectIds.Contains(ownerAttr.GobjectId))
|
if (ownerAttr == null || !gobjectIds.Contains(ownerAttr.GobjectId))
|
||||||
continue;
|
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))
|
if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag))
|
||||||
continue;
|
continue;
|
||||||
try { _mxAccessClient.SubscribeAsync(tag, (_, _) => { }); }
|
try
|
||||||
catch { /* ignore */ }
|
{
|
||||||
|
_mxAccessClient.SubscribeAsync(tag, (_, _) => { });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,17 +887,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
variable.NodeId = new NodeId(nodeIdString, NamespaceIndex);
|
variable.NodeId = new NodeId(nodeIdString, NamespaceIndex);
|
||||||
|
|
||||||
if (attr.IsArray && attr.ArrayDimension.HasValue)
|
if (attr.IsArray && attr.ArrayDimension.HasValue)
|
||||||
{
|
|
||||||
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { (uint)attr.ArrayDimension.Value });
|
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { (uint)attr.ArrayDimension.Value });
|
||||||
}
|
|
||||||
|
|
||||||
var accessLevel = SecurityClassificationMapper.IsWritable(attr.SecurityClassification)
|
var accessLevel = SecurityClassificationMapper.IsWritable(attr.SecurityClassification)
|
||||||
? AccessLevels.CurrentReadOrWrite
|
? AccessLevels.CurrentReadOrWrite
|
||||||
: AccessLevels.CurrentRead;
|
: AccessLevels.CurrentRead;
|
||||||
if (attr.IsHistorized)
|
if (attr.IsHistorized) accessLevel |= AccessLevels.HistoryRead;
|
||||||
{
|
|
||||||
accessLevel |= AccessLevels.HistoryRead;
|
|
||||||
}
|
|
||||||
variable.AccessLevel = accessLevel;
|
variable.AccessLevel = accessLevel;
|
||||||
variable.UserAccessLevel = accessLevel;
|
variable.UserAccessLevel = accessLevel;
|
||||||
variable.Historizing = attr.IsHistorized;
|
variable.Historizing = attr.IsHistorized;
|
||||||
@@ -980,6 +917,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
tagList = new List<string>();
|
tagList = new List<string>();
|
||||||
_gobjectToTagRefs[attr.GobjectId] = tagList;
|
_gobjectToTagRefs[attr.GobjectId] = tagList;
|
||||||
}
|
}
|
||||||
|
|
||||||
tagList.Add(attr.FullTagReference);
|
tagList.Add(attr.FullTagReference);
|
||||||
|
|
||||||
VariableNodeCount++;
|
VariableNodeCount++;
|
||||||
@@ -1034,7 +972,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
return obj;
|
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)
|
var variable = new BaseDataVariableState(parent)
|
||||||
{
|
{
|
||||||
@@ -1059,6 +998,110 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
return variable;
|
return variable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Condition Refresh
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <param name="context">The OPC UA request context for the condition refresh operation.</param>
|
||||||
|
/// <param name="monitoredItems">The monitored event items that should receive retained alarm conditions.</param>
|
||||||
|
public override ServiceResult ConditionRefresh(OperationContext context,
|
||||||
|
IList<IEventMonitoredItem> 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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants.
|
||||||
|
/// </summary>
|
||||||
|
public int MxDataType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsArray { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array.
|
||||||
|
/// </summary>
|
||||||
|
public int? ArrayDimension { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int SecurityClassification { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AlarmInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the full tag reference for the process value whose alarm state is tracked.
|
||||||
|
/// </summary>
|
||||||
|
public string SourceTagReference { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition.
|
||||||
|
/// </summary>
|
||||||
|
public NodeId SourceNodeId { get; set; } = NodeId.Null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the operator-facing source name used in generated alarm events.
|
||||||
|
/// </summary>
|
||||||
|
public string SourceName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued.
|
||||||
|
/// </summary>
|
||||||
|
public bool LastInAlarm { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the retained OPC UA condition node associated with the source alarm.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmConditionState? ConditionNode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates.
|
||||||
|
/// </summary>
|
||||||
|
public string PriorityTagReference { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text.
|
||||||
|
/// </summary>
|
||||||
|
public string DescAttrNameTagReference { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cached OPC UA severity derived from the latest alarm priority value.
|
||||||
|
/// </summary>
|
||||||
|
public ushort CachedSeverity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cached alarm message used when emitting active and cleared events.
|
||||||
|
/// </summary>
|
||||||
|
public string CachedMessage { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Galaxy tag reference for the alarm acknowledged state.
|
||||||
|
/// </summary>
|
||||||
|
public string AckedTagReference { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment.
|
||||||
|
/// </summary>
|
||||||
|
public string AckMsgTagReference { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
#region Read/Write Handlers
|
#region Read/Write Handlers
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -1067,7 +1110,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
base.Read(context, maxAge, nodesToRead, results, errors);
|
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)
|
if (nodesToRead[i].AttributeId != Attributes.Value)
|
||||||
continue;
|
continue;
|
||||||
@@ -1079,7 +1122,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
if (nodeIdStr == null) continue;
|
if (nodeIdStr == null) continue;
|
||||||
|
|
||||||
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult();
|
var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult();
|
||||||
@@ -1093,7 +1135,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Write(OperationContext context, IList<WriteValue> nodesToWrite,
|
public override void Write(OperationContext context, IList<WriteValue> nodesToWrite,
|
||||||
@@ -1101,7 +1142,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
base.Write(context, nodesToWrite, errors);
|
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)
|
if (nodesToWrite[i].AttributeId != Attributes.Value)
|
||||||
continue;
|
continue;
|
||||||
@@ -1217,14 +1258,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
private static void EnableEventNotifierUpChain(NodeState node)
|
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)
|
if (current is BaseObjectState obj)
|
||||||
obj.EventNotifier = EventNotifiers.SubscribeToEvents;
|
obj.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||||
else if (current is FolderState folder)
|
else if (current is FolderState folder)
|
||||||
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void ReportEventUpNotifierChain(BaseInstanceState sourceNode, IFilterTarget eventInstance)
|
private void ReportEventUpNotifierChain(BaseInstanceState sourceNode, IFilterTarget eventInstance)
|
||||||
{
|
{
|
||||||
@@ -1232,14 +1273,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
current.ReportEvent(SystemContext, eventInstance);
|
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!;
|
updatedArray = null!;
|
||||||
|
|
||||||
if (!int.TryParse(indexRange, out var index) || index < 0)
|
if (!int.TryParse(indexRange, out var index) || index < 0)
|
||||||
return false;
|
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)
|
if (currentValue is not Array currentArray || currentArray.Rank != 1 || index >= currentArray.Length)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -1305,7 +1348,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
if (value != null)
|
if (value != null)
|
||||||
return value;
|
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 null;
|
||||||
|
|
||||||
return CreateDefaultArrayValue(metadata);
|
return CreateDefaultArrayValue(metadata);
|
||||||
@@ -1317,40 +1361,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
var values = Array.CreateInstance(elementType, metadata.ArrayDimension!.Value);
|
var values = Array.CreateInstance(elementType, metadata.ArrayDimension!.Value);
|
||||||
|
|
||||||
if (elementType == typeof(string))
|
if (elementType == typeof(string))
|
||||||
{
|
for (var i = 0; i < values.Length; i++)
|
||||||
for (int i = 0; i < values.Length; i++)
|
|
||||||
values.SetValue(string.Empty, i);
|
values.SetValue(string.Empty, i);
|
||||||
}
|
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Condition Refresh
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
/// <param name="context">The OPC UA request context for the condition refresh operation.</param>
|
|
||||||
/// <param name="monitoredItems">The monitored event items that should receive retained alarm conditions.</param>
|
|
||||||
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> 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
|
#region HistoryRead
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -1481,7 +1499,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
/// can arrive before the initial publish to the client.
|
/// can arrive before the initial publish to the client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
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);
|
base.OnMonitoredItemCreated(context, handle, monitoredItem);
|
||||||
|
|
||||||
@@ -1495,7 +1514,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
/// Decrements ref-counted MXAccess subscriptions.
|
/// Decrements ref-counted MXAccess subscriptions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
|
protected override void OnDeleteMonitoredItemsComplete(ServerSystemContext context,
|
||||||
|
IList<IMonitoredItem> monitoredItems)
|
||||||
{
|
{
|
||||||
foreach (var item in monitoredItems)
|
foreach (var item in monitoredItems)
|
||||||
{
|
{
|
||||||
@@ -1510,7 +1530,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
/// Rebuilds MXAccess subscription bookkeeping when transferred items arrive without local in-memory state.
|
/// Rebuilds MXAccess subscription bookkeeping when transferred items arrive without local in-memory state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnMonitoredItemsTransferred(ServerSystemContext context, IList<IMonitoredItem> monitoredItems)
|
protected override void OnMonitoredItemsTransferred(ServerSystemContext context,
|
||||||
|
IList<IMonitoredItem> monitoredItems)
|
||||||
{
|
{
|
||||||
base.OnMonitoredItemsTransferred(context, monitoredItems);
|
base.OnMonitoredItemsTransferred(context, monitoredItems);
|
||||||
|
|
||||||
@@ -1531,7 +1552,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to subscribe.</param>
|
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to subscribe.</param>
|
||||||
internal void SubscribeTag(string fullTagReference)
|
internal void SubscribeTag(string fullTagReference)
|
||||||
@@ -1555,7 +1577,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to unsubscribe.</param>
|
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to unsubscribe.</param>
|
||||||
internal void UnsubscribeTag(string fullTagReference)
|
internal void UnsubscribeTag(string fullTagReference)
|
||||||
@@ -1594,7 +1617,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
var tagsToSubscribe = new List<string>();
|
var tagsToSubscribe = new List<string>();
|
||||||
foreach (var kvp in transferredCounts)
|
foreach (var kvp in transferredCounts)
|
||||||
{
|
|
||||||
lock (Lock)
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (_subscriptionRefCounts.ContainsKey(kvp.Key))
|
if (_subscriptionRefCounts.ContainsKey(kvp.Key))
|
||||||
@@ -1603,7 +1625,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
_subscriptionRefCounts[kvp.Key] = kvp.Value;
|
_subscriptionRefCounts[kvp.Key] = kvp.Value;
|
||||||
tagsToSubscribe.Add(kvp.Key);
|
tagsToSubscribe.Add(kvp.Key);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var tagRef in tagsToSubscribe)
|
foreach (var tagRef in tagsToSubscribe)
|
||||||
_ = _mxAccessClient.SubscribeAsync(tagRef, (_, _) => { });
|
_ = _mxAccessClient.SubscribeAsync(tagRef, (_, _) => { });
|
||||||
@@ -1653,7 +1674,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
Log.Information("Data change dispatch thread started");
|
Log.Information("Data change dispatch thread started");
|
||||||
|
|
||||||
while (_dispatchRunning)
|
while (_dispatchRunning)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_dataChangeSignal.WaitOne(TimeSpan.FromMilliseconds(100));
|
_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.
|
// 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 updates =
|
||||||
var pendingAlarmEvents = new List<(string address, AlarmInfo info, bool active, ushort? severity, string? message)>();
|
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)>();
|
var pendingAckedEvents = new List<(AlarmInfo info, bool acked)>();
|
||||||
|
|
||||||
foreach (var address in keys)
|
foreach (var address in keys)
|
||||||
@@ -1680,13 +1702,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
AlarmInfo? alarmInfo = null;
|
AlarmInfo? alarmInfo = null;
|
||||||
AlarmInfo? ackedAlarmInfo = null;
|
AlarmInfo? ackedAlarmInfo = null;
|
||||||
bool newInAlarm = false;
|
var newInAlarm = false;
|
||||||
bool newAcked = false;
|
var newAcked = false;
|
||||||
|
|
||||||
lock (Lock)
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (_tagToVariableNode.TryGetValue(address, out var variable))
|
if (_tagToVariableNode.TryGetValue(address, out var variable))
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var dataValue = CreatePublishedDataValue(address, vtq);
|
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);
|
Log.Warning(ex, "Error preparing data change for {Address}", address);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (_alarmInAlarmTags.TryGetValue(address, out alarmInfo))
|
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)
|
if (newInAlarm == alarmInfo.LastInAlarm)
|
||||||
alarmInfo = null;
|
alarmInfo = null;
|
||||||
}
|
}
|
||||||
@@ -1708,7 +1729,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
// Check for Acked transitions
|
// Check for Acked transitions
|
||||||
if (_alarmAckedTags.TryGetValue(address, out ackedAlarmInfo))
|
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));
|
pendingAckedEvents.Add((ackedAlarmInfo, newAcked));
|
||||||
ackedAlarmInfo = null; // handled
|
ackedAlarmInfo = null; // handled
|
||||||
}
|
}
|
||||||
@@ -1724,11 +1746,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter().GetResult();
|
var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
if (pVtq.Value is int ip)
|
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)
|
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
|
catch
|
||||||
{
|
{
|
||||||
@@ -1737,7 +1760,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
try
|
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))
|
if (dVtq.Value is string desc && !string.IsNullOrEmpty(desc))
|
||||||
message = desc;
|
message = desc;
|
||||||
}
|
}
|
||||||
@@ -1752,12 +1776,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
// Apply under Lock so ClearChangeMasks propagates to monitored items.
|
// Apply under Lock so ClearChangeMasks propagates to monitored items.
|
||||||
if (updates.Count > 0 || pendingAlarmEvents.Count > 0 || pendingAckedEvents.Count > 0)
|
if (updates.Count > 0 || pendingAlarmEvents.Count > 0 || pendingAckedEvents.Count > 0)
|
||||||
{
|
|
||||||
lock (Lock)
|
lock (Lock)
|
||||||
{
|
{
|
||||||
foreach (var (address, variable, dataValue) in updates)
|
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;
|
continue;
|
||||||
|
|
||||||
variable.Value = dataValue.Value;
|
variable.Value = dataValue.Value;
|
||||||
@@ -1768,7 +1792,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
foreach (var (address, info, active, severity, message) in pendingAlarmEvents)
|
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;
|
continue;
|
||||||
|
|
||||||
if (currentInfo.LastInAlarm == active)
|
if (currentInfo.LastInAlarm == active)
|
||||||
@@ -1799,7 +1824,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
condition.SetAcknowledgedState(SystemContext, acked);
|
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))
|
if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var src))
|
||||||
ReportEventUpNotifierChain(src, condition);
|
ReportEventUpNotifierChain(src, condition);
|
||||||
@@ -1813,7 +1838,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Interlocked.Add(ref _totalDispatchBatchSize, updates.Count);
|
Interlocked.Add(ref _totalDispatchBatchSize, updates.Count);
|
||||||
Interlocked.Increment(ref _dispatchCycleCount);
|
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.Error(ex, "Unhandled error in data change dispatch loop");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("Data change dispatch thread stopped");
|
Log.Information("Data change dispatch thread stopped");
|
||||||
}
|
}
|
||||||
@@ -1848,8 +1871,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
Interlocked.Exchange(ref _dispatchCycleCount, 0);
|
Interlocked.Exchange(ref _dispatchCycleCount, 0);
|
||||||
|
|
||||||
_lastMetricsReportTime = now;
|
_lastMetricsReportTime = now;
|
||||||
_lastEventsPerSecond = eventsPerSecond;
|
MxChangeEventsPerSecond = eventsPerSecond;
|
||||||
_lastAvgBatchSize = avgQueueSize;
|
AverageDispatchBatchSize = avgQueueSize;
|
||||||
|
|
||||||
Log.Information(
|
Log.Information(
|
||||||
"DataChange dispatch: EventsPerSec={EventsPerSec:F1}, AvgBatchSize={AvgBatchSize:F1}, PendingItems={Pending}, TotalEvents={Total}",
|
"DataChange dispatch: EventsPerSec={EventsPerSec:F1}, AvgBatchSize={AvgBatchSize:F1}, PendingItems={Pending}, TotalEvents={Total}",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text;
|
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -18,43 +17,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
public class LmxOpcUaServer : StandardServer
|
public class LmxOpcUaServer : StandardServer
|
||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
|
||||||
|
|
||||||
private readonly string _galaxyName;
|
|
||||||
private readonly IMxAccessClient _mxAccessClient;
|
|
||||||
private readonly PerformanceMetrics _metrics;
|
|
||||||
private readonly HistorianDataSource? _historianDataSource;
|
|
||||||
private readonly bool _alarmTrackingEnabled;
|
private readonly bool _alarmTrackingEnabled;
|
||||||
|
private readonly string? _applicationUri;
|
||||||
private readonly AuthenticationConfiguration _authConfig;
|
private readonly AuthenticationConfiguration _authConfig;
|
||||||
private readonly IUserAuthenticationProvider? _authProvider;
|
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 RedundancyConfiguration _redundancyConfig;
|
||||||
private readonly string? _applicationUri;
|
private readonly ServiceLevelCalculator _serviceLevelCalculator = new();
|
||||||
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
|
private NodeId? _alarmAckRoleId;
|
||||||
|
|
||||||
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
|
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
|
||||||
private NodeId? _readOnlyRoleId;
|
private NodeId? _readOnlyRoleId;
|
||||||
|
private NodeId? _writeConfigureRoleId;
|
||||||
private NodeId? _writeOperateRoleId;
|
private NodeId? _writeOperateRoleId;
|
||||||
private NodeId? _writeTuneRoleId;
|
private NodeId? _writeTuneRoleId;
|
||||||
private NodeId? _writeConfigureRoleId;
|
|
||||||
private NodeId? _alarmAckRoleId;
|
|
||||||
|
|
||||||
private LmxNodeManager? _nodeManager;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
|
|
||||||
/// </summary>
|
|
||||||
public LmxNodeManager? NodeManager => _nodeManager;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of active OPC UA sessions currently connected to the server.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveSessionCount
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
try { return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0; }
|
|
||||||
catch { return 0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
||||||
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
|
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
|
||||||
@@ -72,18 +52,42 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
_applicationUri = applicationUri;
|
_applicationUri = applicationUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
|
||||||
|
/// </summary>
|
||||||
|
public LmxNodeManager? NodeManager { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of active OPC UA sessions currently connected to the server.
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveSessionCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server,
|
||||||
|
ApplicationConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Resolve custom role NodeIds from the roles namespace
|
// Resolve custom role NodeIds from the roles namespace
|
||||||
ResolveRoleNodeIds(server);
|
ResolveRoleNodeIds(server);
|
||||||
|
|
||||||
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
|
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,
|
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
|
||||||
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId);
|
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId);
|
||||||
|
|
||||||
var nodeManagers = new List<INodeManager> { _nodeManager };
|
var nodeManagers = new List<INodeManager> { NodeManager };
|
||||||
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
|
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.Value = _redundancyConfig.ServerUris.ToArray();
|
||||||
serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
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
|
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,7 +157,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +171,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
|
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (ServerInternal != null)
|
if (ServerInternal != null) SetServiceLevelValue(ServerInternal, level);
|
||||||
{
|
|
||||||
SetServiceLevelValue(ServerInternal, level);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -206,7 +210,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
||||||
{
|
{
|
||||||
if (!_authConfig.AllowAnonymous)
|
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(
|
args.Identity = new RoleBasedIdentity(
|
||||||
new UserIdentity(anonymousToken),
|
new UserIdentity(anonymousToken),
|
||||||
@@ -232,26 +237,26 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
|
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
|
||||||
|
|
||||||
foreach (var appRole in appRoles)
|
foreach (var appRole in appRoles)
|
||||||
{
|
|
||||||
switch (appRole)
|
switch (appRole)
|
||||||
{
|
{
|
||||||
case AppRoles.ReadOnly:
|
case AppRoles.ReadOnly:
|
||||||
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
||||||
break;
|
break;
|
||||||
case AppRoles.WriteOperate:
|
case AppRoles.WriteOperate:
|
||||||
if (_writeOperateRoleId != null) roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
|
if (_writeOperateRoleId != null)
|
||||||
|
roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
|
||||||
break;
|
break;
|
||||||
case AppRoles.WriteTune:
|
case AppRoles.WriteTune:
|
||||||
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
|
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
|
||||||
break;
|
break;
|
||||||
case AppRoles.WriteConfigure:
|
case AppRoles.WriteConfigure:
|
||||||
if (_writeConfigureRoleId != null) roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
|
if (_writeConfigureRoleId != null)
|
||||||
|
roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
|
||||||
break;
|
break;
|
||||||
case AppRoles.AlarmAck:
|
case AppRoles.AlarmAck:
|
||||||
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
|
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("User {Username} authenticated with roles [{Roles}]",
|
Log.Information("User {Username} authenticated with roles [{Roles}]",
|
||||||
userNameToken.UserName, string.Join(", ", appRoles));
|
userNameToken.UserName, string.Join(", ", appRoles));
|
||||||
@@ -279,7 +284,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
ProductUri = $"urn:{_galaxyName}:LmxOpcUa",
|
ProductUri = $"urn:{_galaxyName}:LmxOpcUa",
|
||||||
SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||||
BuildNumber = "1",
|
BuildNumber = "1",
|
||||||
BuildDate = System.DateTime.UtcNow
|
BuildDate = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
return properties;
|
return properties;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Configuration;
|
using Opc.Ua.Configuration;
|
||||||
using Opc.Ua.Server;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
@@ -17,39 +17,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
public class OpcUaServerHost : IDisposable
|
public class OpcUaServerHost : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
|
||||||
|
|
||||||
private readonly OpcUaConfiguration _config;
|
|
||||||
private readonly IMxAccessClient _mxAccessClient;
|
|
||||||
private readonly PerformanceMetrics _metrics;
|
|
||||||
private readonly HistorianDataSource? _historianDataSource;
|
|
||||||
private readonly AuthenticationConfiguration _authConfig;
|
private readonly AuthenticationConfiguration _authConfig;
|
||||||
private readonly IUserAuthenticationProvider? _authProvider;
|
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 RedundancyConfiguration _redundancyConfig;
|
||||||
|
private readonly SecurityProfileConfiguration _securityConfig;
|
||||||
private ApplicationInstance? _application;
|
private ApplicationInstance? _application;
|
||||||
private LmxOpcUaServer? _server;
|
private LmxOpcUaServer? _server;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the active node manager that holds the published Galaxy namespace.
|
|
||||||
/// </summary>
|
|
||||||
public LmxNodeManager? NodeManager => _server?.NodeManager;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of currently connected OPC UA client sessions.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsRunning => _server != null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the OPC UA ServiceLevel based on current runtime health.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) =>
|
|
||||||
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
|
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -75,7 +54,39 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public LmxNodeManager? NodeManager => _server?.NodeManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of currently connected OPC UA client sessions.
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRunning => _server != null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the host and releases server resources.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the OPC UA ServiceLevel based on current runtime health.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
||||||
|
{
|
||||||
|
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured
|
||||||
|
/// endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task StartAsync()
|
public async Task StartAsync()
|
||||||
{
|
{
|
||||||
@@ -85,12 +96,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
// Resolve configured security profiles
|
// Resolve configured security profiles
|
||||||
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
|
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
|
||||||
foreach (var sp in securityPolicies)
|
foreach (var sp in securityPolicies)
|
||||||
{
|
|
||||||
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
|
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
|
||||||
}
|
|
||||||
|
|
||||||
// Build PKI paths
|
// Build PKI paths
|
||||||
var pkiRoot = _securityConfig.PkiRootPath ?? System.IO.Path.Combine(
|
var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"OPC Foundation", "pki");
|
"OPC Foundation", "pki");
|
||||||
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
|
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
|
ApplicationCertificate = new CertificateIdentifier
|
||||||
{
|
{
|
||||||
StoreType = CertificateStoreType.Directory,
|
StoreType = CertificateStoreType.Directory,
|
||||||
StorePath = System.IO.Path.Combine(pkiRoot, "own"),
|
StorePath = Path.Combine(pkiRoot, "own"),
|
||||||
SubjectName = certSubject
|
SubjectName = certSubject
|
||||||
},
|
},
|
||||||
TrustedIssuerCertificates = new CertificateTrustList
|
TrustedIssuerCertificates = new CertificateTrustList
|
||||||
{
|
{
|
||||||
StoreType = CertificateStoreType.Directory,
|
StoreType = CertificateStoreType.Directory,
|
||||||
StorePath = System.IO.Path.Combine(pkiRoot, "issuer")
|
StorePath = Path.Combine(pkiRoot, "issuer")
|
||||||
},
|
},
|
||||||
TrustedPeerCertificates = new CertificateTrustList
|
TrustedPeerCertificates = new CertificateTrustList
|
||||||
{
|
{
|
||||||
StoreType = CertificateStoreType.Directory,
|
StoreType = CertificateStoreType.Directory,
|
||||||
StorePath = System.IO.Path.Combine(pkiRoot, "trusted")
|
StorePath = Path.Combine(pkiRoot, "trusted")
|
||||||
},
|
},
|
||||||
RejectedCertificateStore = new CertificateTrustList
|
RejectedCertificateStore = new CertificateTrustList
|
||||||
{
|
{
|
||||||
StoreType = CertificateStoreType.Directory,
|
StoreType = CertificateStoreType.Directory,
|
||||||
StorePath = System.IO.Path.Combine(pkiRoot, "rejected")
|
StorePath = Path.Combine(pkiRoot, "rejected")
|
||||||
},
|
},
|
||||||
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
|
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
|
||||||
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
|
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
|
||||||
@@ -176,7 +185,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
// Check/create application certificate
|
// Check/create application certificate
|
||||||
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
||||||
bool certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
|
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
|
||||||
if (!certOk)
|
if (!certOk)
|
||||||
{
|
{
|
||||||
Log.Warning("Application certificate check failed, attempting to create...");
|
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);
|
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri);
|
||||||
await _application.Start(_server);
|
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);
|
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,10 +252,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
return policies;
|
return policies;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the host and releases server resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose() => Stop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System;
|
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
@@ -31,9 +30,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolved == RedundancySupport.None)
|
if (resolved == RedundancySupport.None)
|
||||||
{
|
Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot",
|
||||||
Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot", mode);
|
mode);
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||||
@@ -15,7 +14,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
|
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
|
||||||
|
|
||||||
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
|
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
|
||||||
new Dictionary<string, ServerSecurityPolicy>(StringComparer.OrdinalIgnoreCase)
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["None"] = new ServerSecurityPolicy
|
["None"] = new ServerSecurityPolicy
|
||||||
{
|
{
|
||||||
@@ -34,6 +33,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of valid profile names for validation and documentation.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy" /> entries.
|
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy" /> entries.
|
||||||
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
|
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
|
||||||
@@ -59,15 +63,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (KnownProfiles.TryGetValue(trimmed, out var policy))
|
if (KnownProfiles.TryGetValue(trimmed, out var policy))
|
||||||
{
|
|
||||||
resolved.Add(policy);
|
resolved.Add(policy);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
|
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
|
||||||
trimmed, string.Join(", ", KnownProfiles.Keys));
|
trimmed, string.Join(", ", KnownProfiles.Keys));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.Count == 0)
|
if (resolved.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -77,10 +77,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the list of valid profile names for validation and documentation.
|
|
||||||
/// </summary>
|
|
||||||
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
if (!mxAccessConnected && !dbConnected)
|
if (!mxAccessConnected && !dbConnected)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
int level = baseLevel;
|
var level = baseLevel;
|
||||||
|
|
||||||
if (!mxAccessConnected)
|
if (!mxAccessConnected)
|
||||||
level -= 100;
|
level -= 100;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository;
|
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.Metrics;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||||
@@ -18,27 +20,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
internal sealed class OpcUaService
|
internal sealed class OpcUaService
|
||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaService>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaService>();
|
||||||
|
private readonly IUserAuthenticationProvider? _authProviderOverride;
|
||||||
|
|
||||||
private readonly AppConfiguration _config;
|
private readonly AppConfiguration _config;
|
||||||
private readonly IMxProxy? _mxProxy;
|
|
||||||
private readonly IGalaxyRepository? _galaxyRepository;
|
private readonly IGalaxyRepository? _galaxyRepository;
|
||||||
private readonly IMxAccessClient? _mxAccessClientOverride;
|
|
||||||
private readonly bool _hasMxAccessClientOverride;
|
|
||||||
private readonly IUserAuthenticationProvider? _authProviderOverride;
|
|
||||||
private readonly bool _hasAuthProviderOverride;
|
private readonly bool _hasAuthProviderOverride;
|
||||||
|
private readonly bool _hasMxAccessClientOverride;
|
||||||
|
private readonly IMxAccessClient? _mxAccessClientOverride;
|
||||||
|
private readonly IMxProxy? _mxProxy;
|
||||||
|
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private PerformanceMetrics? _metrics;
|
private HealthCheckService? _healthCheck;
|
||||||
private StaComThread? _staThread;
|
|
||||||
private MxAccessClient? _mxAccessClient;
|
private MxAccessClient? _mxAccessClient;
|
||||||
private IMxAccessClient? _mxAccessClientForWiring;
|
private IMxAccessClient? _mxAccessClientForWiring;
|
||||||
private ChangeDetectionService? _changeDetection;
|
private StaComThread? _staThread;
|
||||||
private OpcUaServerHost? _serverHost;
|
|
||||||
private LmxNodeManager? _nodeManager;
|
|
||||||
private HealthCheckService? _healthCheck;
|
|
||||||
private StatusReportService? _statusReport;
|
|
||||||
private StatusWebServer? _statusWebServer;
|
|
||||||
private GalaxyRepositoryStats? _galaxyStats;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Production constructor. Loads configuration from appsettings.json.
|
/// Production constructor. Loads configuration from appsettings.json.
|
||||||
@@ -46,8 +41,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
public OpcUaService()
|
public OpcUaService()
|
||||||
{
|
{
|
||||||
var configuration = new ConfigurationBuilder()
|
var configuration = new ConfigurationBuilder()
|
||||||
.AddJsonFile("appsettings.json", optional: false)
|
.AddJsonFile("appsettings.json", false)
|
||||||
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true)
|
.AddJsonFile(
|
||||||
|
$"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json",
|
||||||
|
true)
|
||||||
.AddEnvironmentVariables()
|
.AddEnvironmentVariables()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -70,11 +67,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test constructor. Accepts injected dependencies.
|
/// Test constructor. Accepts injected dependencies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="config">The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard behavior during the test run.</param>
|
/// <param name="config">
|
||||||
|
/// The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard
|
||||||
|
/// behavior during the test run.
|
||||||
|
/// </param>
|
||||||
/// <param name="mxProxy">The MXAccess proxy substitute used when a test wants to exercise COM-style wiring.</param>
|
/// <param name="mxProxy">The MXAccess proxy substitute used when a test wants to exercise COM-style wiring.</param>
|
||||||
/// <param name="galaxyRepository">The repository substitute that supplies Galaxy hierarchy and deploy metadata for address-space builds.</param>
|
/// <param name="galaxyRepository">
|
||||||
/// <param name="mxAccessClientOverride">An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop.</param>
|
/// The repository substitute that supplies Galaxy hierarchy and deploy metadata for
|
||||||
/// <param name="hasMxAccessClientOverride">A value indicating whether the override client should be used instead of creating a client from <paramref name="mxProxy"/>.</param>
|
/// address-space builds.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="mxAccessClientOverride">
|
||||||
|
/// An optional direct MXAccess client substitute that bypasses STA thread setup and
|
||||||
|
/// COM interop.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="hasMxAccessClientOverride">
|
||||||
|
/// A value indicating whether the override client should be used instead of
|
||||||
|
/// creating a client from <paramref name="mxProxy" />.
|
||||||
|
/// </param>
|
||||||
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
|
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
|
||||||
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false,
|
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false,
|
||||||
IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false)
|
IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false)
|
||||||
@@ -88,8 +97,50 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
_hasAuthProviderOverride = hasAuthProviderOverride;
|
_hasAuthProviderOverride = hasAuthProviderOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accessors for testing
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the metrics collector that tracks bridge operation timings during the service lifetime.
|
||||||
|
/// </summary>
|
||||||
|
internal PerformanceMetrics? Metrics { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the OPC UA server host that owns the runtime endpoint.
|
||||||
|
/// </summary>
|
||||||
|
internal OpcUaServerHost? ServerHost { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the node manager instance that holds the current Galaxy-derived address space.
|
||||||
|
/// </summary>
|
||||||
|
internal LmxNodeManager? NodeManagerInstance { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild.
|
||||||
|
/// </summary>
|
||||||
|
internal ChangeDetectionService? ChangeDetectionInstance { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the hosted status web server when the dashboard is enabled.
|
||||||
|
/// </summary>
|
||||||
|
internal StatusWebServer? StatusWeb { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the dashboard report generator used to assemble operator-facing status snapshots.
|
||||||
|
/// </summary>
|
||||||
|
internal StatusReportService? StatusReportInstance { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds.
|
||||||
|
/// </summary>
|
||||||
|
internal GalaxyRepositoryStats? GalaxyStatsInstance { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA
|
||||||
|
/// address space, and optionally hosting the status dashboard.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
@@ -109,7 +160,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
|
|
||||||
// Step 4: Create PerformanceMetrics
|
// Step 4: Create PerformanceMetrics
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_metrics = new PerformanceMetrics();
|
Metrics = new PerformanceMetrics();
|
||||||
|
|
||||||
// Step 5: Create MxAccessClient → Connect
|
// Step 5: Create MxAccessClient → Connect
|
||||||
if (_hasMxAccessClientOverride)
|
if (_hasMxAccessClientOverride)
|
||||||
@@ -117,24 +168,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
// Test path: use injected IMxAccessClient directly (skips STA thread + COM)
|
// Test path: use injected IMxAccessClient directly (skips STA thread + COM)
|
||||||
_mxAccessClientForWiring = _mxAccessClientOverride;
|
_mxAccessClientForWiring = _mxAccessClientOverride;
|
||||||
if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected)
|
if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected)
|
||||||
{
|
|
||||||
_mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
_mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (_mxProxy != null)
|
else if (_mxProxy != null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_staThread = new StaComThread();
|
_staThread = new StaComThread();
|
||||||
_staThread.Start();
|
_staThread.Start();
|
||||||
_mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, _metrics);
|
_mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, Metrics);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
_mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
// 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
|
// Step 7: Create GalaxyRepositoryService → TestConnection
|
||||||
_galaxyStats = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName };
|
GalaxyStatsInstance = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName };
|
||||||
|
|
||||||
if (_galaxyRepository != null)
|
if (_galaxyRepository != null)
|
||||||
{
|
{
|
||||||
var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult();
|
var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult();
|
||||||
_galaxyStats.DbConnected = dbOk;
|
GalaxyStatsInstance.DbConnected = dbOk;
|
||||||
if (!dbOk)
|
if (!dbOk)
|
||||||
Log.Warning("Galaxy repository database connection failed — continuing without initial data");
|
Log.Warning("Galaxy repository database connection failed — continuing without initial data");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Create OPC UA server host + node manager
|
// 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
|
var historianDataSource = _config.Historian.Enabled
|
||||||
? new Historian.HistorianDataSource(_config.Historian)
|
? new HistorianDataSource(_config.Historian)
|
||||||
: null;
|
: null;
|
||||||
Domain.IUserAuthenticationProvider? authProvider = null;
|
IUserAuthenticationProvider? authProvider = null;
|
||||||
if (_hasAuthProviderOverride)
|
if (_hasAuthProviderOverride)
|
||||||
{
|
{
|
||||||
authProvider = _authProviderOverride;
|
authProvider = _authProviderOverride;
|
||||||
}
|
}
|
||||||
else if (_config.Authentication.Ldap.Enabled)
|
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})",
|
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);
|
_config.Authentication, authProvider, _config.Security, _config.Redundancy);
|
||||||
|
|
||||||
// Step 9-10: Query hierarchy, start server, build address space
|
// Step 9-10: Query hierarchy, start server, build address space
|
||||||
DateTime? initialDeployTime = null;
|
DateTime? initialDeployTime = null;
|
||||||
if (_galaxyRepository != null && _galaxyStats.DbConnected)
|
if (_galaxyRepository != null && GalaxyStatsInstance.DbConnected)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter().GetResult();
|
initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult();
|
var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult();
|
||||||
var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult();
|
var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult();
|
||||||
_galaxyStats.ObjectCount = hierarchy.Count;
|
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
|
||||||
_galaxyStats.AttributeCount = attributes.Count;
|
GalaxyStatsInstance.AttributeCount = attributes.Count;
|
||||||
|
|
||||||
_serverHost.StartAsync().GetAwaiter().GetResult();
|
ServerHost.StartAsync().GetAwaiter().GetResult();
|
||||||
_nodeManager = _serverHost.NodeManager;
|
NodeManagerInstance = ServerHost.NodeManager;
|
||||||
|
|
||||||
if (_nodeManager != null)
|
if (NodeManagerInstance != null)
|
||||||
{
|
{
|
||||||
_nodeManager.BuildAddressSpace(hierarchy, attributes);
|
NodeManagerInstance.BuildAddressSpace(hierarchy, attributes);
|
||||||
_galaxyStats.LastRebuildTime = DateTime.UtcNow;
|
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to build initial address space");
|
Log.Warning(ex, "Failed to build initial address space");
|
||||||
if (!_serverHost.IsRunning)
|
if (!ServerHost.IsRunning)
|
||||||
{
|
{
|
||||||
_serverHost.StartAsync().GetAwaiter().GetResult();
|
ServerHost.StartAsync().GetAwaiter().GetResult();
|
||||||
_nodeManager = _serverHost.NodeManager;
|
NodeManagerInstance = ServerHost.NodeManager;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_serverHost.StartAsync().GetAwaiter().GetResult();
|
ServerHost.StartAsync().GetAwaiter().GetResult();
|
||||||
_nodeManager = _serverHost.NodeManager;
|
NodeManagerInstance = ServerHost.NodeManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 11-12: Change detection wired to rebuild
|
// Step 11-12: Change detection wired to rebuild
|
||||||
if (_galaxyRepository != null)
|
if (_galaxyRepository != null)
|
||||||
{
|
{
|
||||||
_changeDetection = new ChangeDetectionService(_galaxyRepository, _config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime);
|
ChangeDetectionInstance = new ChangeDetectionService(_galaxyRepository,
|
||||||
_changeDetection.OnGalaxyChanged += OnGalaxyChanged;
|
_config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime);
|
||||||
_changeDetection.Start();
|
ChangeDetectionInstance.OnGalaxyChanged += OnGalaxyChanged;
|
||||||
|
ChangeDetectionInstance.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 13: Dashboard
|
// Step 13: Dashboard
|
||||||
_healthCheck = new HealthCheckService();
|
_healthCheck = new HealthCheckService();
|
||||||
_statusReport = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds);
|
StatusReportInstance = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds);
|
||||||
_statusReport.SetComponents(effectiveMxClient, _metrics, _galaxyStats, _serverHost, _nodeManager,
|
StatusReportInstance.SetComponents(effectiveMxClient, Metrics, GalaxyStatsInstance, ServerHost,
|
||||||
|
NodeManagerInstance,
|
||||||
_config.Redundancy, _config.OpcUa.ApplicationUri);
|
_config.Redundancy, _config.OpcUa.ApplicationUri);
|
||||||
|
|
||||||
if (_config.Dashboard.Enabled)
|
if (_config.Dashboard.Enabled)
|
||||||
{
|
{
|
||||||
_statusWebServer = new StatusWebServer(_statusReport, _config.Dashboard.Port);
|
StatusWeb = new StatusWebServer(StatusReportInstance, _config.Dashboard.Port);
|
||||||
_statusWebServer.Start();
|
StatusWeb.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire ServiceLevel updates from MXAccess health changes
|
// Wire ServiceLevel updates from MXAccess health changes
|
||||||
if (_config.Redundancy.Enabled)
|
if (_config.Redundancy.Enabled)
|
||||||
{
|
|
||||||
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
|
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
|
||||||
}
|
|
||||||
|
|
||||||
// Step 14
|
// Step 14
|
||||||
Log.Information("LmxOpcUa service started successfully");
|
Log.Information("LmxOpcUa service started successfully");
|
||||||
@@ -254,7 +308,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
@@ -263,8 +318,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
_changeDetection?.Stop();
|
ChangeDetectionInstance?.Stop();
|
||||||
_serverHost?.Stop();
|
ServerHost?.Stop();
|
||||||
|
|
||||||
if (_mxAccessClient != null)
|
if (_mxAccessClient != null)
|
||||||
{
|
{
|
||||||
@@ -272,11 +327,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
_mxAccessClient.DisconnectAsync().GetAwaiter().GetResult();
|
_mxAccessClient.DisconnectAsync().GetAwaiter().GetResult();
|
||||||
_mxAccessClient.Dispose();
|
_mxAccessClient.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
_staThread?.Dispose();
|
_staThread?.Dispose();
|
||||||
|
|
||||||
_statusWebServer?.Dispose();
|
StatusWeb?.Dispose();
|
||||||
_metrics?.Dispose();
|
Metrics?.Dispose();
|
||||||
_changeDetection?.Dispose();
|
ChangeDetectionInstance?.Dispose();
|
||||||
_cts?.Dispose();
|
_cts?.Dispose();
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
|
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
|
||||||
@@ -294,19 +350,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
Log.Information("Galaxy change detected — rebuilding address space");
|
Log.Information("Galaxy change detected — rebuilding address space");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_galaxyRepository == null || _nodeManager == null) return;
|
if (_galaxyRepository == null || NodeManagerInstance == null) return;
|
||||||
|
|
||||||
var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult();
|
var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult();
|
||||||
var attributes = _galaxyRepository.GetAttributesAsync().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;
|
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
|
||||||
_galaxyStats.AttributeCount = attributes.Count;
|
GalaxyStatsInstance.AttributeCount = attributes.Count;
|
||||||
_galaxyStats.LastRebuildTime = DateTime.UtcNow;
|
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
|
||||||
_galaxyStats.LastDeployTime = _changeDetection?.LastKnownDeployTime;
|
GalaxyStatsInstance.LastDeployTime = ChangeDetectionInstance?.LastKnownDeployTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -315,64 +371,27 @@ 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 mxConnected = e.CurrentState == ConnectionState.Connected;
|
||||||
var dbConnected = _galaxyStats?.DbConnected ?? false;
|
var dbConnected = GalaxyStatsInstance?.DbConnected ?? false;
|
||||||
_serverHost?.UpdateServiceLevel(mxConnected, dbConnected);
|
ServerHost?.UpdateServiceLevel(mxConnected, dbConnected);
|
||||||
Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected);
|
Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal void TriggerRebuild() => OnGalaxyChanged();
|
internal void TriggerRebuild()
|
||||||
|
{
|
||||||
// Accessors for testing
|
OnGalaxyChanged();
|
||||||
/// <summary>
|
}
|
||||||
/// Gets the MXAccess client instance currently wired into the service for test inspection.
|
|
||||||
/// </summary>
|
|
||||||
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the metrics collector that tracks bridge operation timings during the service lifetime.
|
|
||||||
/// </summary>
|
|
||||||
internal PerformanceMetrics? Metrics => _metrics;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the OPC UA server host that owns the runtime endpoint.
|
|
||||||
/// </summary>
|
|
||||||
internal OpcUaServerHost? ServerHost => _serverHost;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the node manager instance that holds the current Galaxy-derived address space.
|
|
||||||
/// </summary>
|
|
||||||
internal LmxNodeManager? NodeManagerInstance => _nodeManager;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild.
|
|
||||||
/// </summary>
|
|
||||||
internal ChangeDetectionService? ChangeDetectionInstance => _changeDetection;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the hosted status web server when the dashboard is enabled.
|
|
||||||
/// </summary>
|
|
||||||
internal StatusWebServer? StatusWeb => _statusWebServer;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the dashboard report generator used to assemble operator-facing status snapshots.
|
|
||||||
/// </summary>
|
|
||||||
internal StatusReportService? StatusReportInstance => _statusReport;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds.
|
|
||||||
/// </summary>
|
|
||||||
internal GalaxyRepositoryStats? GalaxyStatsInstance => _galaxyStats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -409,25 +428,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
/// Completes immediately because no live runtime connection is available or required.
|
/// Completes immediately because no live runtime connection is available or required.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
||||||
public System.Threading.Tasks.Task ConnectAsync(CancellationToken ct = default) => System.Threading.Tasks.Task.CompletedTask;
|
public Task ConnectAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Completes immediately because there is no live runtime session to close.
|
/// Completes immediately because there is no live runtime session to close.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public System.Threading.Tasks.Task DisconnectAsync() => System.Threading.Tasks.Task.CompletedTask;
|
public Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fullTagReference">The tag reference that would have been subscribed.</param>
|
/// <param name="fullTagReference">The tag reference that would have been subscribed.</param>
|
||||||
/// <param name="callback">The callback that would have received runtime value changes.</param>
|
/// <param name="callback">The callback that would have received runtime value changes.</param>
|
||||||
public System.Threading.Tasks.Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback) => System.Threading.Tasks.Task.CompletedTask;
|
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Completes immediately because the null client does not maintain runtime subscriptions.
|
/// Completes immediately because the null client does not maintain runtime subscriptions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fullTagReference">The tag reference that would have been unsubscribed.</param>
|
/// <param name="fullTagReference">The tag reference that would have been unsubscribed.</param>
|
||||||
public System.Threading.Tasks.Task UnsubscribeAsync(string fullTagReference) => System.Threading.Tasks.Task.CompletedTask;
|
public Task UnsubscribeAsync(string fullTagReference)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a bad-quality value because no live runtime source exists.
|
/// Returns a bad-quality value because no live runtime source exists.
|
||||||
@@ -435,7 +466,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
/// <param name="fullTagReference">The tag reference that would have been read from the runtime.</param>
|
/// <param name="fullTagReference">The tag reference that would have been read from the runtime.</param>
|
||||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
||||||
/// <returns>A bad-quality VTQ indicating that runtime data is unavailable.</returns>
|
/// <returns>A bad-quality VTQ indicating that runtime data is unavailable.</returns>
|
||||||
public System.Threading.Tasks.Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(Vtq.Bad());
|
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Vtq.Bad());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
@@ -444,11 +478,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
/// <param name="value">The value that would have been sent to the runtime.</param>
|
/// <param name="value">The value that would have been sent to the runtime.</param>
|
||||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
||||||
/// <returns>A completed task returning <see langword="false" />.</returns>
|
/// <returns>A completed task returning <see langword="false" />.</returns>
|
||||||
public System.Threading.Tasks.Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(false);
|
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Releases the null client. No unmanaged runtime resources exist.
|
/// Releases the null client. No unmanaged runtime resources exist.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose() { }
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||||
|
|
||||||
@@ -10,15 +13,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal class OpcUaServiceBuilder
|
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 IUserAuthenticationProvider? _authProvider;
|
||||||
private bool _authProviderSet;
|
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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replaces the default service configuration used by the test host.
|
/// Replaces the default service configuration used by the test host.
|
||||||
@@ -207,11 +210,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private class FakeBuilderGalaxyRepository : IGalaxyRepository
|
private class FakeBuilderGalaxyRepository : IGalaxyRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the fake repository wants to simulate a Galaxy deploy change.
|
|
||||||
/// </summary>
|
|
||||||
public event System.Action? OnGalaxyChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -222,37 +220,50 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
|
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Occurs when the fake repository wants to simulate a Galaxy deploy change.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnGalaxyChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the seeded hierarchy rows for address-space construction.
|
/// Returns the seeded hierarchy rows for address-space construction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||||
/// <returns>The configured hierarchy rows.</returns>
|
/// <returns>The configured hierarchy rows.</returns>
|
||||||
public System.Threading.Tasks.Task<List<GalaxyObjectInfo>> GetHierarchyAsync(System.Threading.CancellationToken ct = default)
|
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
=> System.Threading.Tasks.Task.FromResult(Hierarchy);
|
{
|
||||||
|
return Task.FromResult(Hierarchy);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the seeded attribute rows for address-space construction.
|
/// Returns the seeded attribute rows for address-space construction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||||
/// <returns>The configured attribute rows.</returns>
|
/// <returns>The configured attribute rows.</returns>
|
||||||
public System.Threading.Tasks.Task<List<GalaxyAttributeInfo>> GetAttributesAsync(System.Threading.CancellationToken ct = default)
|
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
=> System.Threading.Tasks.Task.FromResult(Attributes);
|
{
|
||||||
|
return Task.FromResult(Attributes);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||||
/// <returns>The current UTC time.</returns>
|
/// <returns>The current UTC time.</returns>
|
||||||
public System.Threading.Tasks.Task<System.DateTime?> GetLastDeployTimeAsync(System.Threading.CancellationToken ct = default)
|
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||||
=> System.Threading.Tasks.Task.FromResult<System.DateTime?>(System.DateTime.UtcNow);
|
{
|
||||||
|
return Task.FromResult<DateTime?>(DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reports a healthy repository connection for builder-based test setups.
|
/// Reports a healthy repository connection for builder-based test setups.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||||
/// <returns>A completed task returning <see langword="true" />.</returns>
|
/// <returns>A completed task returning <see langword="true" />.</returns>
|
||||||
public System.Threading.Tasks.Task<bool> TestConnectionAsync(System.Threading.CancellationToken ct = default)
|
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||||
=> System.Threading.Tasks.Task.FromResult(true);
|
{
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
{
|
{
|
||||||
internal static class Program
|
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
|
// Set working directory to exe location so relative log paths resolve correctly
|
||||||
// (Windows services default to System32)
|
// (Windows services default to System32)
|
||||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
.MinimumLevel.Information()
|
.MinimumLevel.Information()
|
||||||
.WriteTo.Console()
|
.WriteTo.Console()
|
||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
path: "logs/lmxopcua-.log",
|
"logs/lmxopcua-.log",
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
retainedFileCountLimit: 31)
|
retainedFileCountLimit: 31)
|
||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|||||||
@@ -18,32 +18,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
{
|
{
|
||||||
// Rule 1: Not connected → Unhealthy
|
// Rule 1: Not connected → Unhealthy
|
||||||
if (connectionState != ConnectionState.Connected)
|
if (connectionState != ConnectionState.Connected)
|
||||||
{
|
|
||||||
return new HealthInfo
|
return new HealthInfo
|
||||||
{
|
{
|
||||||
Status = "Unhealthy",
|
Status = "Unhealthy",
|
||||||
Message = $"MXAccess not connected (state: {connectionState})",
|
Message = $"MXAccess not connected (state: {connectionState})",
|
||||||
Color = "red"
|
Color = "red"
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 2: Success rate < 50% with > 100 ops → Degraded
|
// Rule 2: Success rate < 50% with > 100 ops → Degraded
|
||||||
if (metrics != null)
|
if (metrics != null)
|
||||||
{
|
{
|
||||||
var stats = metrics.GetStatistics();
|
var stats = metrics.GetStatistics();
|
||||||
foreach (var kvp in stats)
|
foreach (var kvp in stats)
|
||||||
{
|
|
||||||
if (kvp.Value.TotalCount > 100 && kvp.Value.SuccessRate < 0.5)
|
if (kvp.Value.TotalCount > 100 && kvp.Value.SuccessRate < 0.5)
|
||||||
{
|
|
||||||
return new HealthInfo
|
return new HealthInfo
|
||||||
{
|
{
|
||||||
Status = "Degraded",
|
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"
|
Color = "yellow"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 3: All good
|
// Rule 3: All good
|
||||||
return new HealthInfo
|
return new HealthInfo
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||||
@@ -17,17 +18,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
private readonly HealthCheckService _healthCheck;
|
private readonly HealthCheckService _healthCheck;
|
||||||
private readonly int _refreshIntervalSeconds;
|
private readonly int _refreshIntervalSeconds;
|
||||||
private readonly DateTime _startTime = DateTime.UtcNow;
|
private readonly DateTime _startTime = DateTime.UtcNow;
|
||||||
|
private string? _applicationUri;
|
||||||
|
private GalaxyRepositoryStats? _galaxyStats;
|
||||||
|
private PerformanceMetrics? _metrics;
|
||||||
|
|
||||||
private IMxAccessClient? _mxAccessClient;
|
private IMxAccessClient? _mxAccessClient;
|
||||||
private PerformanceMetrics? _metrics;
|
|
||||||
private GalaxyRepositoryStats? _galaxyStats;
|
|
||||||
private OpcUaServerHost? _serverHost;
|
|
||||||
private LmxNodeManager? _nodeManager;
|
private LmxNodeManager? _nodeManager;
|
||||||
private RedundancyConfiguration? _redundancyConfig;
|
private RedundancyConfiguration? _redundancyConfig;
|
||||||
private string? _applicationUri;
|
private OpcUaServerHost? _serverHost;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
|
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
|
||||||
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
|
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
|
||||||
@@ -44,7 +46,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
|
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
|
||||||
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
|
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
|
||||||
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
|
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
|
||||||
/// <param name="nodeManager">The node manager whose queue depth and MXAccess event throughput should be surfaced on the dashboard.</param>
|
/// <param name="nodeManager">
|
||||||
|
/// The node manager whose queue depth and MXAccess event throughput should be surfaced on the
|
||||||
|
/// dashboard.
|
||||||
|
/// </param>
|
||||||
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
|
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
|
||||||
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost,
|
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost,
|
||||||
LmxNodeManager? nodeManager = null,
|
LmxNodeManager? nodeManager = null,
|
||||||
@@ -96,7 +101,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
PendingItems = _nodeManager?.PendingDataChangeCount ?? 0,
|
PendingItems = _nodeManager?.PendingDataChangeCount ?? 0,
|
||||||
TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0
|
TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0
|
||||||
},
|
},
|
||||||
Operations = _metrics?.GetStatistics() ?? new(),
|
Operations = _metrics?.GetStatistics() ?? new Dictionary<string, MetricsStatistics>(),
|
||||||
Redundancy = BuildRedundancyInfo(),
|
Redundancy = BuildRedundancyInfo(),
|
||||||
Footer = new FooterInfo
|
Footer = new FooterInfo
|
||||||
{
|
{
|
||||||
@@ -126,7 +131,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
Role = _redundancyConfig.Role,
|
Role = _redundancyConfig.Role,
|
||||||
ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected),
|
ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected),
|
||||||
ApplicationUri = _applicationUri ?? "",
|
ApplicationUri = _applicationUri ?? "",
|
||||||
ServerUris = new System.Collections.Generic.List<string>(_redundancyConfig.ServerUris)
|
ServerUris = new List<string>(_redundancyConfig.ServerUris)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,16 +151,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
sb.AppendLine("<style>");
|
sb.AppendLine("<style>");
|
||||||
sb.AppendLine("body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }");
|
sb.AppendLine("body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }");
|
||||||
sb.AppendLine(".panel { border: 2px solid #444; border-radius: 8px; padding: 15px; margin: 10px 0; }");
|
sb.AppendLine(".panel { border: 2px solid #444; border-radius: 8px; padding: 15px; margin: 10px 0; }");
|
||||||
sb.AppendLine(".green { border-color: #00cc66; } .red { border-color: #cc3333; } .yellow { border-color: #cccc33; } .gray { border-color: #666; }");
|
sb.AppendLine(
|
||||||
sb.AppendLine("table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; }");
|
".green { border-color: #00cc66; } .red { border-color: #cc3333; } .yellow { border-color: #cccc33; } .gray { border-color: #666; }");
|
||||||
|
sb.AppendLine(
|
||||||
|
"table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; }");
|
||||||
sb.AppendLine("h2 { margin: 0 0 10px 0; } h1 { color: #66ccff; }");
|
sb.AppendLine("h2 { margin: 0 0 10px 0; } h1 { color: #66ccff; }");
|
||||||
sb.AppendLine("</style></head><body>");
|
sb.AppendLine("</style></head><body>");
|
||||||
sb.AppendLine("<h1>LmxOpcUa Status Dashboard</h1>");
|
sb.AppendLine("<h1>LmxOpcUa Status Dashboard</h1>");
|
||||||
|
|
||||||
// Connection panel
|
// 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($"<div class='panel {connColor}'><h2>Connection</h2>");
|
sb.AppendLine($"<div class='panel {connColor}'><h2>Connection</h2>");
|
||||||
sb.AppendLine($"<p>State: <b>{data.Connection.State}</b> | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}</p>");
|
sb.AppendLine(
|
||||||
|
$"<p>State: <b>{data.Connection.State}</b> | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}</p>");
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Health panel
|
// Health panel
|
||||||
@@ -168,7 +177,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
{
|
{
|
||||||
var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow";
|
var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow";
|
||||||
sb.AppendLine($"<div class='panel {roleColor}'><h2>Redundancy</h2>");
|
sb.AppendLine($"<div class='panel {roleColor}'><h2>Redundancy</h2>");
|
||||||
sb.AppendLine($"<p>Mode: <b>{data.Redundancy.Mode}</b> | Role: <b>{data.Redundancy.Role}</b> | Service Level: <b>{data.Redundancy.ServiceLevel}</b></p>");
|
sb.AppendLine(
|
||||||
|
$"<p>Mode: <b>{data.Redundancy.Mode}</b> | Role: <b>{data.Redundancy.Role}</b> | Service Level: <b>{data.Redundancy.ServiceLevel}</b></p>");
|
||||||
sb.AppendLine($"<p>Application URI: {data.Redundancy.ApplicationUri}</p>");
|
sb.AppendLine($"<p>Application URI: {data.Redundancy.ApplicationUri}</p>");
|
||||||
sb.AppendLine($"<p>Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}</p>");
|
sb.AppendLine($"<p>Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}</p>");
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
@@ -181,25 +191,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
|
|
||||||
// Data Change Dispatch panel
|
// Data Change Dispatch panel
|
||||||
sb.AppendLine("<div class='panel gray'><h2>Data Change Dispatch</h2>");
|
sb.AppendLine("<div class='panel gray'><h2>Data Change Dispatch</h2>");
|
||||||
sb.AppendLine($"<p>Events/sec: <b>{data.DataChange.EventsPerSecond:F1}</b> | Avg Batch Size: <b>{data.DataChange.AvgBatchSize:F1}</b> | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}</p>");
|
sb.AppendLine(
|
||||||
|
$"<p>Events/sec: <b>{data.DataChange.EventsPerSecond:F1}</b> | Avg Batch Size: <b>{data.DataChange.AvgBatchSize:F1}</b> | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}</p>");
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Galaxy Info panel
|
// Galaxy Info panel
|
||||||
sb.AppendLine("<div class='panel gray'><h2>Galaxy Info</h2>");
|
sb.AppendLine("<div class='panel gray'><h2>Galaxy Info</h2>");
|
||||||
sb.AppendLine($"<p>Galaxy: <b>{data.Galaxy.GalaxyName}</b> | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}</p>");
|
sb.AppendLine(
|
||||||
sb.AppendLine($"<p>Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}</p>");
|
$"<p>Galaxy: <b>{data.Galaxy.GalaxyName}</b> | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}</p>");
|
||||||
|
sb.AppendLine(
|
||||||
|
$"<p>Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}</p>");
|
||||||
sb.AppendLine($"<p>Last Rebuild: {data.Galaxy.LastRebuildTime:O}</p>");
|
sb.AppendLine($"<p>Last Rebuild: {data.Galaxy.LastRebuildTime:O}</p>");
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Operations table
|
// Operations table
|
||||||
sb.AppendLine("<div class='panel gray'><h2>Operations</h2>");
|
sb.AppendLine("<div class='panel gray'><h2>Operations</h2>");
|
||||||
sb.AppendLine("<table><tr><th>Operation</th><th>Count</th><th>Success Rate</th><th>Avg (ms)</th><th>Min (ms)</th><th>Max (ms)</th><th>P95 (ms)</th></tr>");
|
sb.AppendLine(
|
||||||
|
"<table><tr><th>Operation</th><th>Count</th><th>Success Rate</th><th>Avg (ms)</th><th>Min (ms)</th><th>Max (ms)</th><th>P95 (ms)</th></tr>");
|
||||||
foreach (var kvp in data.Operations)
|
foreach (var kvp in data.Operations)
|
||||||
{
|
{
|
||||||
var s = kvp.Value;
|
var s = kvp.Value;
|
||||||
sb.AppendLine($"<tr><td>{kvp.Key}</td><td>{s.TotalCount}</td><td>{s.SuccessRate:P1}</td>" +
|
sb.AppendLine($"<tr><td>{kvp.Key}</td><td>{s.TotalCount}</td><td>{s.SuccessRate:P1}</td>" +
|
||||||
$"<td>{s.AverageMilliseconds:F1}</td><td>{s.MinMilliseconds:F1}</td><td>{s.MaxMilliseconds:F1}</td><td>{s.Percentile95Milliseconds:F1}</td></tr>");
|
$"<td>{s.AverageMilliseconds:F1}</td><td>{s.MinMilliseconds:F1}</td><td>{s.MaxMilliseconds:F1}</td><td>{s.Percentile95Milliseconds:F1}</td></tr>");
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine("</table></div>");
|
sb.AppendLine("</table></div>");
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
@@ -250,7 +265,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
{
|
{
|
||||||
MxAccess = connectionState.ToString(),
|
MxAccess = connectionState.ToString(),
|
||||||
Database = dbConnected ? "Connected" : "Disconnected",
|
Database = dbConnected ? "Connected" : "Disconnected",
|
||||||
OpcUaServer = (_serverHost?.IsRunning ?? false) ? "Running" : "Stopped"
|
OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped"
|
||||||
},
|
},
|
||||||
Uptime = FormatUptime(uptime),
|
Uptime = FormatUptime(uptime),
|
||||||
Timestamp = DateTime.UtcNow
|
Timestamp = DateTime.UtcNow
|
||||||
@@ -304,13 +319,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
||||||
sb.AppendLine("<title>LmxOpcUa Health</title>");
|
sb.AppendLine("<title>LmxOpcUa Health</title>");
|
||||||
sb.AppendLine("<style>");
|
sb.AppendLine("<style>");
|
||||||
sb.AppendLine("body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; margin: 0; }");
|
sb.AppendLine(
|
||||||
|
"body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; margin: 0; }");
|
||||||
sb.AppendLine(".header { text-align: center; padding: 30px 0; }");
|
sb.AppendLine(".header { text-align: center; padding: 30px 0; }");
|
||||||
sb.AppendLine(".status-badge { display: inline-block; font-size: 2em; font-weight: bold; padding: 15px 40px; border-radius: 12px; letter-spacing: 2px; }");
|
sb.AppendLine(
|
||||||
|
".status-badge { display: inline-block; font-size: 2em; font-weight: bold; padding: 15px 40px; border-radius: 12px; letter-spacing: 2px; }");
|
||||||
sb.AppendLine(".service-level { text-align: center; font-size: 4em; font-weight: bold; margin: 20px 0; }");
|
sb.AppendLine(".service-level { text-align: center; font-size: 4em; font-weight: bold; margin: 20px 0; }");
|
||||||
sb.AppendLine(".service-level .label { font-size: 0.3em; color: #999; display: block; }");
|
sb.AppendLine(".service-level .label { font-size: 0.3em; color: #999; display: block; }");
|
||||||
sb.AppendLine(".components { display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin: 30px auto; max-width: 800px; }");
|
sb.AppendLine(
|
||||||
sb.AppendLine(".component { border: 2px solid #444; border-radius: 8px; padding: 20px; min-width: 200px; text-align: center; }");
|
".components { display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin: 30px auto; max-width: 800px; }");
|
||||||
|
sb.AppendLine(
|
||||||
|
".component { border: 2px solid #444; border-radius: 8px; padding: 20px; min-width: 200px; text-align: center; }");
|
||||||
sb.AppendLine(".component .name { font-size: 0.9em; color: #999; margin-bottom: 8px; }");
|
sb.AppendLine(".component .name { font-size: 0.9em; color: #999; margin-bottom: 8px; }");
|
||||||
sb.AppendLine(".component .value { font-size: 1.3em; font-weight: bold; }");
|
sb.AppendLine(".component .value { font-size: 1.3em; font-weight: bold; }");
|
||||||
sb.AppendLine(".meta { text-align: center; margin-top: 30px; color: #666; font-size: 0.85em; }");
|
sb.AppendLine(".meta { text-align: center; margin-top: 30px; color: #666; font-size: 0.85em; }");
|
||||||
@@ -320,7 +339,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
|
|
||||||
// Status badge
|
// Status badge
|
||||||
sb.AppendLine("<div class='header'>");
|
sb.AppendLine("<div class='header'>");
|
||||||
sb.AppendLine($"<div class='status-badge' style='background: {statusColor}; color: #000;'>{data.Status.ToUpperInvariant()}</div>");
|
sb.AppendLine(
|
||||||
|
$"<div class='status-badge' style='background: {statusColor}; color: #000;'>{data.Status.ToUpperInvariant()}</div>");
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Service Level
|
// Service Level
|
||||||
@@ -331,15 +351,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
|
|
||||||
// Redundancy info
|
// Redundancy info
|
||||||
if (data.RedundancyEnabled)
|
if (data.RedundancyEnabled)
|
||||||
{
|
sb.AppendLine(
|
||||||
sb.AppendLine($"<div class='redundancy'>Role: <b>{data.RedundancyRole}</b> | Mode: <b>{data.RedundancyMode}</b></div>");
|
$"<div class='redundancy'>Role: <b>{data.RedundancyRole}</b> | Mode: <b>{data.RedundancyMode}</b></div>");
|
||||||
}
|
|
||||||
|
|
||||||
// Component health cards
|
// Component health cards
|
||||||
sb.AppendLine("<div class='components'>");
|
sb.AppendLine("<div class='components'>");
|
||||||
sb.AppendLine($"<div class='component' style='border-color: {mxColor};'><div class='name'>MXAccess</div><div class='value' style='color: {mxColor};'>{data.Components.MxAccess}</div></div>");
|
sb.AppendLine(
|
||||||
sb.AppendLine($"<div class='component' style='border-color: {dbColor};'><div class='name'>Galaxy Database</div><div class='value' style='color: {dbColor};'>{data.Components.Database}</div></div>");
|
$"<div class='component' style='border-color: {mxColor};'><div class='name'>MXAccess</div><div class='value' style='color: {mxColor};'>{data.Components.MxAccess}</div></div>");
|
||||||
sb.AppendLine($"<div class='component' style='border-color: {uaColor};'><div class='name'>OPC UA Server</div><div class='value' style='color: {uaColor};'>{data.Components.OpcUaServer}</div></div>");
|
sb.AppendLine(
|
||||||
|
$"<div class='component' style='border-color: {dbColor};'><div class='name'>Galaxy Database</div><div class='value' style='color: {dbColor};'>{data.Components.Database}</div></div>");
|
||||||
|
sb.AppendLine(
|
||||||
|
$"<div class='component' style='border-color: {uaColor};'><div class='name'>OPC UA Server</div><div class='value' style='color: {uaColor};'>{data.Components.OpcUaServer}</div></div>");
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
|
|||||||
@@ -13,16 +13,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
public class StatusWebServer : IDisposable
|
public class StatusWebServer : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<StatusWebServer>();
|
private static readonly ILogger Log = Serilog.Log.ForContext<StatusWebServer>();
|
||||||
|
private readonly int _port;
|
||||||
|
|
||||||
private readonly StatusReportService _reportService;
|
private readonly StatusReportService _reportService;
|
||||||
private readonly int _port;
|
|
||||||
private HttpListener? _listener;
|
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
private HttpListener? _listener;
|
||||||
/// <summary>
|
|
||||||
/// Gets a value indicating whether the dashboard listener is currently accepting requests.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsRunning => _listener?.IsListening ?? false;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new dashboard web server bound to the supplied report service and HTTP port.
|
/// Initializes a new dashboard web server bound to the supplied report service and HTTP port.
|
||||||
@@ -35,6 +30,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
_port = port;
|
_port = port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the dashboard listener is currently accepting requests.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRunning => _listener?.IsListening ?? false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the dashboard listener and releases its resources.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts the HTTP listener and background request loop for the status dashboard.
|
/// Starts the HTTP listener and background request loop for the status dashboard.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -69,7 +77,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
_listener?.Stop();
|
_listener?.Stop();
|
||||||
_listener?.Close();
|
_listener?.Close();
|
||||||
}
|
}
|
||||||
catch { /* ignore */ }
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
_listener = null;
|
_listener = null;
|
||||||
Log.Information("Status dashboard stopped");
|
Log.Information("Status dashboard stopped");
|
||||||
}
|
}
|
||||||
@@ -77,20 +89,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
private async Task ListenLoopAsync(CancellationToken ct)
|
private async Task ListenLoopAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested && _listener != null && _listener.IsListening)
|
while (!ct.IsCancellationRequested && _listener != null && _listener.IsListening)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var context = await _listener.GetContextAsync();
|
var context = await _listener.GetContextAsync();
|
||||||
_ = HandleRequestAsync(context);
|
_ = HandleRequestAsync(context);
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException) { break; }
|
catch (ObjectDisposedException)
|
||||||
catch (HttpListenerException) { break; }
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (HttpListenerException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Dashboard listener error");
|
Log.Warning(ex, "Dashboard listener error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||||
{
|
{
|
||||||
@@ -144,11 +160,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Error handling dashboard request");
|
Log.Warning(ex, "Error handling dashboard request");
|
||||||
try { context.Response.Close(); } catch { }
|
try
|
||||||
|
{
|
||||||
|
context.Response.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteResponse(HttpListenerResponse response, string body, string contentType, int statusCode)
|
private static async Task WriteResponse(HttpListenerResponse response, string body, string contentType,
|
||||||
|
int statusCode)
|
||||||
{
|
{
|
||||||
var buffer = Encoding.UTF8.GetBytes(body);
|
var buffer = Encoding.UTF8.GetBytes(body);
|
||||||
response.StatusCode = statusCode;
|
response.StatusCode = statusCode;
|
||||||
@@ -157,10 +180,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
|||||||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||||||
response.Close();
|
response.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the dashboard listener and releases its resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose() => Stop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"Profiles": ["None"],
|
"Profiles": [
|
||||||
|
"None"
|
||||||
|
],
|
||||||
"AutoAcceptClientCertificates": true,
|
"AutoAcceptClientCertificates": true,
|
||||||
"RejectSHA1Certificates": true,
|
"RejectSHA1Certificates": true,
|
||||||
"MinimumCertificateKeySize": 2048,
|
"MinimumCertificateKeySize": 2048,
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ public class AlarmsCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
@@ -47,10 +44,7 @@ public class AlarmsCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
@@ -74,10 +68,7 @@ public class AlarmsCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
@@ -104,10 +95,7 @@ public class AlarmsCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
@@ -129,10 +117,7 @@ public class AlarmsCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
@@ -153,10 +138,7 @@ public class AlarmsCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using Shouldly;
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
|
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests.Fakes;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
|
||||||
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
|
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
|
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Tests;
|
||||||
@@ -16,9 +15,9 @@ public class BrowseCommandTests
|
|||||||
{
|
{
|
||||||
BrowseResults = new List<BrowseResult>
|
BrowseResults = new List<BrowseResult>
|
||||||
{
|
{
|
||||||
new BrowseResult("ns=2;s=Obj1", "Object1", "Object", true),
|
new("ns=2;s=Obj1", "Object1", "Object", true),
|
||||||
new BrowseResult("ns=2;s=Var1", "Variable1", "Variable", false),
|
new("ns=2;s=Var1", "Variable1", "Variable", false),
|
||||||
new BrowseResult("ns=2;s=Meth1", "Method1", "Method", false)
|
new("ns=2;s=Meth1", "Method1", "Method", false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||||
@@ -85,7 +84,7 @@ public class BrowseCommandTests
|
|||||||
{
|
{
|
||||||
BrowseResults = new List<BrowseResult>
|
BrowseResults = new List<BrowseResult>
|
||||||
{
|
{
|
||||||
new BrowseResult("ns=2;s=Child", "Child", "Object", true)
|
new("ns=2;s=Child", "Child", "Object", true)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using CliFx.Infrastructure;
|
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ public class ConnectCommandTests
|
|||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
// The command should propagate the exception but still clean up.
|
// The command should propagate the exception but still clean up.
|
||||||
// Since connect fails, service is null in finally, so no disconnect.
|
// Since connect fails, service is null in finally, so no disconnect.
|
||||||
await Should.ThrowAsync<InvalidOperationException>(
|
await Should.ThrowAsync<InvalidOperationException>(async () => await command.ExecuteAsync(console));
|
||||||
async () => await command.ExecuteAsync(console));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,20 +16,24 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
public ConnectionSettings? LastConnectionSettings { get; private set; }
|
public ConnectionSettings? LastConnectionSettings { get; private set; }
|
||||||
public bool DisconnectCalled { get; private set; }
|
public bool DisconnectCalled { get; private set; }
|
||||||
public bool DisposeCalled { get; private set; }
|
public bool DisposeCalled { get; private set; }
|
||||||
public List<NodeId> ReadNodeIds { get; } = new();
|
public List<NodeId> ReadNodeIds { get; } = [];
|
||||||
public List<(NodeId NodeId, object Value)> WriteValues { get; } = new();
|
public List<(NodeId NodeId, object Value)> WriteValues { get; } = [];
|
||||||
public List<NodeId?> BrowseNodeIds { get; } = new();
|
public List<NodeId?> BrowseNodeIds { get; } = [];
|
||||||
public List<(NodeId NodeId, int IntervalMs)> SubscribeCalls { get; } = new();
|
public List<(NodeId NodeId, int IntervalMs)> SubscribeCalls { get; } = [];
|
||||||
public List<NodeId> UnsubscribeCalls { get; } = new();
|
public List<NodeId> UnsubscribeCalls { get; } = [];
|
||||||
public List<(NodeId? SourceNodeId, int IntervalMs)> SubscribeAlarmsCalls { get; } = new();
|
public List<(NodeId? SourceNodeId, int IntervalMs)> SubscribeAlarmsCalls { get; } = [];
|
||||||
public bool UnsubscribeAlarmsCalled { get; private set; }
|
public bool UnsubscribeAlarmsCalled { get; private set; }
|
||||||
public bool RequestConditionRefreshCalled { get; private set; }
|
public bool RequestConditionRefreshCalled { get; private set; }
|
||||||
public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadRawCalls { get; } = new();
|
public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadRawCalls { get; } = [];
|
||||||
public List<(NodeId NodeId, DateTime Start, DateTime End, AggregateType Aggregate, double IntervalMs)> HistoryReadAggregateCalls { get; } = new();
|
|
||||||
|
public List<(NodeId NodeId, DateTime Start, DateTime End, AggregateType Aggregate, double IntervalMs)>
|
||||||
|
HistoryReadAggregateCalls { get; } =
|
||||||
|
[];
|
||||||
|
|
||||||
public bool GetRedundancyInfoCalled { get; private set; }
|
public bool GetRedundancyInfoCalled { get; private set; }
|
||||||
|
|
||||||
// Configurable results
|
// Configurable results
|
||||||
public ConnectionInfo ConnectionInfoResult { get; set; } = new ConnectionInfo(
|
public ConnectionInfo ConnectionInfoResult { get; set; } = new(
|
||||||
"opc.tcp://localhost:4840",
|
"opc.tcp://localhost:4840",
|
||||||
"TestServer",
|
"TestServer",
|
||||||
"None",
|
"None",
|
||||||
@@ -37,7 +41,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
"session-1",
|
"session-1",
|
||||||
"TestSession");
|
"TestSession");
|
||||||
|
|
||||||
public DataValue ReadValueResult { get; set; } = new DataValue(
|
public DataValue ReadValueResult { get; set; } = new(
|
||||||
new Variant(42),
|
new Variant(42),
|
||||||
StatusCodes.Good,
|
StatusCodes.Good,
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
@@ -47,18 +51,18 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
|
|
||||||
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = new List<BrowseResult>
|
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = new List<BrowseResult>
|
||||||
{
|
{
|
||||||
new BrowseResult("ns=2;s=Node1", "Node1", "Object", true),
|
new("ns=2;s=Node1", "Node1", "Object", true),
|
||||||
new BrowseResult("ns=2;s=Node2", "Node2", "Variable", false)
|
new("ns=2;s=Node2", "Node2", "Variable", false)
|
||||||
};
|
};
|
||||||
|
|
||||||
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
|
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
|
||||||
{
|
{
|
||||||
new DataValue(new Variant(10.0), StatusCodes.Good, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow),
|
new(new Variant(10.0), StatusCodes.Good, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow),
|
||||||
new DataValue(new Variant(20.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
|
new(new Variant(20.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
|
||||||
};
|
};
|
||||||
|
|
||||||
public RedundancyInfo RedundancyInfoResult { get; set; } = new RedundancyInfo(
|
public RedundancyInfo RedundancyInfoResult { get; set; } = new(
|
||||||
"Warm", 200, new[] { "urn:server1", "urn:server2" }, "urn:app:test");
|
"Warm", 200, ["urn:server1", "urn:server2"], "urn:app:test");
|
||||||
|
|
||||||
public Exception? ConnectException { get; set; }
|
public Exception? ConnectException { get; set; }
|
||||||
public Exception? ReadException { get; set; }
|
public Exception? ReadException { get; set; }
|
||||||
@@ -159,6 +163,11 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
return Task.FromResult(RedundancyInfoResult);
|
return Task.FromResult(RedundancyInfoResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Raises the DataChanged event for testing subscribe commands.</summary>
|
/// <summary>Raises the DataChanged event for testing subscribe commands.</summary>
|
||||||
public void RaiseDataChanged(string nodeId, DataValue value)
|
public void RaiseDataChanged(string nodeId, DataValue value)
|
||||||
{
|
{
|
||||||
@@ -176,9 +185,4 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
{
|
{
|
||||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
|
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
DisposeCalled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -14,5 +14,8 @@ public sealed class FakeOpcUaClientServiceFactory : IOpcUaClientServiceFactory
|
|||||||
_service = service;
|
_service = service;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IOpcUaClientService Create() => _service;
|
public IOpcUaClientService Create()
|
||||||
|
{
|
||||||
|
return _service;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,8 @@ public class HistoryReadCommandTests
|
|||||||
{
|
{
|
||||||
HistoryReadResult = new List<DataValue>
|
HistoryReadResult = new List<DataValue>
|
||||||
{
|
{
|
||||||
new DataValue(new Variant(10.5), StatusCodes.Good, time1, time1),
|
new(new Variant(10.5), StatusCodes.Good, time1, time1),
|
||||||
new DataValue(new Variant(20.3), StatusCodes.Good, time2, time2)
|
new(new Variant(20.3), StatusCodes.Good, time2, time2)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||||
@@ -117,8 +117,7 @@ public class HistoryReadCommandTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
await Should.ThrowAsync<ArgumentException>(
|
await Should.ThrowAsync<ArgumentException>(async () => await command.ExecuteAsync(console));
|
||||||
async () => await command.ExecuteAsync(console));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
// This file intentionally left empty. Real tests are in separate files.
|
// This file intentionally left empty. Real tests are in separate files.
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,7 @@ public class ReadCommandTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
await Should.ThrowAsync<InvalidOperationException>(
|
await Should.ThrowAsync<InvalidOperationException>(async () => await command.ExecuteAsync(console));
|
||||||
async () => await command.ExecuteAsync(console));
|
|
||||||
|
|
||||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||||
fakeService.DisposeCalled.ShouldBeTrue();
|
fakeService.DisposeCalled.ShouldBeTrue();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public class RedundancyCommandTests
|
|||||||
var fakeService = new FakeOpcUaClientService
|
var fakeService = new FakeOpcUaClientService
|
||||||
{
|
{
|
||||||
RedundancyInfoResult = new RedundancyInfo(
|
RedundancyInfoResult = new RedundancyInfo(
|
||||||
"Hot", 250, new[] { "urn:server:primary", "urn:server:secondary" }, "urn:app:myserver")
|
"Hot", 250, ["urn:server:primary", "urn:server:secondary"], "urn:app:myserver")
|
||||||
};
|
};
|
||||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||||
var command = new RedundancyCommand(factory)
|
var command = new RedundancyCommand(factory)
|
||||||
@@ -40,7 +40,7 @@ public class RedundancyCommandTests
|
|||||||
var fakeService = new FakeOpcUaClientService
|
var fakeService = new FakeOpcUaClientService
|
||||||
{
|
{
|
||||||
RedundancyInfoResult = new RedundancyInfo(
|
RedundancyInfoResult = new RedundancyInfo(
|
||||||
"None", 100, Array.Empty<string>(), "urn:app:standalone")
|
"None", 100, [], "urn:app:standalone")
|
||||||
};
|
};
|
||||||
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
var factory = new FakeOpcUaClientServiceFactory(fakeService);
|
||||||
var command = new RedundancyCommand(factory)
|
var command = new RedundancyCommand(factory)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using Opc.Ua;
|
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
|
||||||
@@ -24,10 +23,7 @@ public class SubscribeCommandTests
|
|||||||
|
|
||||||
// The subscribe command waits for cancellation. We need to cancel it.
|
// The subscribe command waits for cancellation. We need to cancel it.
|
||||||
// Use the console's cancellation to trigger stop.
|
// Use the console's cancellation to trigger stop.
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Give it a moment to subscribe, then cancel
|
// Give it a moment to subscribe, then cancel
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
@@ -52,10 +48,7 @@ public class SubscribeCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
@@ -77,10 +70,7 @@ public class SubscribeCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
@@ -104,10 +94,7 @@ public class SubscribeCommandTests
|
|||||||
|
|
||||||
using var console = TestConsoleHelper.CreateConsole();
|
using var console = TestConsoleHelper.CreateConsole();
|
||||||
|
|
||||||
var task = Task.Run(async () =>
|
var task = Task.Run(async () => { await command.ExecuteAsync(console); });
|
||||||
{
|
|
||||||
await command.ExecuteAsync(console);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Task.Delay(100);
|
await Task.Delay(100);
|
||||||
console.RequestCancellation();
|
console.RequestCancellation();
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ internal sealed class FakeEndpointDiscovery : IEndpointDiscovery
|
|||||||
public int SelectCallCount { get; private set; }
|
public int SelectCallCount { get; private set; }
|
||||||
public string? LastEndpointUrl { get; private set; }
|
public string? LastEndpointUrl { get; private set; }
|
||||||
|
|
||||||
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode)
|
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||||
|
MessageSecurityMode requestedMode)
|
||||||
{
|
{
|
||||||
SelectCallCount++;
|
SelectCallCount++;
|
||||||
LastEndpointUrl = endpointUrl;
|
LastEndpointUrl = endpointUrl;
|
||||||
|
|||||||
@@ -5,17 +5,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
|
|||||||
|
|
||||||
internal sealed class FakeSessionAdapter : ISessionAdapter
|
internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||||
{
|
{
|
||||||
|
private readonly List<FakeSubscriptionAdapter> _createdSubscriptions = [];
|
||||||
private Action<bool>? _keepAliveCallback;
|
private Action<bool>? _keepAliveCallback;
|
||||||
private readonly List<FakeSubscriptionAdapter> _createdSubscriptions = new();
|
|
||||||
|
|
||||||
public bool Connected { get; set; } = true;
|
|
||||||
public string SessionId { get; set; } = "ns=0;i=12345";
|
|
||||||
public string SessionName { get; set; } = "FakeSession";
|
|
||||||
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
|
||||||
public string ServerName { get; set; } = "FakeServer";
|
|
||||||
public string SecurityMode { get; set; } = "None";
|
|
||||||
public string SecurityPolicyUri { get; set; } = "http://opcfoundation.org/UA/SecurityPolicy#None";
|
|
||||||
public NamespaceTable NamespaceUris { get; set; } = new();
|
|
||||||
|
|
||||||
public bool Closed { get; private set; }
|
public bool Closed { get; private set; }
|
||||||
public bool Disposed { get; private set; }
|
public bool Disposed { get; private set; }
|
||||||
@@ -35,14 +26,14 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
public bool ThrowOnWrite { get; set; }
|
public bool ThrowOnWrite { get; set; }
|
||||||
public bool ThrowOnBrowse { get; set; }
|
public bool ThrowOnBrowse { get; set; }
|
||||||
|
|
||||||
public ReferenceDescriptionCollection BrowseResponse { get; set; } = new();
|
public ReferenceDescriptionCollection BrowseResponse { get; set; } = [];
|
||||||
public byte[]? BrowseContinuationPoint { get; set; }
|
public byte[]? BrowseContinuationPoint { get; set; }
|
||||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = new();
|
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
||||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||||
public bool HasChildrenResponse { get; set; } = false;
|
public bool HasChildrenResponse { get; set; } = false;
|
||||||
|
|
||||||
public List<DataValue> HistoryReadRawResponse { get; set; } = new();
|
public List<DataValue> HistoryReadRawResponse { get; set; } = [];
|
||||||
public List<DataValue> HistoryReadAggregateResponse { get; set; } = new();
|
public List<DataValue> HistoryReadAggregateResponse { get; set; } = [];
|
||||||
public bool ThrowOnHistoryReadRaw { get; set; }
|
public bool ThrowOnHistoryReadRaw { get; set; }
|
||||||
public bool ThrowOnHistoryReadAggregate { get; set; }
|
public bool ThrowOnHistoryReadAggregate { get; set; }
|
||||||
|
|
||||||
@@ -54,19 +45,20 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
|
|
||||||
public IReadOnlyList<FakeSubscriptionAdapter> CreatedSubscriptions => _createdSubscriptions;
|
public IReadOnlyList<FakeSubscriptionAdapter> CreatedSubscriptions => _createdSubscriptions;
|
||||||
|
|
||||||
|
public bool Connected { get; set; } = true;
|
||||||
|
public string SessionId { get; set; } = "ns=0;i=12345";
|
||||||
|
public string SessionName { get; set; } = "FakeSession";
|
||||||
|
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
||||||
|
public string ServerName { get; set; } = "FakeServer";
|
||||||
|
public string SecurityMode { get; set; } = "None";
|
||||||
|
public string SecurityPolicyUri { get; set; } = "http://opcfoundation.org/UA/SecurityPolicy#None";
|
||||||
|
public NamespaceTable NamespaceUris { get; set; } = new();
|
||||||
|
|
||||||
public void RegisterKeepAliveHandler(Action<bool> callback)
|
public void RegisterKeepAliveHandler(Action<bool> callback)
|
||||||
{
|
{
|
||||||
_keepAliveCallback = callback;
|
_keepAliveCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Simulates a keep-alive event.
|
|
||||||
/// </summary>
|
|
||||||
public void SimulateKeepAlive(bool isGood)
|
|
||||||
{
|
|
||||||
_keepAliveCallback?.Invoke(isGood);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct)
|
public Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ReadCount++;
|
ReadCount++;
|
||||||
@@ -119,7 +111,8 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
|
public Task<IReadOnlyList<DataValue>> 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)
|
||||||
{
|
{
|
||||||
HistoryReadAggregateCount++;
|
HistoryReadAggregateCount++;
|
||||||
if (ThrowOnHistoryReadAggregate)
|
if (ThrowOnHistoryReadAggregate)
|
||||||
@@ -147,4 +140,12 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
|||||||
Disposed = true;
|
Disposed = true;
|
||||||
Connected = false;
|
Connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simulates a keep-alive event.
|
||||||
|
/// </summary>
|
||||||
|
public void SimulateKeepAlive(bool isGood)
|
||||||
|
{
|
||||||
|
_keepAliveCallback?.Invoke(isGood);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,21 +5,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes;
|
|||||||
|
|
||||||
internal sealed class FakeSessionFactory : ISessionFactory
|
internal sealed class FakeSessionFactory : ISessionFactory
|
||||||
{
|
{
|
||||||
|
private readonly List<FakeSessionAdapter> _createdSessions = [];
|
||||||
private readonly Queue<FakeSessionAdapter> _sessions = new();
|
private readonly Queue<FakeSessionAdapter> _sessions = new();
|
||||||
private readonly List<FakeSessionAdapter> _createdSessions = new();
|
|
||||||
|
|
||||||
public int CreateCallCount { get; private set; }
|
public int CreateCallCount { get; private set; }
|
||||||
public bool ThrowOnCreate { get; set; }
|
public bool ThrowOnCreate { get; set; }
|
||||||
public string? LastEndpointUrl { get; private set; }
|
public string? LastEndpointUrl { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enqueues a session adapter to be returned on the next call to CreateSessionAsync.
|
|
||||||
/// </summary>
|
|
||||||
public void EnqueueSession(FakeSessionAdapter session)
|
|
||||||
{
|
|
||||||
_sessions.Enqueue(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<FakeSessionAdapter> CreatedSessions => _createdSessions;
|
public IReadOnlyList<FakeSessionAdapter> CreatedSessions => _createdSessions;
|
||||||
|
|
||||||
public Task<ISessionAdapter> CreateSessionAsync(
|
public Task<ISessionAdapter> CreateSessionAsync(
|
||||||
@@ -34,11 +26,8 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
|||||||
|
|
||||||
FakeSessionAdapter session;
|
FakeSessionAdapter session;
|
||||||
if (_sessions.Count > 0)
|
if (_sessions.Count > 0)
|
||||||
{
|
|
||||||
session = _sessions.Dequeue();
|
session = _sessions.Dequeue();
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
session = new FakeSessionAdapter
|
session = new FakeSessionAdapter
|
||||||
{
|
{
|
||||||
EndpointUrl = endpoint.EndpointUrl,
|
EndpointUrl = endpoint.EndpointUrl,
|
||||||
@@ -46,11 +35,18 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
|||||||
SecurityMode = endpoint.SecurityMode.ToString(),
|
SecurityMode = endpoint.SecurityMode.ToString(),
|
||||||
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
|
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure endpoint URL matches
|
// Ensure endpoint URL matches
|
||||||
session.EndpointUrl = endpoint.EndpointUrl;
|
session.EndpointUrl = endpoint.EndpointUrl;
|
||||||
_createdSessions.Add(session);
|
_createdSessions.Add(session);
|
||||||
return Task.FromResult<ISessionAdapter>(session);
|
return Task.FromResult<ISessionAdapter>(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a session adapter to be returned on the next call to CreateSessionAsync.
|
||||||
|
/// </summary>
|
||||||
|
public void EnqueueSession(FakeSessionAdapter session)
|
||||||
|
{
|
||||||
|
_sessions.Enqueue(session);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user