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:
Joseph Doherty
2026-03-31 07:58:13 -04:00
parent 55ef854612
commit 41a6b66943
221 changed files with 4274 additions and 3823 deletions

View File

@@ -9,7 +9,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI;
/// <summary>
/// Abstract base class for all CLI commands providing common connection options and helpers.
/// Abstract base class for all CLI commands providing common connection options and helpers.
/// </summary>
public abstract class CommandBase : ICommand
{
@@ -31,7 +31,8 @@ public abstract class CommandBase : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt, signandencrypt (default: none)")]
[CommandOption("security", 'S',
Description = "Transport security: none, sign, encrypt, signandencrypt (default: none)")]
public string Security { get; init; } = "none";
[CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")]
@@ -40,8 +41,10 @@ public abstract class CommandBase : ICommand
[CommandOption("verbose", Description = "Enable verbose/debug logging")]
public bool Verbose { get; init; }
public abstract ValueTask ExecuteAsync(IConsole console);
/// <summary>
/// Creates a <see cref="ConnectionSettings"/> from the common command options.
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
/// </summary>
protected ConnectionSettings CreateConnectionSettings()
{
@@ -64,10 +67,11 @@ public abstract class CommandBase : ICommand
}
/// <summary>
/// Creates a new <see cref="IOpcUaClientService"/>, connects it using the common options,
/// and returns both the service and the connection info.
/// Creates a new <see cref="IOpcUaClientService" />, connects it using the common options,
/// and returns both the service and the connection info.
/// </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 settings = CreateConnectionSettings();
@@ -76,24 +80,18 @@ public abstract class CommandBase : ICommand
}
/// <summary>
/// Configures Serilog based on the verbose flag.
/// Configures Serilog based on the verbose flag.
/// </summary>
protected void ConfigureLogging()
{
var config = new LoggerConfiguration();
if (Verbose)
{
config.MinimumLevel.Debug()
.WriteTo.Console();
}
.WriteTo.Console();
else
{
config.MinimumLevel.Warning()
.WriteTo.Console();
}
.WriteTo.Console();
Log.Logger = config.CreateLogger();
}
public abstract ValueTask ExecuteAsync(IConsole console);
}

View File

@@ -8,6 +8,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("alarms", Description = "Subscribe to alarm events")]
public class AlarmsCommand : CommandBase
{
public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
[CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
public string? NodeId { get; init; }
@@ -17,8 +21,6 @@ public class AlarmsCommand : CommandBase
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
public bool Refresh { get; init; }
public AlarmsCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -49,7 +51,6 @@ public class AlarmsCommand : CommandBase
$"Subscribed to alarm events (interval: {Interval}ms). Press Ctrl+C to stop.");
if (Refresh)
{
try
{
await service.RequestConditionRefreshAsync(ct);
@@ -59,7 +60,6 @@ public class AlarmsCommand : CommandBase
{
await console.Output.WriteLineAsync($"Condition refresh not supported: {ex.Message}");
}
}
// Wait until cancellation
try
@@ -71,7 +71,7 @@ public class AlarmsCommand : CommandBase
// Expected on Ctrl+C
}
await service.UnsubscribeAlarmsAsync(default);
await service.UnsubscribeAlarmsAsync();
await console.Output.WriteLineAsync("Unsubscribed.");
}
finally

View File

@@ -3,13 +3,16 @@ using CliFx.Infrastructure;
using Opc.Ua;
using ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("browse", Description = "Browse the OPC UA address space")]
public class BrowseCommand : CommandBase
{
public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
[CommandOption("node", 'n', Description = "Node ID to browse (default: Objects folder)")]
public string? NodeId { get; init; }
@@ -19,8 +22,6 @@ public class BrowseCommand : CommandBase
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
public bool Recursive { get; init; }
public BrowseCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();

View File

@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("connect", Description = "Test connection to an OPC UA server")]
public class ConnectCommand : CommandBase
{
public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public ConnectCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
public override async ValueTask ExecuteAsync(IConsole console)
{

View File

@@ -10,6 +10,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("historyread", Description = "Read historical data from a node")]
public class HistoryReadCommand : CommandBase
{
public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
@@ -28,8 +32,6 @@ public class HistoryReadCommand : CommandBase
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
public double IntervalMs { get; init; } = 3600000;
public HistoryReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();

View File

@@ -8,11 +8,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("read", Description = "Read a value from a node")]
public class ReadCommand : CommandBase
{
public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
public ReadCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();

View File

@@ -7,7 +7,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
public class RedundancyCommand : CommandBase
{
public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public RedundancyCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -26,10 +28,7 @@ public class RedundancyCommand : CommandBase
if (info.ServerUris.Length > 0)
{
await console.Output.WriteLineAsync("Server URIs:");
foreach (var uri in info.ServerUris)
{
await console.Output.WriteLineAsync($" - {uri}");
}
foreach (var uri in info.ServerUris) await console.Output.WriteLineAsync($" - {uri}");
}
await console.Output.WriteLineAsync($"Application URI: {info.ApplicationUri}");

View File

@@ -8,14 +8,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("subscribe", Description = "Monitor a node for value changes")]
public class SubscribeCommand : CommandBase
{
public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
[CommandOption("node", 'n', Description = "Node ID to monitor", IsRequired = true)]
public string NodeId { get; init; } = default!;
[CommandOption("interval", 'i', Description = "Sampling interval in milliseconds")]
public int Interval { get; init; } = 1000;
public SubscribeCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -47,7 +49,7 @@ public class SubscribeCommand : CommandBase
// Expected on Ctrl+C
}
await service.UnsubscribeAsync(nodeId, default);
await service.UnsubscribeAsync(nodeId);
await console.Output.WriteLineAsync("Unsubscribed.");
}
finally

View File

@@ -10,14 +10,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Commands;
[Command("write", Description = "Write a value to a node")]
public class WriteCommand : CommandBase
{
public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
[CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
public string Value { get; init; } = default!;
public WriteCommand(IOpcUaClientServiceFactory factory) : base(factory) { }
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();

View File

@@ -3,16 +3,16 @@ using Opc.Ua;
namespace ZB.MOM.WW.LmxOpcUa.Client.CLI.Helpers;
/// <summary>
/// Parses node ID strings into OPC UA <see cref="NodeId"/> objects.
/// Supports standard OPC UA format (e.g., "ns=2;s=MyNode", "i=85") and bare numeric IDs.
/// Parses node ID strings into OPC UA <see cref="NodeId" /> objects.
/// Supports standard OPC UA format (e.g., "ns=2;s=MyNode", "i=85") and bare numeric IDs.
/// </summary>
public static class NodeIdParser
{
/// <summary>
/// Parses a string into a <see cref="NodeId"/>. Returns <c>null</c> if the input is null or empty.
/// Parses a string into a <see cref="NodeId" />. Returns <c>null</c> if the input is null or empty.
/// </summary>
/// <param name="nodeIdString">The node ID string to parse.</param>
/// <returns>A parsed <see cref="NodeId"/>, or <c>null</c> if input is null/empty.</returns>
/// <returns>A parsed <see cref="NodeId" />, or <c>null</c> if input is null/empty.</returns>
/// <exception cref="FormatException">Thrown when the string cannot be parsed as a valid NodeId.</exception>
public static NodeId? Parse(string? nodeIdString)
{
@@ -24,7 +24,6 @@ public static class NodeIdParser
// Standard OPC UA format: ns=X;s=..., ns=X;i=..., ns=X;g=..., ns=X;b=...
// Also: s=..., i=..., g=..., b=... (namespace 0 implied)
if (trimmed.Contains('='))
{
try
{
return NodeId.Parse(trimmed);
@@ -33,22 +32,19 @@ public static class NodeIdParser
{
throw new FormatException($"Invalid node ID format: '{nodeIdString}'", ex);
}
}
// Bare numeric: treat as namespace 0, numeric identifier
if (uint.TryParse(trimmed, out var numericId))
{
return new NodeId(numericId);
}
if (uint.TryParse(trimmed, out var numericId)) return new NodeId(numericId);
throw new FormatException($"Invalid node ID format: '{nodeIdString}'. Expected format like 'ns=2;s=MyNode', 'i=85', or a numeric ID.");
throw new FormatException(
$"Invalid node ID format: '{nodeIdString}'. Expected format like 'ns=2;s=MyNode', 'i=85', or a numeric ID.");
}
/// <summary>
/// Parses a string into a <see cref="NodeId"/>, throwing if the input is null or empty.
/// Parses a string into a <see cref="NodeId" />, throwing if the input is null or empty.
/// </summary>
/// <param name="nodeIdString">The node ID string to parse.</param>
/// <returns>A parsed <see cref="NodeId"/>.</returns>
/// <returns>A parsed <see cref="NodeId" />.</returns>
/// <exception cref="ArgumentException">Thrown when the input is null or empty.</exception>
/// <exception cref="FormatException">Thrown when the string cannot be parsed as a valid NodeId.</exception>
public static NodeId ParseRequired(string? nodeIdString)

View File

@@ -6,10 +6,7 @@ return await new CliApplicationBuilder()
.UseTypeActivator(type =>
{
// Inject the default factory into commands that derive from CommandBase
if (type.IsSubclassOf(typeof(CommandBase)))
{
return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
}
if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
return Activator.CreateInstance(type)!;
})
.SetExecutableName("lmxopcua-cli")

View File

@@ -1,21 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Client.CLI</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Client.CLI</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.6" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.6"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.LmxOpcUa.Client.Shared\ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.LmxOpcUa.Client.Shared\ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj"/>
</ItemGroup>
</Project>

View File

@@ -6,7 +6,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Production implementation that builds a real OPC UA ApplicationConfiguration.
/// Production implementation that builds a real OPC UA ApplicationConfiguration.
/// </summary>
internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfigurationFactory
{
@@ -54,11 +54,9 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
await config.Validate(ApplicationType.Client);
if (settings.AutoAcceptCertificates)
{
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
}
if (settings.SecurityMode != Models.SecurityMode.None)
if (settings.SecurityMode != SecurityMode.None)
{
var app = new ApplicationInstance
{

View File

@@ -5,13 +5,14 @@ using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Production endpoint discovery that queries the real server.
/// Production endpoint discovery that queries the real server.
/// </summary>
internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
{
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)
{
@@ -54,7 +55,8 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
{
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host };
best.EndpointUrl = builder.ToString();
Logger.Debug("Rewrote endpoint host from {ServerHost} to {RequestedHost}", serverUri.Host, requestedUri.Host);
Logger.Debug("Rewrote endpoint host from {ServerHost} to {RequestedHost}", serverUri.Host,
requestedUri.Host);
}
return best;

View File

@@ -5,7 +5,7 @@ using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Production session adapter wrapping a real OPC UA Session.
/// Production session adapter wrapping a real OPC UA Session.
/// </summary>
internal sealed class DefaultSessionAdapter : ISessionAdapter
{
@@ -67,14 +67,14 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
true,
nodeClassMask);
return (continuationPoint, references ?? new ReferenceDescriptionCollection());
return (continuationPoint, references ?? []);
}
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct)
{
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint);
return (nextCp, nextRefs ?? new ReferenceDescriptionCollection());
return (nextCp, nextRefs ?? []);
}
public async Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
@@ -134,26 +134,24 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
break;
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
{
allValues.AddRange(historyData.DataValues);
}
continuationPoint = result.ContinuationPoint;
}
while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
} while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
return allValues;
}
public async Task<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
{
StartTime = startTime,
EndTime = endTime,
ProcessingInterval = intervalMs,
AggregateType = new NodeIdCollection { aggregateId }
AggregateType = [aggregateId]
};
var nodesToRead = new HistoryReadValueIdCollection
@@ -178,9 +176,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
if (!StatusCode.IsBad(result.StatusCode) &&
result.HistoryData is ExtensionObject ext &&
ext.Body is HistoryData historyData)
{
allValues.AddRange(historyData.DataValues);
}
}
return allValues;
@@ -204,10 +200,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
{
try
{
if (_session.Connected)
{
_session.Close();
}
if (_session.Connected) _session.Close();
}
catch (Exception ex)
{
@@ -219,12 +212,12 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
{
try
{
if (_session.Connected)
{
_session.Close();
}
if (_session.Connected) _session.Close();
}
catch { }
catch
{
}
_session.Dispose();
}
}

View File

@@ -5,7 +5,7 @@ using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Production session factory that creates real OPC UA sessions.
/// Production session factory that creates real OPC UA sessions.
/// </summary>
internal sealed class DefaultSessionFactory : ISessionFactory
{

View File

@@ -5,13 +5,13 @@ using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Production subscription adapter wrapping a real OPC UA Subscription.
/// Production subscription adapter wrapping a real OPC UA Subscription.
/// </summary>
internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
{
private static readonly ILogger Logger = Log.ForContext<DefaultSubscriptionAdapter>();
private readonly Subscription _subscription;
private readonly Dictionary<uint, MonitoredItem> _monitoredItems = new();
private readonly Subscription _subscription;
public DefaultSubscriptionAdapter(Subscription subscription)
{
@@ -33,9 +33,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
item.Notification += (_, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
onDataChange(nodeId.ToString(), notification.Value);
}
};
_subscription.AddItem(item);
@@ -75,10 +73,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
item.Notification += (_, e) =>
{
if (e.NotificationValue is EventFieldList eventFields)
{
onEvent(eventFields);
}
if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
};
_subscription.AddItem(item);
@@ -106,6 +101,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
{
Logger.Warning(ex, "Error deleting subscription");
}
_monitoredItems.Clear();
}
@@ -115,7 +111,10 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
{
_subscription.Delete(true);
}
catch { }
catch
{
}
_monitoredItems.Clear();
}
}

View File

@@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Creates and configures an OPC UA ApplicationConfiguration.
/// Creates and configures an OPC UA ApplicationConfiguration.
/// </summary>
internal interface IApplicationConfigurationFactory
{
/// <summary>
/// Creates a validated ApplicationConfiguration for the given connection settings.
/// Creates a validated ApplicationConfiguration for the given connection settings.
/// </summary>
Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct = default);
}

View File

@@ -3,13 +3,14 @@ using Opc.Ua;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Abstracts OPC UA endpoint discovery for testability.
/// Abstracts OPC UA endpoint discovery for testability.
/// </summary>
internal interface IEndpointDiscovery
{
/// <summary>
/// Discovers endpoints at the given URL and returns the best match for the requested security mode.
/// Also rewrites the endpoint URL hostname to match the requested URL when they differ.
/// Discovers endpoints at the given URL and returns the best match for the requested security mode.
/// Also rewrites the endpoint URL hostname to match the requested URL when they differ.
/// </summary>
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl, MessageSecurityMode requestedMode);
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode);
}

View File

@@ -3,7 +3,7 @@ using Opc.Ua;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Abstracts the OPC UA session for read, write, browse, history, and subscription operations.
/// Abstracts the OPC UA session for read, write, browse, history, and subscription operations.
/// </summary>
internal interface ISessionAdapter : IDisposable
{
@@ -17,7 +17,7 @@ internal interface ISessionAdapter : IDisposable
NamespaceTable NamespaceUris { get; }
/// <summary>
/// Registers a keep-alive callback. The callback receives true when the session is healthy, false on failure.
/// Registers a keep-alive callback. The callback receives true when the session is healthy, false on failure.
/// </summary>
void RegisterKeepAliveHandler(Action<bool> callback);
@@ -25,35 +25,37 @@ internal interface ISessionAdapter : IDisposable
Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default);
/// <summary>
/// Browses forward hierarchical references from the given node.
/// Returns (continuationPoint, references).
/// Browses forward hierarchical references from the given node.
/// Returns (continuationPoint, references).
/// </summary>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
/// <summary>
/// Continues a browse from a continuation point.
/// Continues a browse from a continuation point.
/// </summary>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct = default);
/// <summary>
/// Checks whether a node has any forward hierarchical child references.
/// Checks whether a node has any forward hierarchical child references.
/// </summary>
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
/// Reads raw historical data.
/// Reads raw historical data.
/// </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>
/// Reads processed/aggregate historical data.
/// Reads processed/aggregate historical data.
/// </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>
/// Creates a subscription adapter for this session.
/// Creates a subscription adapter for this session.
/// </summary>
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);

View File

@@ -3,12 +3,12 @@ using Opc.Ua;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Creates OPC UA sessions from a configured endpoint.
/// Creates OPC UA sessions from a configured endpoint.
/// </summary>
internal interface ISessionFactory
{
/// <summary>
/// Creates a session to the given endpoint.
/// Creates a session to the given endpoint.
/// </summary>
/// <param name="config">The application configuration.</param>
/// <param name="endpoint">The configured endpoint.</param>

View File

@@ -3,29 +3,30 @@ using Opc.Ua;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
/// <summary>
/// Abstracts OPC UA subscription and monitored item management.
/// Abstracts OPC UA subscription and monitored item management.
/// </summary>
internal interface ISubscriptionAdapter : IDisposable
{
uint SubscriptionId { get; }
/// <summary>
/// Adds a data-change monitored item and returns its client handle for tracking.
/// Adds a data-change monitored item and returns its client handle for tracking.
/// </summary>
/// <param name="nodeId">The node to monitor.</param>
/// <param name="samplingIntervalMs">The sampling interval in milliseconds.</param>
/// <param name="onDataChange">Callback when data changes. Receives (nodeIdString, DataValue).</param>
/// <param name="ct">Cancellation token.</param>
/// <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>
/// Removes a previously added monitored item by its client handle.
/// Removes a previously added monitored item by its client handle.
/// </summary>
Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default);
/// <summary>
/// Adds an event monitored item with the given event filter.
/// Adds an event monitored item with the given event filter.
/// </summary>
/// <param name="nodeId">The node to monitor for events.</param>
/// <param name="samplingIntervalMs">The sampling interval.</param>
@@ -33,15 +34,16 @@ internal interface ISubscriptionAdapter : IDisposable
/// <param name="onEvent">Callback when events arrive. Receives the event field list.</param>
/// <param name="ct">Cancellation token.</param>
/// <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>
/// Requests a condition refresh for this subscription.
/// Requests a condition refresh for this subscription.
/// </summary>
Task ConditionRefreshAsync(CancellationToken ct = default);
/// <summary>
/// Removes all monitored items and deletes the subscription.
/// Removes all monitored items and deletes the subscription.
/// </summary>
Task DeleteAsync(CancellationToken ct = default);
}

View File

@@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
/// <summary>
/// Maps the library's AggregateType enum to OPC UA aggregate function NodeIds.
/// Maps the library's AggregateType enum to OPC UA aggregate function NodeIds.
/// </summary>
public static class AggregateTypeMapper
{
/// <summary>
/// Returns the OPC UA NodeId for the specified aggregate type.
/// Returns the OPC UA NodeId for the specified aggregate type.
/// </summary>
public static NodeId ToNodeId(AggregateType aggregate)
{

View File

@@ -1,13 +1,13 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
/// <summary>
/// Parses and normalizes failover URL sets for redundant OPC UA connections.
/// Parses and normalizes failover URL sets for redundant OPC UA connections.
/// </summary>
public static class FailoverUrlParser
{
/// <summary>
/// Parses a comma-separated failover URL string, prepending the primary URL.
/// Trims whitespace and deduplicates.
/// Parses a comma-separated failover URL string, prepending the primary URL.
/// Trims whitespace and deduplicates.
/// </summary>
/// <param name="primaryUrl">The primary endpoint URL.</param>
/// <param name="failoverCsv">Optional comma-separated failover URLs.</param>
@@ -15,7 +15,7 @@ public static class FailoverUrlParser
public static string[] Parse(string primaryUrl, string? failoverCsv)
{
if (string.IsNullOrWhiteSpace(failoverCsv))
return new[] { primaryUrl };
return [primaryUrl];
var urls = new List<string> { primaryUrl };
foreach (var url in failoverCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
@@ -24,11 +24,12 @@ public static class FailoverUrlParser
if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
urls.Add(trimmed);
}
return urls.ToArray();
}
/// <summary>
/// Builds a failover URL set from the primary URL and an optional array of failover URLs.
/// Builds a failover URL set from the primary URL and an optional array of failover URLs.
/// </summary>
/// <param name="primaryUrl">The primary endpoint URL.</param>
/// <param name="failoverUrls">Optional failover URLs.</param>
@@ -36,7 +37,7 @@ public static class FailoverUrlParser
public static string[] Parse(string primaryUrl, string[]? failoverUrls)
{
if (failoverUrls == null || failoverUrls.Length == 0)
return new[] { primaryUrl };
return [primaryUrl];
var urls = new List<string> { primaryUrl };
foreach (var url in failoverUrls)
@@ -45,6 +46,7 @@ public static class FailoverUrlParser
if (!string.IsNullOrEmpty(trimmed) && !urls.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
urls.Add(trimmed);
}
return urls.ToArray();
}
}

View File

@@ -4,12 +4,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
/// <summary>
/// Maps between the library's SecurityMode enum and OPC UA SDK MessageSecurityMode.
/// Maps between the library's SecurityMode enum and OPC UA SDK MessageSecurityMode.
/// </summary>
public static class SecurityModeMapper
{
/// <summary>
/// Converts a <see cref="SecurityMode"/> to an OPC UA <see cref="MessageSecurityMode"/>.
/// Converts a <see cref="SecurityMode" /> to an OPC UA <see cref="MessageSecurityMode" />.
/// </summary>
public static MessageSecurityMode ToMessageSecurityMode(SecurityMode mode)
{
@@ -23,7 +23,7 @@ public static class SecurityModeMapper
}
/// <summary>
/// Parses a string to a <see cref="SecurityMode"/> value, case-insensitively.
/// Parses a string to a <see cref="SecurityMode" /> value, case-insensitively.
/// </summary>
/// <param name="value">The string to parse (e.g., "none", "sign", "encrypt", "signandencrypt").</param>
/// <returns>The corresponding SecurityMode.</returns>

View File

@@ -1,13 +1,13 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
/// <summary>
/// Converts raw string values into typed values based on the current value's runtime type.
/// Ported from the CLI tool's OpcUaHelper.ConvertValue.
/// Converts raw string values into typed values based on the current value's runtime type.
/// Ported from the CLI tool's OpcUaHelper.ConvertValue.
/// </summary>
public static class ValueConverter
{
/// <summary>
/// Converts a raw string value into the runtime type expected by the target node.
/// Converts a raw string value into the runtime type expected by the target node.
/// </summary>
/// <param name="rawValue">The raw string supplied by the user.</param>
/// <param name="currentValue">The current node value used to infer the target type. May be null.</param>

View File

@@ -1,22 +1,23 @@
using Opc.Ua;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
/// <summary>
/// Shared OPC UA client service contract for CLI and UI consumers.
/// Shared OPC UA client service contract for CLI and UI consumers.
/// </summary>
public interface IOpcUaClientService : IDisposable
{
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
Task DisconnectAsync(CancellationToken ct = default);
bool IsConnected { 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<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 UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
@@ -25,8 +26,11 @@ public interface IOpcUaClientService : IDisposable
Task UnsubscribeAlarmsAsync(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>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
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);

View File

@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
/// <summary>
/// Factory for creating <see cref="IOpcUaClientService"/> instances.
/// Factory for creating <see cref="IOpcUaClientService" /> instances.
/// </summary>
public interface IOpcUaClientServiceFactory
{

View File

@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Aggregate functions for processed history reads.
/// Aggregate functions for processed history reads.
/// </summary>
public enum AggregateType
{

View File

@@ -1,10 +1,30 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Event data for an alarm or condition notification from the OPC UA server.
/// Event data for an alarm or condition notification from the OPC UA server.
/// </summary>
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>
public string SourceName { get; }
@@ -28,24 +48,4 @@ public sealed class AlarmEventArgs : EventArgs
/// <summary>The time the event occurred.</summary>
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;
}
}

View File

@@ -1,30 +1,10 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Represents a single node in the browse result set.
/// Represents a single node in the browse result set.
/// </summary>
public sealed class BrowseResult
{
/// <summary>
/// The string representation of the node's NodeId.
/// </summary>
public string NodeId { get; }
/// <summary>
/// The display name of the node.
/// </summary>
public string DisplayName { get; }
/// <summary>
/// The node class (e.g., "Object", "Variable", "Method").
/// </summary>
public string NodeClass { get; }
/// <summary>
/// Whether the node has child references.
/// </summary>
public bool HasChildren { get; }
public BrowseResult(string nodeId, string displayName, string nodeClass, bool hasChildren)
{
NodeId = nodeId;
@@ -32,4 +12,24 @@ public sealed class BrowseResult
NodeClass = nodeClass;
HasChildren = hasChildren;
}
/// <summary>
/// The string representation of the node's NodeId.
/// </summary>
public string NodeId { get; }
/// <summary>
/// The display name of the node.
/// </summary>
public string DisplayName { get; }
/// <summary>
/// The node class (e.g., "Object", "Variable", "Method").
/// </summary>
public string NodeClass { get; }
/// <summary>
/// Whether the node has child references.
/// </summary>
public bool HasChildren { get; }
}

View File

@@ -1,10 +1,26 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Information about the current OPC UA session.
/// Information about the current OPC UA session.
/// </summary>
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>
public string EndpointUrl { get; }
@@ -22,20 +38,4 @@ public sealed class ConnectionInfo
/// <summary>The session name.</summary>
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;
}
}

View File

@@ -1,54 +1,54 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Settings for establishing an OPC UA client connection.
/// Settings for establishing an OPC UA client connection.
/// </summary>
public sealed class ConnectionSettings
{
/// <summary>
/// The primary OPC UA endpoint URL.
/// The primary OPC UA endpoint URL.
/// </summary>
public string EndpointUrl { get; set; } = string.Empty;
/// <summary>
/// Optional failover endpoint URLs for redundancy.
/// Optional failover endpoint URLs for redundancy.
/// </summary>
public string[]? FailoverUrls { get; set; }
/// <summary>
/// Optional username for authentication.
/// Optional username for authentication.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Optional password for authentication.
/// Optional password for authentication.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Transport security mode. Defaults to <see cref="Models.SecurityMode.None"/>.
/// Transport security mode. Defaults to <see cref="Models.SecurityMode.None" />.
/// </summary>
public SecurityMode SecurityMode { get; set; } = SecurityMode.None;
/// <summary>
/// Session timeout in seconds. Defaults to 60.
/// Session timeout in seconds. Defaults to 60.
/// </summary>
public int SessionTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Whether to automatically accept untrusted server certificates. Defaults to true.
/// Whether to automatically accept untrusted server certificates. Defaults to true.
/// </summary>
public bool AutoAcceptCertificates { get; set; } = true;
/// <summary>
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData.
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData.
/// </summary>
public string CertificateStorePath { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
/// <summary>
/// Validates the settings and throws if any required values are missing or invalid.
/// Validates the settings and throws if any required values are missing or invalid.
/// </summary>
/// <exception cref="ArgumentException">Thrown when settings are invalid.</exception>
public void Validate()
@@ -57,7 +57,8 @@ public sealed class ConnectionSettings
throw new ArgumentException("EndpointUrl must not be null or empty.", nameof(EndpointUrl));
if (SessionTimeoutSeconds <= 0)
throw new ArgumentException("SessionTimeoutSeconds must be greater than zero.", nameof(SessionTimeoutSeconds));
throw new ArgumentException("SessionTimeoutSeconds must be greater than zero.",
nameof(SessionTimeoutSeconds));
if (SessionTimeoutSeconds > 3600)
throw new ArgumentException("SessionTimeoutSeconds must not exceed 3600.", nameof(SessionTimeoutSeconds));

View File

@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Represents the current state of the OPC UA client connection.
/// Represents the current state of the OPC UA client connection.
/// </summary>
public enum ConnectionState
{

View File

@@ -1,10 +1,17 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Event data raised when the client connection state changes.
/// Event data raised when the client connection state changes.
/// </summary>
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>
public ConnectionState OldState { get; }
@@ -13,11 +20,4 @@ public sealed class ConnectionStateChangedEventArgs : EventArgs
/// <summary>The endpoint URL associated with the state change.</summary>
public string EndpointUrl { get; }
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string endpointUrl)
{
OldState = oldState;
NewState = newState;
EndpointUrl = endpointUrl;
}
}

View File

@@ -3,19 +3,19 @@ using Opc.Ua;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Event data for a monitored data value change.
/// Event data for a monitored data value change.
/// </summary>
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)
{
NodeId = nodeId;
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; }
}

View File

@@ -1,10 +1,18 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Redundancy information read from the server.
/// Redundancy information read from the server.
/// </summary>
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>
public string Mode { get; }
@@ -16,12 +24,4 @@ public sealed class RedundancyInfo
/// <summary>The application URI of the connected server.</summary>
public string ApplicationUri { get; }
public RedundancyInfo(string mode, byte serviceLevel, string[] serverUris, string applicationUri)
{
Mode = mode;
ServiceLevel = serviceLevel;
ServerUris = serverUris;
ApplicationUri = applicationUri;
}
}

View File

@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
/// <summary>
/// Transport security mode for the OPC UA connection.
/// Transport security mode for the OPC UA connection.
/// </summary>
public enum SecurityMode
{

View File

@@ -1,45 +1,42 @@
using System.Text;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Adapters;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
/// <summary>
/// Full implementation of <see cref="IOpcUaClientService"/> using adapter abstractions for testability.
/// Full implementation of <see cref="IOpcUaClientService" /> using adapter abstractions for testability.
/// </summary>
public sealed class OpcUaClientService : IOpcUaClientService
{
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
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
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;
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; }
private ISessionAdapter? _session;
private ConnectionSettings? _settings;
private ConnectionState _state = ConnectionState.Disconnected;
/// <summary>
/// Creates a new OpcUaClientService with the specified adapter dependencies.
/// Creates a new OpcUaClientService with the specified adapter dependencies.
/// </summary>
internal OpcUaClientService(
IApplicationConfigurationFactory configFactory,
@@ -52,7 +49,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
/// <summary>
/// Creates a new OpcUaClientService with default production adapters.
/// Creates a new OpcUaClientService with default production adapters.
/// </summary>
public OpcUaClientService()
: this(
@@ -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)
{
ThrowIfDisposed();
@@ -80,10 +84,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood)
{
_ = HandleKeepAliveFailureAsync();
}
if (!isGood) _ = HandleKeepAliveFailureAsync();
});
CurrentConnectionInfo = BuildConnectionInfo(session);
@@ -112,11 +113,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
await _dataSubscription.DeleteAsync(ct);
_dataSubscription = null;
}
if (_alarmSubscription != null)
{
await _alarmSubscription.DeleteAsync(ct);
_alarmSubscription = null;
}
if (_session != null)
{
await _session.CloseAsync(ct);
@@ -150,7 +153,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfNotConnected();
// Read current value for type coercion when value is a string
object typedValue = value;
var typedValue = value;
if (value is string rawString)
{
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
@@ -161,14 +164,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
}
public async Task<IReadOnlyList<Models.BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default)
public async Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
var startNode = parentNodeId ?? ObjectIds.ObjectsFolder;
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
var results = new List<Models.BrowseResult>();
var results = new List<BrowseResult>();
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
@@ -180,7 +184,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
var hasChildren = reference.NodeClass == NodeClass.Object &&
await _session.HasChildrenAsync(childNodeId, ct);
results.Add(new Models.BrowseResult(
results.Add(new BrowseResult(
reference.NodeId.ToString(),
reference.DisplayName?.Text ?? string.Empty,
reference.NodeClass.ToString(),
@@ -188,13 +192,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
if (continuationPoint != null && continuationPoint.Length > 0)
{
(continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct);
}
else
{
break;
}
}
return results;
@@ -209,10 +209,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
return; // Already subscribed
if (_dataSubscription == null)
{
_dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
}
if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, OnDataChangeNotification, ct);
@@ -229,16 +226,14 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out var sub))
return; // Not subscribed, safe to ignore
if (_dataSubscription != null)
{
await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
}
if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
_activeDataSubscriptions.Remove(nodeIdStr);
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
}
public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
@@ -305,16 +300,18 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
var redundancySupportValue = await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
var redundancySupportValue =
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
var redundancyMode = ((RedundancySupport)(int)redundancySupportValue.Value).ToString();
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
var serviceLevel = (byte)serviceLevelValue.Value;
string[] serverUris = Array.Empty<string>();
string[] serverUris = [];
try
{
var serverUriArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
var serverUriArrayValue =
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
if (serverUriArrayValue.Value is string[] uris)
serverUris = uris;
}
@@ -323,7 +320,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
// ServerUriArray may not be present when RedundancySupport is None
}
string applicationUri = string.Empty;
var applicationUri = string.Empty;
try
{
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
@@ -354,7 +351,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
// --- Private helpers ---
private async Task<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
var effectiveSettings = new ConnectionSettings
@@ -372,12 +370,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);
UserIdentity identity = settings.Username != null
? new UserIdentity(settings.Username, System.Text.Encoding.UTF8.GetBytes(settings.Password ?? ""))
var identity = settings.Username != null
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
: new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity, ct);
return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity,
ct);
}
private async Task HandleKeepAliveFailureAsync()
@@ -392,9 +391,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
// Close old session
if (_session != null)
{
try { _session.Dispose(); } catch { }
try
{
_session.Dispose();
}
catch
{
}
_session = null;
}
_dataSubscription = null;
_alarmSubscription = null;
@@ -405,7 +412,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
// Try each endpoint
for (int attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
{
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
var url = _allEndpointUrls[_currentEndpointIndex];
@@ -418,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
session.RegisterKeepAliveHandler(isGood =>
{
if (!isGood) { _ = HandleKeepAliveFailureAsync(); }
if (!isGood) _ = HandleKeepAliveFailureAsync();
});
CurrentConnectionInfo = BuildConnectionInfo(session);
@@ -448,7 +455,6 @@ public sealed class OpcUaClientService : IOpcUaClientService
_activeDataSubscriptions.Clear();
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
{
try
{
if (_dataSubscription == null)
@@ -462,7 +468,6 @@ public sealed class OpcUaClientService : IOpcUaClientService
{
Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr);
}
}
}
// Replay alarm subscription

View File

@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared;
/// <summary>
/// Default factory that creates <see cref="OpcUaClientService"/> instances with production adapters.
/// Default factory that creates <see cref="OpcUaClientService" /> instances with production adapters.
/// </summary>
public sealed class OpcUaClientServiceFactory : IOpcUaClientServiceFactory
{

View File

@@ -1,19 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Client.Shared</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Client.Shared</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests"/>
</ItemGroup>
</Project>

View File

@@ -8,7 +8,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
public partial class App : Application
public class App : Application
{
public override void Initialize()
{

View File

@@ -2,15 +2,20 @@ using Avalonia;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
class Program
internal class Program
{
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -3,7 +3,7 @@ using Avalonia.Threading;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
/// <summary>
/// Dispatches actions to the Avalonia UI thread.
/// Dispatches actions to the Avalonia UI thread.
/// </summary>
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{

View File

@@ -1,12 +1,12 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
/// <summary>
/// Abstraction for dispatching actions to the UI thread.
/// Abstraction for dispatching actions to the UI thread.
/// </summary>
public interface IUiDispatcher
{
/// <summary>
/// Posts an action to be executed on the UI thread.
/// Posts an action to be executed on the UI thread.
/// </summary>
void Post(Action action);
}

View File

@@ -1,8 +1,8 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
/// <summary>
/// Dispatcher that executes actions synchronously on the calling thread.
/// Used for unit testing where no UI thread is available.
/// Dispatcher that executes actions synchronously on the calling thread.
/// Used for unit testing where no UI thread is available.
/// </summary>
public sealed class SynchronousUiDispatcher : IUiDispatcher
{

View File

@@ -3,19 +3,10 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// Represents a single alarm event row.
/// Represents a single alarm event row.
/// </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(
string sourceName,
string conditionName,
@@ -35,4 +26,13 @@ public partial class AlarmEventViewModel : ObservableObject
AckedState = ackedState;
Time = time;
}
public string SourceName { get; }
public string ConditionName { get; }
public ushort Severity { get; }
public string Message { get; }
public bool Retain { get; }
public bool ActiveState { get; }
public bool AckedState { get; }
public DateTime Time { get; }
}

View File

@@ -9,27 +9,14 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// ViewModel for the alarms panel.
/// ViewModel for the alarms panel.
/// </summary>
public partial class AlarmsViewModel : ObservableObject
{
private readonly IOpcUaClientService _service;
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
/// <summary>Received alarm events.</summary>
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] private int _interval = 1000;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
@@ -37,6 +24,14 @@ public partial class AlarmsViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
private bool _isConnected;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
[NotifyCanExecuteChangedFor(nameof(UnsubscribeCommand))]
[NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
private bool _isSubscribed;
[ObservableProperty] private string? _monitoredNodeIdText;
public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
@@ -44,6 +39,9 @@ public partial class AlarmsViewModel : ObservableObject
_service.AlarmEvent += OnAlarmEvent;
}
/// <summary>Received alarm events.</summary>
public ObservableCollection<AlarmEventViewModel> AlarmEvents { get; } = [];
private void OnAlarmEvent(object? sender, AlarmEventArgs e)
{
_dispatcher.Post(() =>
@@ -60,14 +58,17 @@ public partial class AlarmsViewModel : ObservableObject
});
}
private bool CanSubscribe() => IsConnected && !IsSubscribed;
private bool CanSubscribe()
{
return IsConnected && !IsSubscribed;
}
[RelayCommand(CanExecute = nameof(CanSubscribe))]
private async Task SubscribeAsync()
{
try
{
NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
? null
: NodeId.Parse(MonitoredNodeIdText);
@@ -80,7 +81,10 @@ public partial class AlarmsViewModel : ObservableObject
}
}
private bool CanUnsubscribe() => IsConnected && IsSubscribed;
private bool CanUnsubscribe()
{
return IsConnected && IsSubscribed;
}
[RelayCommand(CanExecute = nameof(CanUnsubscribe))]
private async Task UnsubscribeAsync()
@@ -110,7 +114,7 @@ public partial class AlarmsViewModel : ObservableObject
}
/// <summary>
/// Clears alarm events and resets state.
/// Clears alarm events and resets state.
/// </summary>
public void Clear()
{
@@ -119,7 +123,7 @@ public partial class AlarmsViewModel : ObservableObject
}
/// <summary>
/// Unhooks event handlers from the service.
/// Unhooks event handlers from the service.
/// </summary>
public void Teardown()
{

View File

@@ -6,15 +6,12 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// ViewModel for the OPC UA browse tree panel.
/// ViewModel for the OPC UA browse tree panel.
/// </summary>
public partial class BrowseTreeViewModel : ObservableObject
public class BrowseTreeViewModel : ObservableObject
{
private readonly IOpcUaClientService _service;
private readonly IUiDispatcher _dispatcher;
/// <summary>Top-level nodes in the browse tree.</summary>
public ObservableCollection<TreeNodeViewModel> RootNodes { get; } = new();
private readonly IOpcUaClientService _service;
public BrowseTreeViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
@@ -22,18 +19,20 @@ public partial class BrowseTreeViewModel : ObservableObject
_dispatcher = dispatcher;
}
/// <summary>Top-level nodes in the browse tree.</summary>
public ObservableCollection<TreeNodeViewModel> RootNodes { get; } = [];
/// <summary>
/// Loads root nodes by browsing with a null parent.
/// Loads root nodes by browsing with a null parent.
/// </summary>
public async Task LoadRootsAsync()
{
var results = await _service.BrowseAsync(null);
var results = await _service.BrowseAsync();
_dispatcher.Post(() =>
{
RootNodes.Clear();
foreach (var result in results)
{
RootNodes.Add(new TreeNodeViewModel(
result.NodeId,
result.DisplayName,
@@ -41,12 +40,11 @@ public partial class BrowseTreeViewModel : ObservableObject
result.HasChildren,
_service,
_dispatcher));
}
});
}
/// <summary>
/// Clears all root nodes from the tree.
/// Clears all root nodes from the tree.
/// </summary>
public void Clear()
{

View File

@@ -3,15 +3,10 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// Represents a single historical value row.
/// Represents a single historical value row.
/// </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)
{
Value = value;
@@ -19,4 +14,9 @@ public partial class HistoryValueViewModel : ObservableObject
SourceTimestamp = sourceTimestamp;
ServerTimestamp = serverTimestamp;
}
public string Value { get; }
public string Status { get; }
public string SourceTimestamp { get; }
public string ServerTimestamp { get; }
}

View File

@@ -9,55 +9,30 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// ViewModel for the history panel.
/// ViewModel for the history panel.
/// </summary>
public partial class HistoryViewModel : ObservableObject
{
private readonly IOpcUaClientService _service;
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
private string? _selectedNodeId;
[ObservableProperty] private DateTimeOffset _endTime = DateTimeOffset.UtcNow;
[ObservableProperty]
private DateTimeOffset _startTime = DateTimeOffset.UtcNow.AddHours(-1);
[ObservableProperty] private double _intervalMs = 3600000;
[ObservableProperty]
private DateTimeOffset _endTime = DateTimeOffset.UtcNow;
[ObservableProperty]
private int _maxValues = 1000;
[ObservableProperty]
private AggregateType? _selectedAggregateType;
/// <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))]
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
private bool _isConnected;
/// <summary>History read results.</summary>
public ObservableCollection<HistoryValueViewModel> Results { get; } = new();
[ObservableProperty] private bool _isLoading;
[ObservableProperty] private int _maxValues = 1000;
[ObservableProperty] private AggregateType? _selectedAggregateType;
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
private string? _selectedNodeId;
[ObservableProperty] private DateTimeOffset _startTime = DateTimeOffset.UtcNow.AddHours(-1);
public HistoryViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
@@ -65,12 +40,32 @@ public partial class HistoryViewModel : ObservableObject
_dispatcher = dispatcher;
}
/// <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)
{
OnPropertyChanged(nameof(IsAggregateRead));
}
private bool CanReadHistory() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
private bool CanReadHistory()
{
return IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
}
[RelayCommand(CanExecute = nameof(CanReadHistory))]
private async Task ReadHistoryAsync()
@@ -86,33 +81,27 @@ public partial class HistoryViewModel : ObservableObject
IReadOnlyList<DataValue> values;
if (SelectedAggregateType != null)
{
values = await _service.HistoryReadAggregateAsync(
nodeId,
StartTime.UtcDateTime,
EndTime.UtcDateTime,
SelectedAggregateType.Value,
IntervalMs);
}
else
{
values = await _service.HistoryReadRawAsync(
nodeId,
StartTime.UtcDateTime,
EndTime.UtcDateTime,
MaxValues);
}
_dispatcher.Post(() =>
{
foreach (var dv in values)
{
Results.Add(new HistoryValueViewModel(
dv.Value?.ToString() ?? "(null)",
dv.StatusCode.ToString(),
dv.SourceTimestamp.ToString("O"),
dv.ServerTimestamp.ToString("O")));
}
});
}
catch (Exception ex)
@@ -130,7 +119,7 @@ public partial class HistoryViewModel : ObservableObject
}
/// <summary>
/// Clears results and resets state.
/// Clears results and resets state.
/// </summary>
public void Clear()
{

View File

@@ -8,78 +8,49 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// Main window ViewModel coordinating all panels.
/// Main window ViewModel coordinating all panels.
/// </summary>
public partial class MainWindowViewModel : ObservableObject
{
private readonly IOpcUaClientService _service;
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
[ObservableProperty]
private string _endpointUrl = "opc.tcp://localhost:4840";
[ObservableProperty] private bool _autoAcceptCertificates = true;
[ObservableProperty]
private string? _username;
[ObservableProperty]
private string? _password;
[ObservableProperty]
private SecurityMode _selectedSecurityMode = SecurityMode.None;
[ObservableProperty]
private string? _failoverUrls;
[ObservableProperty]
private int _sessionTimeoutSeconds = 60;
[ObservableProperty]
private bool _autoAcceptCertificates = true;
[ObservableProperty]
private string _certificateStorePath = Path.Combine(
[ObservableProperty] private string _certificateStorePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
/// <summary>All available security modes.</summary>
public IReadOnlyList<SecurityMode> SecurityModes { get; } = Enum.GetValues<SecurityMode>();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
[NotifyCanExecuteChangedFor(nameof(DisconnectCommand))]
private ConnectionState _connectionState = ConnectionState.Disconnected;
public bool IsConnected => ConnectionState == ConnectionState.Connected;
[ObservableProperty] private string _endpointUrl = "opc.tcp://localhost:4840";
[ObservableProperty]
private TreeNodeViewModel? _selectedTreeNode;
[ObservableProperty] private string? _failoverUrls;
[ObservableProperty]
private RedundancyInfo? _redundancyInfo;
[ObservableProperty] private bool _isHistoryEnabledForSelection;
[ObservableProperty]
private string _statusMessage = "Disconnected";
[ObservableProperty] private string? _password;
[ObservableProperty]
private string _sessionLabel = string.Empty;
[ObservableProperty] private RedundancyInfo? _redundancyInfo;
[ObservableProperty]
private int _subscriptionCount;
[ObservableProperty] private SecurityMode _selectedSecurityMode = SecurityMode.None;
[ObservableProperty]
private int _selectedTabIndex;
[ObservableProperty] private int _selectedTabIndex;
[ObservableProperty]
private bool _isHistoryEnabledForSelection;
[ObservableProperty] private TreeNodeViewModel? _selectedTreeNode;
/// <summary>The currently selected tree nodes (supports multi-select).</summary>
public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = new();
[ObservableProperty] private string _sessionLabel = string.Empty;
public BrowseTreeViewModel BrowseTree { get; }
public ReadWriteViewModel ReadWrite { get; }
public SubscriptionsViewModel Subscriptions { get; }
public AlarmsViewModel Alarms { get; }
public HistoryViewModel History { get; }
[ObservableProperty] private int _sessionTimeoutSeconds = 60;
[ObservableProperty] private string _statusMessage = "Disconnected";
[ObservableProperty] private int _subscriptionCount;
[ObservableProperty] private string? _username;
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher)
{
@@ -95,12 +66,23 @@ public partial class MainWindowViewModel : ObservableObject
_service.ConnectionStateChanged += OnConnectionStateChanged;
}
/// <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)
{
_dispatcher.Post(() =>
{
ConnectionState = e.NewState;
});
_dispatcher.Post(() => { ConnectionState = e.NewState; });
}
partial void OnConnectionStateChanged(ConnectionState value)
@@ -144,7 +126,10 @@ public partial class MainWindowViewModel : ObservableObject
History.SelectedNodeId = value?.NodeId;
}
private bool CanConnect() => ConnectionState == ConnectionState.Disconnected;
private bool CanConnect()
{
return ConnectionState == ConnectionState.Disconnected;
}
[RelayCommand(CanExecute = nameof(CanConnect))]
private async Task ConnectAsync()
@@ -199,8 +184,11 @@ public partial class MainWindowViewModel : ObservableObject
}
}
private bool CanDisconnect() => ConnectionState == ConnectionState.Connected
|| ConnectionState == ConnectionState.Reconnecting;
private bool CanDisconnect()
{
return ConnectionState == ConnectionState.Connected
|| ConnectionState == ConnectionState.Reconnecting;
}
[RelayCommand(CanExecute = nameof(CanDisconnect))]
private async Task DisconnectAsync()
@@ -217,15 +205,12 @@ public partial class MainWindowViewModel : ObservableObject
}
finally
{
_dispatcher.Post(() =>
{
ConnectionState = ConnectionState.Disconnected;
});
_dispatcher.Post(() => { ConnectionState = ConnectionState.Disconnected; });
}
}
/// <summary>
/// Subscribes all selected tree nodes and switches to the Subscriptions tab.
/// Subscribes all selected tree nodes and switches to the Subscriptions tab.
/// </summary>
[RelayCommand]
private async Task SubscribeSelectedNodesAsync()
@@ -233,17 +218,14 @@ public partial class MainWindowViewModel : ObservableObject
if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
var nodes = SelectedTreeNodes.ToList();
foreach (var node in nodes)
{
await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId);
}
foreach (var node in nodes) await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId);
SubscriptionCount = Subscriptions.SubscriptionCount;
SelectedTabIndex = 1; // Subscriptions tab
}
/// <summary>
/// Sets the history tab's selected node and switches to the History tab.
/// Sets the history tab's selected node and switches to the History tab.
/// </summary>
[RelayCommand]
private void ViewHistoryForSelectedNode()
@@ -256,14 +238,14 @@ public partial class MainWindowViewModel : ObservableObject
}
/// <summary>
/// Updates whether "View History" should be enabled based on the selected node's type.
/// Only Variable nodes can have history.
/// Updates whether "View History" should be enabled based on the selected node's type.
/// Only Variable nodes can have history.
/// </summary>
public void UpdateHistoryEnabledForSelection()
{
IsHistoryEnabledForSelection = IsConnected
&& SelectedTreeNodes.Count > 0
&& SelectedTreeNodes[0].NodeClass == "Variable";
&& SelectedTreeNodes.Count > 0
&& SelectedTreeNodes[0].NodeClass == "Variable";
}
private static string[]? ParseFailoverUrls(string? csv)
@@ -272,7 +254,7 @@ public partial class MainWindowViewModel : ObservableObject
return null;
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(u => !string.IsNullOrEmpty(u))
.ToArray();
.Where(u => !string.IsNullOrEmpty(u))
.ToArray();
}
}

View File

@@ -7,42 +7,34 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// ViewModel for the read/write panel.
/// ViewModel for the read/write panel.
/// </summary>
public partial class ReadWriteViewModel : ObservableObject
{
private readonly IOpcUaClientService _service;
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ReadCommand))]
[NotifyCanExecuteChangedFor(nameof(WriteCommand))]
private string? _selectedNodeId;
[ObservableProperty] private string? _currentStatus;
[ObservableProperty]
private string? _currentValue;
[ObservableProperty]
private string? _currentStatus;
[ObservableProperty]
private string? _sourceTimestamp;
[ObservableProperty]
private string? _serverTimestamp;
[ObservableProperty]
private string? _writeValue;
[ObservableProperty]
private string? _writeStatus;
[ObservableProperty] private string? _currentValue;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ReadCommand))]
[NotifyCanExecuteChangedFor(nameof(WriteCommand))]
private bool _isConnected;
public bool IsNodeSelected => !string.IsNullOrEmpty(SelectedNodeId);
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ReadCommand))]
[NotifyCanExecuteChangedFor(nameof(WriteCommand))]
private string? _selectedNodeId;
[ObservableProperty] private string? _serverTimestamp;
[ObservableProperty] private string? _sourceTimestamp;
[ObservableProperty] private string? _writeStatus;
[ObservableProperty] private string? _writeValue;
public ReadWriteViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
@@ -50,16 +42,18 @@ public partial class ReadWriteViewModel : ObservableObject
_dispatcher = dispatcher;
}
public bool IsNodeSelected => !string.IsNullOrEmpty(SelectedNodeId);
partial void OnSelectedNodeIdChanged(string? value)
{
OnPropertyChanged(nameof(IsNodeSelected));
if (!string.IsNullOrEmpty(value) && IsConnected)
{
_ = ExecuteReadAsync();
}
if (!string.IsNullOrEmpty(value) && IsConnected) _ = ExecuteReadAsync();
}
private bool CanReadOrWrite() => IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
private bool CanReadOrWrite()
{
return IsConnected && !string.IsNullOrEmpty(SelectedNodeId);
}
[RelayCommand(CanExecute = nameof(CanReadOrWrite))]
private async Task ReadAsync()
@@ -106,22 +100,16 @@ public partial class ReadWriteViewModel : ObservableObject
var nodeId = NodeId.Parse(SelectedNodeId);
var statusCode = await _service.WriteValueAsync(nodeId, WriteValue);
_dispatcher.Post(() =>
{
WriteStatus = statusCode.ToString();
});
_dispatcher.Post(() => { WriteStatus = statusCode.ToString(); });
}
catch (Exception ex)
{
_dispatcher.Post(() =>
{
WriteStatus = $"Error: {ex.Message}";
});
_dispatcher.Post(() => { WriteStatus = $"Error: {ex.Message}"; });
}
}
/// <summary>
/// Clears all displayed values.
/// Clears all displayed values.
/// </summary>
public void Clear()
{

View File

@@ -3,28 +3,25 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// Represents a single active subscription row.
/// Represents a single active subscription row.
/// </summary>
public partial class SubscriptionItemViewModel : ObservableObject
{
/// <summary>The monitored NodeId.</summary>
public string NodeId { get; }
[ObservableProperty] private string? _status;
/// <summary>The subscription interval in milliseconds.</summary>
public int IntervalMs { get; }
[ObservableProperty] private string? _timestamp;
[ObservableProperty]
private string? _value;
[ObservableProperty]
private string? _status;
[ObservableProperty]
private string? _timestamp;
[ObservableProperty] private string? _value;
public SubscriptionItemViewModel(string nodeId, int intervalMs)
{
NodeId = nodeId;
IntervalMs = intervalMs;
}
/// <summary>The monitored NodeId.</summary>
public string NodeId { get; }
/// <summary>The subscription interval in milliseconds.</summary>
public int IntervalMs { get; }
}

View File

@@ -9,35 +9,28 @@ using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// ViewModel for the subscriptions panel.
/// ViewModel for the subscriptions panel.
/// </summary>
public partial class SubscriptionsViewModel : ObservableObject
{
private readonly IOpcUaClientService _service;
private readonly IUiDispatcher _dispatcher;
/// <summary>Currently active subscriptions.</summary>
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = new();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
private string? _newNodeIdText;
[ObservableProperty]
private int _newInterval = 1000;
private readonly IOpcUaClientService _service;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
private bool _isConnected;
[ObservableProperty]
private int _subscriptionCount;
[ObservableProperty] private int _newInterval = 1000;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
private string? _newNodeIdText;
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
private SubscriptionItemViewModel? _selectedSubscription;
[ObservableProperty] private int _subscriptionCount;
public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
@@ -45,23 +38,27 @@ public partial class SubscriptionsViewModel : ObservableObject
_service.DataChanged += OnDataChanged;
}
/// <summary>Currently active subscriptions.</summary>
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
private void OnDataChanged(object? sender, DataChangedEventArgs e)
{
_dispatcher.Post(() =>
{
foreach (var item in ActiveSubscriptions)
{
if (item.NodeId == e.NodeId)
{
item.Value = e.Value.Value?.ToString() ?? "(null)";
item.Status = e.Value.StatusCode.ToString();
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
}
}
});
}
private bool CanAddSubscription() => IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
private bool CanAddSubscription()
{
return IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
}
[RelayCommand(CanExecute = nameof(CanAddSubscription))]
private async Task AddSubscriptionAsync()
@@ -88,7 +85,10 @@ public partial class SubscriptionsViewModel : ObservableObject
}
}
private bool CanRemoveSubscription() => IsConnected && SelectedSubscription != null;
private bool CanRemoveSubscription()
{
return IsConnected && SelectedSubscription != null;
}
[RelayCommand(CanExecute = nameof(CanRemoveSubscription))]
private async Task RemoveSubscriptionAsync()
@@ -115,7 +115,7 @@ public partial class SubscriptionsViewModel : ObservableObject
}
/// <summary>
/// Subscribes to a node by ID (used by context menu). Skips if already subscribed.
/// Subscribes to a node by ID (used by context menu). Skips if already subscribed.
/// </summary>
public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000)
{
@@ -142,7 +142,7 @@ public partial class SubscriptionsViewModel : ObservableObject
}
/// <summary>
/// Clears all subscriptions and resets state.
/// Clears all subscriptions and resets state.
/// </summary>
public void Clear()
{
@@ -151,7 +151,7 @@ public partial class SubscriptionsViewModel : ObservableObject
}
/// <summary>
/// Unhooks event handlers from the service.
/// Unhooks event handlers from the service.
/// </summary>
public void Teardown()
{

View File

@@ -1,45 +1,27 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Opc.Ua;
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
/// <summary>
/// Represents a single node in the OPC UA browse tree with lazy-load support.
/// Represents a single node in the OPC UA browse tree with lazy-load support.
/// </summary>
public partial class TreeNodeViewModel : ObservableObject
{
private static readonly TreeNodeViewModel PlaceholderSentinel = new();
private readonly IUiDispatcher? _dispatcher;
private readonly IOpcUaClientService? _service;
private readonly IUiDispatcher? _dispatcher;
private bool _hasLoadedChildren;
/// <summary>The string NodeId of this node.</summary>
public string NodeId { get; }
[ObservableProperty] private bool _isExpanded;
/// <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; } = new();
[ObservableProperty]
private bool _isExpanded;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty] private bool _isLoading;
/// <summary>
/// Private constructor for the placeholder sentinel only.
/// Private constructor for the placeholder sentinel only.
/// </summary>
private TreeNodeViewModel()
{
@@ -64,18 +46,32 @@ public partial class TreeNodeViewModel : ObservableObject
_service = service;
_dispatcher = dispatcher;
if (hasChildren)
{
Children.Add(PlaceholderSentinel);
}
if (hasChildren) Children.Add(PlaceholderSentinel);
}
/// <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)
{
if (value && !_hasLoadedChildren && HasChildren)
{
_ = LoadChildrenAsync();
}
if (value && !_hasLoadedChildren && HasChildren) _ = LoadChildrenAsync();
}
private async Task LoadChildrenAsync()
@@ -94,7 +90,6 @@ public partial class TreeNodeViewModel : ObservableObject
{
Children.Clear();
foreach (var result in results)
{
Children.Add(new TreeNodeViewModel(
result.NodeId,
result.DisplayName,
@@ -102,7 +97,6 @@ public partial class TreeNodeViewModel : ObservableObject
result.HasChildren,
_service,
_dispatcher));
}
});
}
catch
@@ -114,9 +108,4 @@ public partial class TreeNodeViewModel : ObservableObject
_dispatcher.Post(() => IsLoading = false);
}
}
/// <summary>
/// Returns whether this node instance is the placeholder sentinel.
/// </summary>
internal bool IsPlaceholder => ReferenceEquals(this, PlaceholderSentinel);
}

View File

@@ -1,3 +1,4 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
@@ -17,17 +18,11 @@ public partial class MainWindow : Window
var browseTreeView = this.FindControl<BrowseTreeView>("BrowseTreePanel");
var treeView = browseTreeView?.FindControl<TreeView>("BrowseTree");
if (treeView != null)
{
treeView.SelectionChanged += OnTreeSelectionChanged;
}
if (treeView != null) treeView.SelectionChanged += OnTreeSelectionChanged;
// Wire up context menu opening to sync selection and check history
var contextMenu = this.FindControl<ContextMenu>("TreeContextMenu");
if (contextMenu != null)
{
contextMenu.Opening += OnTreeContextMenuOpening;
}
if (contextMenu != null) contextMenu.Opening += OnTreeContextMenuOpening;
}
private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -40,19 +35,12 @@ public partial class MainWindow : Window
// Sync multi-selection collection
vm.SelectedTreeNodes.Clear();
foreach (var item in treeView.SelectedItems)
{
if (item is TreeNodeViewModel node)
{
vm.SelectedTreeNodes.Add(node);
}
}
}
private void OnTreeContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
private void OnTreeContextMenuOpening(object? sender, CancelEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
{
vm.UpdateHistoryEnabledForSelection();
}
if (DataContext is MainWindowViewModel vm) vm.UpdateHistoryEnabledForSelection();
}
}

View File

@@ -1,30 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Client.UI</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Client.UI</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.7" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.7" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.7" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.7" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.7" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.7"/>
<PackageReference Include="Avalonia.Desktop" Version="11.2.7"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.7"/>
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.7"/>
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.7"/>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.LmxOpcUa.Client.Shared\ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.LmxOpcUa.Client.Shared\ZB.MOM.WW.LmxOpcUa.Client.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.LmxOpcUa.Client.UI.Tests" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.LmxOpcUa.Client.UI.Tests"/>
</ItemGroup>
</Project>

View File

@@ -1,48 +1,48 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
/// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
/// </summary>
public class AppConfiguration
{
/// <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>
public OpcUaConfiguration OpcUa { get; set; } = new OpcUaConfiguration();
public OpcUaConfiguration OpcUa { get; set; } = new();
/// <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>
public MxAccessConfiguration MxAccess { get; set; } = new MxAccessConfiguration();
public MxAccessConfiguration MxAccess { get; set; } = new();
/// <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>
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new GalaxyRepositoryConfiguration();
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new();
/// <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>
public DashboardConfiguration Dashboard { get; set; } = new DashboardConfiguration();
public DashboardConfiguration Dashboard { get; set; } = new();
/// <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>
public HistorianConfiguration Historian { get; set; } = new HistorianConfiguration();
public HistorianConfiguration Historian { get; set; } = new();
/// <summary>
/// Gets or sets the authentication and role-based access control settings.
/// Gets or sets the authentication and role-based access control settings.
/// </summary>
public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration();
public AuthenticationConfiguration Authentication { get; set; } = new();
/// <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>
public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration();
public SecurityProfileConfiguration Security { get; set; } = new();
/// <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>
public RedundancyConfiguration Redundancy { get; set; } = new RedundancyConfiguration();
public RedundancyConfiguration Redundancy { get; set; } = new();
}
}

View File

@@ -1,25 +1,25 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Authentication and role-based access control settings for the OPC UA server.
/// Authentication and role-based access control settings for the OPC UA server.
/// </summary>
public class AuthenticationConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
/// </summary>
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether anonymous users can write tag values.
/// When false, only authenticated users can write. Existing security classification restrictions still apply.
/// Gets or sets a value indicating whether anonymous users can write tag values.
/// When false, only authenticated users can write. Existing security classification restrictions still apply.
/// </summary>
public bool AnonymousCanWrite { get; set; } = true;
/// <summary>
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
/// credentials are validated against the LDAP server and group membership determines permissions.
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
/// credentials are validated against the LDAP server and group membership determines permissions.
/// </summary>
public LdapConfiguration Ldap { get; set; } = new LdapConfiguration();
public LdapConfiguration Ldap { get; set; } = new();
}
}

View File

@@ -1,30 +1,41 @@
using System;
using System.Linq;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Validates and logs effective configuration at startup. (SVC-003, SVC-005)
/// Validates and logs effective configuration at startup. (SVC-003, SVC-005)
/// </summary>
public static class ConfigurationValidator
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
/// <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>
/// <param name="config">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>
/// <param name="config">
/// 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)
{
bool valid = true;
var valid = true;
Log.Information("=== Effective Configuration ===");
// OPC UA
Log.Information("OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName);
Log.Information(
"OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName,
config.OpcUa.GalaxyName);
Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
@@ -41,10 +52,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
}
// MxAccess
Log.Information("MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}",
Log.Information(
"MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}",
config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds,
config.MxAccess.MaxConcurrentOperations);
Log.Information("MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s",
Log.Information(
"MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s",
config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect,
config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
@@ -55,7 +68,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
}
// Galaxy Repository
Log.Information("GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
Log.Information(
"GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
config.GalaxyRepository.ConnectionString, config.GalaxyRepository.ChangeDetectionIntervalSeconds,
config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
@@ -70,7 +84,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
// Security
Log.Information("Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
Log.Information(
"Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
@@ -80,13 +95,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject);
var unknownProfiles = config.Security.Profiles
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, System.StringComparer.OrdinalIgnoreCase))
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase))
.ToList();
if (unknownProfiles.Count > 0)
{
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
}
if (config.Security.MinimumCertificateKeySize < 2048)
{
@@ -95,14 +108,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
}
if (config.Security.AutoAcceptClientCertificates)
{
Log.Warning("Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
}
Log.Warning(
"Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
if (config.Security.Profiles.Count == 1 && config.Security.Profiles[0].Equals("None", System.StringComparison.OrdinalIgnoreCase))
{
if (config.Security.Profiles.Count == 1 &&
config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase))
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
}
// Authentication
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
@@ -111,51 +122,53 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
if (config.Authentication.Ldap.Enabled)
{
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN);
Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port,
config.Authentication.Ldap.BaseDN);
Log.Information(
"Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
config.Authentication.Ldap.AlarmAckGroup);
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
{
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
}
}
// Redundancy
if (config.OpcUa.ApplicationUri != null)
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
Log.Information("Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, config.Redundancy.ServiceLevelBase);
Log.Information(
"Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role,
config.Redundancy.ServiceLevelBase);
if (config.Redundancy.ServerUris.Count > 0)
Log.Information("Redundancy.ServerUris=[{ServerUris}]", string.Join(", ", config.Redundancy.ServerUris));
Log.Information("Redundancy.ServerUris=[{ServerUris}]",
string.Join(", ", config.Redundancy.ServerUris));
if (config.Redundancy.Enabled)
{
if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
{
Log.Error("OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
Log.Error(
"OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
valid = false;
}
if (config.Redundancy.ServerUris.Count < 2)
{
Log.Warning("Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
}
Log.Warning(
"Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
if (config.OpcUa.ApplicationUri != null && !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
{
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris", config.OpcUa.ApplicationUri);
}
if (config.OpcUa.ApplicationUri != null &&
!config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris",
config.OpcUa.ApplicationUri);
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
if (mode == Opc.Ua.RedundancySupport.None)
{
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", config.Redundancy.Mode);
}
if (mode == RedundancySupport.None)
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None",
config.Redundancy.Mode);
}
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)

View File

@@ -1,22 +1,22 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Status dashboard configuration. (SVC-003, DASH-001)
/// Status dashboard configuration. (SVC-003, DASH-001)
/// </summary>
public class DashboardConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
/// </summary>
public int Port { get; set; } = 8081;
/// <summary>
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
/// </summary>
public int RefreshIntervalSeconds { get; set; } = 10;
}

View File

@@ -1,27 +1,28 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Galaxy repository database configuration. (SVC-003, GR-005)
/// Galaxy repository database configuration. (SVC-003, GR-005)
/// </summary>
public class GalaxyRepositoryConfiguration
{
/// <summary>
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
/// </summary>
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
/// <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>
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
/// </summary>
public bool ExtendedAttributes { get; set; } = false;
}

View File

@@ -1,27 +1,27 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Wonderware Historian database configuration for OPC UA historical data access.
/// Wonderware Historian database configuration for OPC UA historical data access.
/// </summary>
public class HistorianConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether OPC UA historical data access is enabled.
/// Gets or sets a value indicating whether OPC UA historical data access is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the connection string for the Wonderware Historian Runtime database.
/// Gets or sets the connection string for the Wonderware Historian Runtime database.
/// </summary>
public string ConnectionString { get; set; } = "Server=localhost;Database=Runtime;Integrated Security=true;";
/// <summary>
/// Gets or sets the SQL command timeout in seconds for historian queries.
/// Gets or sets the SQL command timeout in seconds for historian queries.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the maximum number of values returned per HistoryRead request.
/// Gets or sets the maximum number of values returned per HistoryRead request.
/// </summary>
public int MaxValuesPerRead { get; set; } = 10000;
}

View File

@@ -1,76 +1,74 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// LDAP authentication and group-to-role mapping settings.
/// LDAP authentication and group-to-role mapping settings.
/// </summary>
public class LdapConfiguration
{
/// <summary>
/// Gets or sets whether LDAP authentication is enabled.
/// When true, user credentials are validated against the configured LDAP server
/// and group membership determines OPC UA permissions.
/// Gets or sets whether LDAP authentication is enabled.
/// When true, user credentials are validated against the configured LDAP server
/// and group membership determines OPC UA permissions.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the LDAP server hostname or IP address.
/// Gets or sets the LDAP server hostname or IP address.
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the LDAP server port.
/// Gets or sets the LDAP server port.
/// </summary>
public int Port { get; set; } = 3893;
/// <summary>
/// Gets or sets the base DN for LDAP operations.
/// Gets or sets the base DN for LDAP operations.
/// </summary>
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the bind DN template. Use {username} as a placeholder.
/// Gets or sets the bind DN template. Use {username} as a placeholder.
/// </summary>
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the service account DN used for LDAP searches (group lookups).
/// Gets or sets the service account DN used for LDAP searches (group lookups).
/// </summary>
public string ServiceAccountDn { get; set; } = "";
/// <summary>
/// Gets or sets the service account password.
/// Gets or sets the service account password.
/// </summary>
public string ServiceAccountPassword { get; set; } = "";
/// <summary>
/// Gets or sets the LDAP connection timeout in seconds.
/// Gets or sets the LDAP connection timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the LDAP group name that grants read-only access.
/// Gets or sets the LDAP group name that grants read-only access.
/// </summary>
public string ReadOnlyGroup { get; set; } = "ReadOnly";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
/// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
/// </summary>
public string WriteOperateGroup { get; set; } = "WriteOperate";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
/// </summary>
public string WriteTuneGroup { get; set; } = "WriteTune";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
/// </summary>
public string WriteConfigureGroup { get; set; } = "WriteConfigure";
/// <summary>
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
/// </summary>
public string AlarmAckGroup { get; set; } = "AlarmAck";
}

View File

@@ -1,57 +1,58 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
/// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
/// </summary>
public class MxAccessConfiguration
{
/// <summary>
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
/// </summary>
public string ClientName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
/// </summary>
public string? NodeName { get; set; }
/// <summary>
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
/// </summary>
public string? GalaxyName { get; set; }
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
/// </summary>
public int ReadTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
/// </summary>
public int WriteTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
/// </summary>
public int MaxConcurrentOperations { get; set; } = 10;
/// <summary>
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
/// </summary>
public int MonitorIntervalSeconds { get; set; } = 5;
/// <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>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
/// </summary>
public string? ProbeTag { get; set; }
/// <summary>
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
/// </summary>
public int ProbeStaleThresholdSeconds { get; set; } = 60;
}

View File

@@ -1,56 +1,56 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
/// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
/// </summary>
public class OpcUaConfiguration
{
/// <summary>
/// Gets or sets the IP address or hostname the OPC UA server binds to.
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
/// Gets or sets the IP address or hostname the OPC UA server binds to.
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
/// </summary>
public string BindAddress { get; set; } = "0.0.0.0";
/// <summary>
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
/// </summary>
public int Port { get; set; } = 4840;
/// <summary>
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
/// </summary>
public string EndpointPath { get; set; } = "/LmxOpcUa";
/// <summary>
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
/// </summary>
public string ServerName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
/// </summary>
public string GalaxyName { get; set; } = "ZB";
/// <summary>
/// Gets or sets the explicit application URI for this server instance.
/// When <see langword="null"/>, defaults to <c>urn:{GalaxyName}:LmxOpcUa</c>.
/// Must be set to a unique value per instance when redundancy is enabled.
/// Gets or sets the explicit application URI for this server instance.
/// When <see langword="null" />, defaults to <c>urn:{GalaxyName}:LmxOpcUa</c>.
/// Must be set to a unique value per instance when redundancy is enabled.
/// </summary>
public string? ApplicationUri { get; set; }
/// <summary>
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// </summary>
public int MaxSessions { get; set; } = 100;
/// <summary>
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
/// </summary>
public int SessionTimeoutMinutes { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether alarm tracking is enabled.
/// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored.
/// Gets or sets a value indicating whether alarm tracking is enabled.
/// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored.
/// </summary>
public bool AlarmTrackingEnabled { get; set; } = false;
}

View File

@@ -3,38 +3,38 @@ using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Non-transparent redundancy settings that control how the server advertises itself
/// within a redundant pair and computes its dynamic ServiceLevel.
/// Non-transparent redundancy settings that control how the server advertises itself
/// within a redundant pair and computes its dynamic ServiceLevel.
/// </summary>
public class RedundancyConfiguration
{
/// <summary>
/// Gets or sets whether redundancy is enabled. When <see langword="false"/> (default),
/// the server reports <c>RedundancySupport.None</c> and <c>ServiceLevel = 255</c>.
/// Gets or sets whether redundancy is enabled. When <see langword="false" /> (default),
/// the server reports <c>RedundancySupport.None</c> and <c>ServiceLevel = 255</c>.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the redundancy mode. Valid values: <c>Warm</c>, <c>Hot</c>.
/// Gets or sets the redundancy mode. Valid values: <c>Warm</c>, <c>Hot</c>.
/// </summary>
public string Mode { get; set; } = "Warm";
/// <summary>
/// Gets or sets the role of this instance. Valid values: <c>Primary</c>, <c>Secondary</c>.
/// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
/// Gets or sets the role of this instance. Valid values: <c>Primary</c>, <c>Secondary</c>.
/// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
/// </summary>
public string Role { get; set; } = "Primary";
/// <summary>
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
/// </summary>
public List<string> ServerUris { get; set; } = new List<string>();
public List<string> ServerUris { get; set; } = new();
/// <summary>
/// Gets or sets the base ServiceLevel when the server is fully healthy.
/// The secondary automatically receives <c>ServiceLevelBase - 50</c>.
/// Valid range: 1-255.
/// Gets or sets the base ServiceLevel when the server is fully healthy.
/// The secondary automatically receives <c>ServiceLevelBase - 50</c>.
/// Valid range: 1-255.
/// </summary>
public int ServiceLevelBase { get; set; } = 200;
}

View File

@@ -3,42 +3,43 @@ using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <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>
public class SecurityProfileConfiguration
{
/// <summary>
/// Gets or sets the list of security profile names to expose as server endpoints.
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
/// Defaults to ["None"] for backward compatibility.
/// Gets or sets the list of security profile names to expose as server endpoints.
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
/// Defaults to ["None"] for backward compatibility.
/// </summary>
public List<string> Profiles { get; set; } = new List<string> { "None" };
public List<string> Profiles { get; set; } = new() { "None" };
/// <summary>
/// Gets or sets a value indicating whether the server automatically accepts client certificates
/// that are not in the trusted store. Should be <see langword="false"/> in production.
/// Gets or sets a value indicating whether the server automatically accepts client certificates
/// that are not in the trusted store. Should be <see langword="false" /> in production.
/// </summary>
public bool AutoAcceptClientCertificates { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
/// </summary>
public bool RejectSHA1Certificates { get; set; } = true;
/// <summary>
/// Gets or sets the minimum RSA key size required for client certificates.
/// Gets or sets the minimum RSA key size required for client certificates.
/// </summary>
public int MinimumCertificateKeySize { get; set; } = 2048;
/// <summary>
/// Gets or sets an optional override for the PKI root directory.
/// When <see langword="null"/>, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
/// Gets or sets an optional override for the PKI root directory.
/// When <see langword="null" />, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
/// </summary>
public string? PkiRootPath { get; set; }
/// <summary>
/// Gets or sets an optional override for the server certificate subject name.
/// When <see langword="null"/>, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
/// Gets or sets an optional override for the server certificate subject name.
/// When <see langword="null" />, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
/// </summary>
public string? CertificateSubject { get; set; }
}

View File

@@ -1,37 +1,37 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// MXAccess connection lifecycle states. (MXA-002)
/// MXAccess connection lifecycle states. (MXA-002)
/// </summary>
public enum ConnectionState
{
/// <summary>
/// No active session exists to the Galaxy runtime.
/// No active session exists to the Galaxy runtime.
/// </summary>
Disconnected,
/// <summary>
/// The bridge is opening a new MXAccess session to the runtime.
/// The bridge is opening a new MXAccess session to the runtime.
/// </summary>
Connecting,
/// <summary>
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
/// </summary>
Connected,
/// <summary>
/// The bridge is closing the current MXAccess session and draining runtime resources.
/// The bridge is closing the current MXAccess session and draining runtime resources.
/// </summary>
Disconnecting,
/// <summary>
/// The bridge detected a connection fault that requires operator attention or recovery logic.
/// The bridge detected a connection fault that requires operator attention or recovery logic.
/// </summary>
Error,
/// <summary>
/// The bridge is attempting to restore service after a runtime communication failure.
/// The bridge is attempting to restore service after a runtime communication failure.
/// </summary>
Reconnecting
}

View File

@@ -3,27 +3,12 @@ using System;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Event args for connection state transitions. (MXA-002)
/// Event args for connection state transitions. (MXA-002)
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Gets the previous MXAccess connection state before the transition was raised.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the new MXAccess connection state that the bridge moved into.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets an operator-facing message that explains why the connection state changed.
/// </summary>
public string Message { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs"/> class.
/// 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>
@@ -34,5 +19,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
CurrentState = current;
Message = message ?? "";
}
/// <summary>
/// Gets the previous MXAccess connection state before the transition was raised.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the new MXAccess connection state that the bridge moved into.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets an operator-facing message that explains why the connection state changed.
/// </summary>
public string Message { get; }
}
}

View File

@@ -1,73 +1,75 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// DTO matching attributes.sql result columns. (GR-002)
/// DTO matching attributes.sql result columns. (GR-002)
/// </summary>
public class GalaxyAttributeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier that owns the attribute.
/// Gets or sets the Galaxy object identifier that owns the attribute.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
/// </summary>
public string DataTypeName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <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>
public string AttributeSource { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
/// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly.
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
/// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly.
/// </summary>
public int SecurityClassification { get; set; } = 1;
/// <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>
public bool IsHistorized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm.
/// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm.
/// </summary>
public bool IsAlarm { get; set; }
}

View File

@@ -1,37 +1,37 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// DTO matching hierarchy.sql result columns. (GR-001)
/// DTO matching hierarchy.sql result columns. (GR-001)
/// </summary>
public class GalaxyObjectInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the contained name shown for the object inside its parent area or object.
/// Gets or sets the contained name shown for the object inside its parent area or object.
/// </summary>
public string ContainedName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
/// </summary>
public bool IsArea { get; set; }
}

View File

@@ -6,40 +6,40 @@ using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Interface for Galaxy repository database queries. (GR-001 through GR-004)
/// Interface for Galaxy repository database queries. (GR-001 through GR-004)
/// </summary>
public interface IGalaxyRepository
{
/// <summary>
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of Galaxy objects ordered for address-space construction.</returns>
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
/// <summary>
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of attribute definitions with MXAccess references and type metadata.</returns>
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
/// <summary>
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>The latest deploy timestamp, or <see langword="null"/> when it cannot be determined.</returns>
/// <returns>The latest deploy timestamp, or <see langword="null" /> when it cannot be determined.</returns>
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
/// <summary>
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true"/> when repository access succeeds; otherwise, <see langword="false"/>.</returns>
/// <returns><see langword="true" /> when repository access succeeds; otherwise, <see langword="false" />.</returns>
Task<bool> TestConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
/// </summary>
event Action? OnGalaxyChanged;
}

View File

@@ -5,52 +5,62 @@ using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Abstraction over MXAccess COM client for tag read/write/subscribe operations.
/// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009)
/// Abstraction over MXAccess COM client for tag read/write/subscribe operations.
/// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009)
/// </summary>
public interface IMxAccessClient : IDisposable
{
/// <summary>
/// Gets the current runtime connectivity state for the bridge.
/// Gets the current runtime connectivity state for the bridge.
/// </summary>
ConnectionState State { get; }
/// <summary>
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
/// </summary>
int ActiveSubscriptionCount { get; }
/// <summary>
/// Gets the number of reconnect cycles attempted since the client was created.
/// </summary>
int ReconnectCount { get; }
/// <summary>
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
/// </summary>
event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Closes the MXAccess session and releases runtime resources.
/// Closes the MXAccess session and releases runtime resources.
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="callback">The callback to invoke when the runtime publishes a new value for the attribute.</param>
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
/// <summary>
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
Task UnsubscribeAsync(string fullTagReference);
/// <summary>
/// Reads the current runtime value for a Galaxy attribute.
/// Reads the current runtime value for a Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="ct">A token that cancels the read.</param>
@@ -58,22 +68,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
/// <summary>
/// Writes a new runtime value to a writable Galaxy attribute.
/// Writes a new runtime value to a writable Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="value">The value to write to the runtime.</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);
/// <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; }
}
}

View File

@@ -1,10 +1,9 @@
using System;
using ArchestrA.MxAccess;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that raised the change.</param>
/// <param name="phItemHandle">The runtime item handle for the attribute that changed.</param>
@@ -21,7 +20,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
ref MXSTATUS_PROXY[] ItemStatus);
/// <summary>
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that processed the write.</param>
/// <param name="phItemHandle">The runtime item handle that was written.</param>
@@ -32,25 +31,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
ref MXSTATUS_PROXY[] ItemStatus);
/// <summary>
/// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001)
/// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001)
/// </summary>
public interface IMxProxy
{
/// <summary>
/// Registers the bridge as an MXAccess client with the runtime proxy.
/// Registers the bridge as an MXAccess client with the runtime proxy.
/// </summary>
/// <param name="clientName">The client identity reported to the runtime for diagnostics and session tracking.</param>
/// <returns>The runtime connection handle assigned to the client session.</returns>
int Register(string clientName);
/// <summary>
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
/// </summary>
/// <param name="handle">The connection handle returned by <see cref="Register(string)"/>.</param>
/// <param name="handle">The connection handle returned by <see cref="Register(string)" />.</param>
void Unregister(int handle);
/// <summary>
/// Adds a Galaxy attribute reference to the active runtime session.
/// Adds a Galaxy attribute reference to the active runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified attribute reference to resolve.</param>
@@ -58,28 +57,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
int AddItem(int handle, string address);
/// <summary>
/// Removes a previously registered attribute from the runtime session.
/// Removes a previously registered attribute from the runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)"/>.</param>
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)" />.</param>
void RemoveItem(int handle, int itemHandle);
/// <summary>
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
void AdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Stops supervisory updates for an attribute.
/// Stops supervisory updates for an attribute.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
void UnAdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Writes a new value to a runtime attribute through the COM proxy.
/// Writes a new value to a runtime attribute through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
@@ -88,12 +87,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
void Write(int handle, int itemHandle, object value, int securityClassification);
/// <summary>
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
/// </summary>
event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the runtime acknowledges completion of a write request.
/// Occurs when the runtime acknowledges completion of a write request.
/// </summary>
event MxWriteCompleteHandler? OnWriteComplete;
}

View File

@@ -3,31 +3,32 @@ using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <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>
public interface IUserAuthenticationProvider
{
/// <summary>
/// Validates a username/password combination.
/// Validates a username/password combination.
/// </summary>
bool ValidateCredentials(string username, string password);
}
/// <summary>
/// Extended interface for providers that can resolve application-level roles for authenticated users.
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
/// to control write and alarm-ack permissions.
/// Extended interface for providers that can resolve application-level roles for authenticated users.
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
/// to control write and alarm-ack permissions.
/// </summary>
public interface IRoleProvider
{
/// <summary>
/// Returns the set of application-level roles granted to the user.
/// Returns the set of application-level roles granted to the user.
/// </summary>
IReadOnlyList<string> GetUserRoles(string username);
}
/// <summary>
/// Well-known application-level role names used for permission enforcement.
/// Well-known application-level role names used for permission enforcement.
/// </summary>
public static class AppRoles
{

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.DirectoryServices.Protocols;
using System.Linq;
using System.Net;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
@@ -9,7 +8,7 @@ using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Validates credentials via LDAP bind and resolves group membership to application roles.
/// Validates credentials via LDAP bind and resolves group membership to application roles.
/// </summary>
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
{
@@ -31,30 +30,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
};
}
public bool ValidateCredentials(string username, string password)
{
try
{
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
using (var connection = CreateConnection())
{
connection.Bind(new NetworkCredential(bindDn, password));
}
Log.Debug("LDAP bind succeeded for {Username}", username);
return true;
}
catch (LdapException ex)
{
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
return false;
}
catch (Exception ex)
{
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
return false;
}
}
public IReadOnlyList<string> GetUserRoles(string username)
{
try
@@ -87,15 +62,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
}
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() ?? "";
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
var groupName = ExtractGroupName(dn);
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role))
{
roles.Add(role);
}
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role);
}
if (roles.Count == 0)
@@ -115,6 +87,31 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
}
}
public bool ValidateCredentials(string username, string password)
{
try
{
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
using (var connection = CreateConnection())
{
connection.Bind(new NetworkCredential(bindDn, password));
}
Log.Debug("LDAP bind succeeded for {Username}", username);
return true;
}
catch (LdapException ex)
{
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
return false;
}
catch (Exception ex)
{
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
return false;
}
}
private LdapConnection CreateConnection()
{
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);

View File

@@ -1,9 +1,9 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
/// The namespace URI is registered in the server namespace table at startup,
/// and the string identifiers are resolved to runtime NodeIds before use.
/// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
/// The namespace URI is registered in the server namespace table at startup,
/// and the string identifiers are resolved to runtime NodeIds before use.
/// </summary>
public static class LmxRoleIds
{

View File

@@ -3,14 +3,14 @@ using System;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005)
/// See gr/data_type_mapping.md for full mapping table.
/// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005)
/// See gr/data_type_mapping.md for full mapping table.
/// </summary>
public static class MxDataTypeMapper
{
/// <summary>
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
/// Unknown types default to String (i=12).
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
/// Unknown types default to String (i=12).
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA built-in data type node identifier.</returns>
@@ -18,24 +18,24 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
return mxDataType switch
{
1 => 1, // Boolean → i=1
2 => 6, // Integer → Int32 i=6
3 => 10, // Float → Float i=10
4 => 11, // Double → Double i=11
5 => 12, // String → String i=12
6 => 13, // Time → DateTime i=13
7 => 11, // ElapsedTime → Double i=11 (seconds)
8 => 12, // Reference → String i=12
13 => 6, // Enumeration → Int32 i=6
14 => 12, // Custom → String i=12
15 => 21, // InternationalizedString → LocalizedText i=21
16 => 12, // Custom → String i=12
_ => 12 // Unknown → String i=12
1 => 1, // Boolean → i=1
2 => 6, // Integer → Int32 i=6
3 => 10, // Float → Float i=10
4 => 11, // Double → Double i=11
5 => 12, // String → String i=12
6 => 13, // Time → DateTime i=13
7 => 11, // ElapsedTime → Double i=11 (seconds)
8 => 12, // Reference → String i=12
13 => 6, // Enumeration → Int32 i=6
14 => 12, // Custom → String i=12
15 => 21, // InternationalizedString → LocalizedText i=21
16 => 12, // Custom → String i=12
_ => 12 // Unknown → String i=12
};
}
/// <summary>
/// Maps mx_data_type to the corresponding CLR type.
/// Maps mx_data_type to the corresponding CLR type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The CLR type used to represent runtime values for the MX type.</returns>
@@ -49,18 +49,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
4 => typeof(double),
5 => typeof(string),
6 => typeof(DateTime),
7 => typeof(double), // ElapsedTime as seconds
8 => typeof(string), // Reference as string
13 => typeof(int), // Enum backing integer
7 => typeof(double), // ElapsedTime as seconds
8 => typeof(string), // Reference as string
13 => typeof(int), // Enum backing integer
14 => typeof(string),
15 => typeof(string), // LocalizedText stored as string
15 => typeof(string), // LocalizedText stored as string
16 => typeof(string),
_ => typeof(string)
};
}
/// <summary>
/// Returns the OPC UA type name for a given mx_data_type.
/// Returns the OPC UA type name for a given mx_data_type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA type name used in diagnostics.</returns>

View File

@@ -1,42 +1,42 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
/// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
/// </summary>
public static class MxErrorCodes
{
/// <summary>
/// The requested Galaxy attribute reference does not resolve in the runtime.
/// The requested Galaxy attribute reference does not resolve in the runtime.
/// </summary>
public const int MX_E_InvalidReference = 1008;
/// <summary>
/// The supplied value does not match the attribute's configured data type.
/// The supplied value does not match the attribute's configured data type.
/// </summary>
public const int MX_E_WrongDataType = 1012;
/// <summary>
/// The target attribute cannot be written because it is read-only or protected.
/// The target attribute cannot be written because it is read-only or protected.
/// </summary>
public const int MX_E_NotWritable = 1013;
/// <summary>
/// The runtime did not complete the operation within the configured timeout.
/// The runtime did not complete the operation within the configured timeout.
/// </summary>
public const int MX_E_RequestTimedOut = 1014;
/// <summary>
/// Communication with the MXAccess runtime failed during the operation.
/// Communication with the MXAccess runtime failed during the operation.
/// </summary>
public const int MX_E_CommFailure = 1015;
/// <summary>
/// The operation was attempted without an active MXAccess session.
/// The operation was attempted without an active MXAccess session.
/// </summary>
public const int MX_E_NotConnected = 1016;
/// <summary>
/// Converts a numeric MXAccess error code into an operator-facing message.
/// Converts a numeric MXAccess error code into an operator-facing message.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>A human-readable description of the runtime failure.</returns>
@@ -55,7 +55,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
}
/// <summary>
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>The quality classification that best represents the runtime failure.</returns>

View File

@@ -1,101 +1,122 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005)
/// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005)
/// </summary>
public enum Quality : byte
{
// Bad family (0-63)
/// <summary>
/// No valid process value is available.
/// No valid process value is available.
/// </summary>
Bad = 0,
/// <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>
BadConfigError = 4,
/// <summary>
/// The bridge is not currently connected to the Galaxy runtime.
/// The bridge is not currently connected to the Galaxy runtime.
/// </summary>
BadNotConnected = 8,
/// <summary>
/// The runtime device or adapter failed while obtaining the value.
/// The runtime device or adapter failed while obtaining the value.
/// </summary>
BadDeviceFailure = 12,
/// <summary>
/// The underlying field source reported a bad sensor condition.
/// The underlying field source reported a bad sensor condition.
/// </summary>
BadSensorFailure = 16,
/// <summary>
/// Communication with the runtime failed while retrieving the value.
/// Communication with the runtime failed while retrieving the value.
/// </summary>
BadCommFailure = 20,
/// <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>
BadOutOfService = 24,
/// <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>
BadWaitingForInitialData = 32,
// Uncertain family (64-191)
/// <summary>
/// A value is available, but it should be treated cautiously.
/// A value is available, but it should be treated cautiously.
/// </summary>
Uncertain = 64,
/// <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>
UncertainLastUsable = 68,
/// <summary>
/// The sensor or source is providing a value with reduced accuracy.
/// The sensor or source is providing a value with reduced accuracy.
/// </summary>
UncertainSensorNotAccurate = 80,
/// <summary>
/// The value exceeds its engineered limits.
/// The value exceeds its engineered limits.
/// </summary>
UncertainEuExceeded = 84,
/// <summary>
/// The source is operating in a degraded or subnormal state.
/// The source is operating in a degraded or subnormal state.
/// </summary>
UncertainSubNormal = 88,
// Good family (192+)
/// <summary>
/// The value is current and suitable for normal client use.
/// The value is current and suitable for normal client use.
/// </summary>
Good = 192,
/// <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>
GoodLocalOverride = 216
}
/// <summary>
/// Helper methods for reasoning about OPC quality families used by the bridge.
/// Helper methods for reasoning about OPC quality families used by the bridge.
/// </summary>
public static class QualityExtensions
{
/// <summary>
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
/// </summary>
/// <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>
public static bool IsGood(this Quality q) => (byte)q >= 192;
/// <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)
{
return (byte)q >= 192;
}
/// <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>
/// <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>
public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 192;
/// <returns><see langword="true" /> when the value is in the uncertain range; otherwise, <see langword="false" />.</returns>
public static bool IsUncertain(this Quality q)
{
return (byte)q >= 64 && (byte)q < 192;
}
/// <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>
/// <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>
public static bool IsBad(this Quality q) => (byte)q < 64;
/// <returns><see langword="true" /> when the value is in the bad range; otherwise, <see langword="false" />.</returns>
public static bool IsBad(this Quality q)
{
return (byte)q < 64;
}
}
}

View File

@@ -1,13 +1,15 @@
using System;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005)
/// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005)
/// </summary>
public static class QualityMapper
{
/// <summary>
/// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
/// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
/// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
/// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
/// </summary>
/// <param name="mxQuality">The raw MXAccess quality integer.</param>
/// <returns>The mapped bridge quality value.</returns>
@@ -16,7 +18,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
var b = (byte)(mxQuality & 0xFF);
// Try exact match first
if (System.Enum.IsDefined(typeof(Quality), b))
if (Enum.IsDefined(typeof(Quality), b))
return (Quality)b;
// Fall back to category
@@ -26,7 +28,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
}
/// <summary>
/// Maps domain Quality to OPC UA StatusCode uint32.
/// Maps domain Quality to OPC UA StatusCode uint32.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code represented as a 32-bit unsigned integer.</returns>
@@ -34,14 +36,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
return quality switch
{
Quality.Good => 0x00000000u, // Good
Quality.Good => 0x00000000u, // Good
Quality.GoodLocalOverride => 0x00D80000u, // Good_LocalOverride
Quality.Uncertain => 0x40000000u, // Uncertain
Quality.Uncertain => 0x40000000u, // Uncertain
Quality.UncertainLastUsable => 0x40900000u,
Quality.UncertainSensorNotAccurate => 0x40930000u,
Quality.UncertainEuExceeded => 0x40940000u,
Quality.UncertainSubNormal => 0x40950000u,
Quality.Bad => 0x80000000u, // Bad
Quality.Bad => 0x80000000u, // Bad
Quality.BadConfigError => 0x80890000u,
Quality.BadNotConnected => 0x808A0000u,
Quality.BadDeviceFailure => 0x808B0000u,
@@ -50,8 +52,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
Quality.BadOutOfService => 0x808D0000u,
Quality.BadWaitingForInitialData => 0x80320000u,
_ => quality.IsGood() ? 0x00000000u :
quality.IsUncertain() ? 0x40000000u :
0x80000000u
quality.IsUncertain() ? 0x40000000u :
0x80000000u
};
}
}

View File

@@ -1,17 +1,19 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Maps Galaxy security classification values to OPC UA write access decisions.
/// See gr/data_type_mapping.md for the full mapping table.
/// Maps Galaxy security classification values to OPC UA write access decisions.
/// See gr/data_type_mapping.md for the full mapping table.
/// </summary>
public static class SecurityClassificationMapper
{
/// <summary>
/// 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>
/// <param name="securityClassification">The Galaxy security classification value.</param>
/// <returns><see langword="true"/> for FreeAccess (0), Operate (1), Tune (4), Configure (5);
/// <see langword="false"/> for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).</returns>
/// <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)
{
switch (securityClassification)

View File

@@ -3,27 +3,27 @@ using System;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
/// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the runtime value returned for the Galaxy attribute.
/// Gets the runtime value returned for the Galaxy attribute.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp associated with the runtime value.
/// Gets the timestamp associated with the runtime value.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq"/> struct for a Galaxy attribute value.
/// Initializes a new instance of the <see cref="Vtq" /> struct for a Galaxy attribute value.
/// </summary>
/// <param name="value">The runtime value returned by MXAccess.</param>
/// <param name="timestamp">The timestamp assigned to the runtime value.</param>
@@ -36,41 +36,61 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
}
/// <summary>
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
/// </summary>
/// <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>
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>
/// 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>
/// <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>
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>
/// 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>
/// <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>
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>
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
/// </summary>
/// <param name="other">The other VTQ snapshot to compare.</param>
/// <returns><see langword="true"/> when all fields match; otherwise, <see langword="false"/>.</returns>
public bool Equals(Vtq other) =>
Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
/// <returns><see langword="true" /> when all fields match; otherwise, <see langword="false" />.</returns>
public bool Equals(Vtq other)
{
return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
}
/// <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 />
public override int GetHashCode() => HashCode.Combine(Value, Timestamp, Quality);
public override int GetHashCode()
{
return HashCode.Combine(Value, Timestamp, Quality);
}
/// <inheritdoc />
public override string ToString() => $"Vtq({Value}, {Timestamp:O}, {Quality})";
public override string ToString()
{
return $"Vtq({Value}, {Timestamp:O}, {Quality})";
}
}
}

View File

@@ -2,48 +2,56 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
{
/// <summary>
/// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004)
/// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004)
/// </summary>
public class ChangeDetectionService : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<ChangeDetectionService>();
private readonly int _intervalSeconds;
private readonly IGalaxyRepository _repository;
private readonly int _intervalSeconds;
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>
/// Initializes a new change detector for Galaxy deploy timestamps.
/// Initializes a new change detector for Galaxy deploy timestamps.
/// </summary>
/// <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="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;
_intervalSeconds = intervalSeconds;
_lastKnownDeployTime = initialDeployTime;
LastKnownDeployTime = initialDeployTime;
}
/// <summary>
/// Starts the background polling loop that watches for Galaxy deploy changes.
/// 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>
/// Starts the background polling loop that watches for Galaxy deploy changes.
/// </summary>
public void Start()
{
@@ -53,7 +61,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
}
/// <summary>
/// Stops the background polling loop.
/// Stops the background polling loop.
/// </summary>
public void Stop()
{
@@ -64,7 +72,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
private async Task PollLoopAsync(CancellationToken ct)
{
// If no initial deploy time was provided, first poll triggers unconditionally
bool firstPoll = _lastKnownDeployTime == null;
var firstPoll = LastKnownDeployTime == null;
while (!ct.IsCancellationRequested)
{
@@ -75,15 +83,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
if (firstPoll)
{
firstPoll = false;
_lastKnownDeployTime = deployTime;
LastKnownDeployTime = deployTime;
Log.Information("Initial deploy time: {DeployTime}", deployTime);
OnGalaxyChanged?.Invoke();
}
else if (deployTime != _lastKnownDeployTime)
else if (deployTime != LastKnownDeployTime)
{
Log.Information("Galaxy deployment change detected: {Previous} → {Current}",
_lastKnownDeployTime, deployTime);
_lastKnownDeployTime = deployTime;
LastKnownDeployTime, deployTime);
LastKnownDeployTime = deployTime;
OnGalaxyChanged?.Invoke();
}
}
@@ -106,14 +114,5 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
}
}
}
/// <summary>
/// Stops the polling loop and disposes the underlying cancellation resources.
/// </summary>
public void Dispose()
{
Stop();
_cts?.Dispose();
}
}
}

View File

@@ -10,7 +10,7 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
{
/// <summary>
/// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007)
/// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007)
/// </summary>
public class GalaxyRepositoryService : IGalaxyRepository
{
@@ -19,10 +19,178 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
private readonly GalaxyRepositoryConfiguration _config;
/// <summary>
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
/// </summary>
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
{
_config = config;
}
/// <summary>
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
/// </summary>
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)
private const string HierarchySql = @"
@@ -263,172 +431,5 @@ ORDER BY tag_name, primitive_name, attribute_name";
private const string TestConnectionSql = "SELECT 1";
#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();
}
}

View File

@@ -3,37 +3,37 @@ using System;
namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
{
/// <summary>
/// POCO for dashboard: Galaxy repository status info. (DASH-009)
/// POCO for dashboard: Galaxy repository status info. (DASH-009)
/// </summary>
public class GalaxyRepositoryStats
{
/// <summary>
/// Gets or sets the Galaxy name currently being represented by the bridge.
/// Gets or sets the Galaxy name currently being represented by the bridge.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}

View File

@@ -11,7 +11,7 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
{
/// <summary>
/// Reads historical data from the Wonderware Historian Runtime database.
/// Reads historical data from the Wonderware Historian Runtime database.
/// </summary>
public class HistorianDataSource
{
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
private readonly HistorianConfiguration _config;
/// <summary>
/// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian queries.
/// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian queries.
/// </summary>
/// <param name="config">The Historian connection settings and command timeout used for runtime history lookups.</param>
public HistorianDataSource(HistorianConfiguration config)
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
}
/// <summary>
/// Reads raw historical values for a tag from the Historian.
/// Reads raw historical values for a tag from the Historian.
/// </summary>
/// <param name="tagName">The Wonderware tag name backing the OPC UA node whose raw history is being requested.</param>
/// <param name="startTime">The inclusive start of the client-requested history window.</param>
@@ -84,7 +84,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
}
/// <summary>
/// Reads aggregate historical values for a tag from the Historian.
/// Reads aggregate historical values for a tag from the Historian.
/// </summary>
/// <param name="tagName">The Wonderware tag name backing the OPC UA node whose aggregate history is being requested.</param>
/// <param name="startTime">The inclusive start of the aggregate history window requested by the OPC UA client.</param>
@@ -134,8 +134,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
/// <summary>
/// Maps an OPC UA aggregate NodeId to the corresponding Historian column name.
/// Returns null if the aggregate is not supported.
/// Maps an OPC UA aggregate NodeId to the corresponding Historian column name.
/// Returns null if the aggregate is not supported.
/// </summary>
/// <param name="aggregateId">The OPC UA aggregate identifier requested by the history client.</param>
public static string? MapAggregateToColumn(NodeId aggregateId)

View File

@@ -9,73 +9,73 @@ using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
{
/// <summary>
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation"/>. (MXA-008)
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation" />. (MXA-008)
/// </summary>
public interface ITimingScope : IDisposable
{
/// <summary>
/// Marks whether the timed bridge operation completed successfully.
/// Marks whether the timed bridge operation completed successfully.
/// </summary>
/// <param name="success">A value indicating whether the measured operation succeeded.</param>
void SetSuccess(bool success);
}
/// <summary>
/// Statistics snapshot for a single operation type.
/// Statistics snapshot for a single operation type.
/// </summary>
public class MetricsStatistics
{
/// <summary>
/// Gets or sets the total number of recorded executions for the operation.
/// Gets or sets the total number of recorded executions for the operation.
/// </summary>
public long TotalCount { get; set; }
/// <summary>
/// Gets or sets the number of recorded executions that completed successfully.
/// Gets or sets the number of recorded executions that completed successfully.
/// </summary>
public long SuccessCount { get; set; }
/// <summary>
/// Gets or sets the ratio of successful executions to total executions.
/// Gets or sets the ratio of successful executions to total executions.
/// </summary>
public double SuccessRate { get; set; }
/// <summary>
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
/// </summary>
public double AverageMilliseconds { get; set; }
/// <summary>
/// Gets or sets the fastest recorded execution time in milliseconds.
/// Gets or sets the fastest recorded execution time in milliseconds.
/// </summary>
public double MinMilliseconds { get; set; }
/// <summary>
/// Gets or sets the slowest recorded execution time in milliseconds.
/// Gets or sets the slowest recorded execution time in milliseconds.
/// </summary>
public double MaxMilliseconds { get; set; }
/// <summary>
/// Gets or sets the 95th percentile execution time in milliseconds.
/// Gets or sets the 95th percentile execution time in milliseconds.
/// </summary>
public double Percentile95Milliseconds { get; set; }
}
/// <summary>
/// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008)
/// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008)
/// </summary>
public class OperationMetrics
{
private readonly List<double> _durations = new List<double>();
private readonly object _lock = new object();
private long _totalCount;
private long _successCount;
private double _totalMilliseconds;
private double _minMilliseconds = double.MaxValue;
private readonly List<double> _durations = new();
private readonly object _lock = new();
private double _maxMilliseconds;
private double _minMilliseconds = double.MaxValue;
private long _successCount;
private long _totalCount;
private double _totalMilliseconds;
/// <summary>
/// Records the outcome and duration of a single bridge operation invocation.
/// Records the outcome and duration of a single bridge operation invocation.
/// </summary>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
@@ -98,7 +98,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
/// <summary>
/// Creates a snapshot of the current statistics for this operation type.
/// Creates a snapshot of the current statistics for this operation type.
/// </summary>
/// <returns>A statistics snapshot suitable for logs, status reporting, and tests.</returns>
public MetricsStatistics GetStatistics()
@@ -126,20 +126,20 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
/// <summary>
/// Tracks per-operation performance metrics with periodic logging. (MXA-008)
/// Tracks per-operation performance metrics with periodic logging. (MXA-008)
/// </summary>
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics
= new ConcurrentDictionary<string, OperationMetrics>(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, OperationMetrics>
_metrics = new(StringComparer.OrdinalIgnoreCase);
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new metrics collector and starts periodic performance reporting.
/// Initializes a new metrics collector and starts periodic performance reporting.
/// </summary>
public PerformanceMetrics()
{
@@ -148,7 +148,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
/// <summary>
/// Records a completed bridge operation under the specified metrics bucket.
/// Stops periodic reporting and emits a final metrics snapshot.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_reportingTimer.Dispose();
ReportMetrics(null);
}
/// <summary>
/// Records a completed bridge operation under the specified metrics bucket.
/// </summary>
/// <param name="operationName">The logical operation name, such as read, write, or subscribe.</param>
/// <param name="duration">The elapsed time for the operation.</param>
@@ -160,7 +171,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
/// <summary>
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
/// </summary>
/// <param name="operationName">The logical operation name to record.</param>
/// <returns>A timing scope that reports elapsed time back into this collector.</returns>
@@ -170,17 +181,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
/// <summary>
/// Retrieves the raw metrics bucket for a named operation.
/// Retrieves the raw metrics bucket for a named operation.
/// </summary>
/// <param name="operationName">The logical operation name to look up.</param>
/// <returns>The metrics bucket when present; otherwise, <see langword="null"/>.</returns>
/// <returns>The metrics bucket when present; otherwise, <see langword="null" />.</returns>
public OperationMetrics? GetMetrics(string operationName)
{
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
}
/// <summary>
/// Produces a statistics snapshot for all recorded bridge operations.
/// Produces a statistics snapshot for all recorded bridge operations.
/// </summary>
/// <returns>A dictionary keyed by operation name containing current metrics statistics.</returns>
public Dictionary<string, MetricsStatistics> GetStatistics()
@@ -208,29 +219,18 @@ 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>
/// 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>
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
private readonly string _operationName;
private readonly Stopwatch _stopwatch;
private bool _success = true;
private bool _disposed;
private bool _success = true;
/// <summary>
/// Initializes a timing scope for a named bridge operation.
/// Initializes a timing scope for a named bridge operation.
/// </summary>
/// <param name="metrics">The metrics collector that should receive the result.</param>
/// <param name="operationName">The logical operation name being timed.</param>
@@ -242,13 +242,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
}
/// <summary>
/// Marks whether the timed operation should be recorded as successful.
/// Marks whether the timed operation should be recorded as successful.
/// </summary>
/// <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>
/// Stops timing and records the operation result once.
/// Stops timing and records the operation result once.
/// </summary>
public void Dispose()
{

View File

@@ -1,8 +1,6 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
@@ -10,7 +8,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
public sealed partial class MxAccessClient
{
/// <summary>
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
public async Task ConnectAsync(CancellationToken ct = default)
@@ -59,7 +57,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
/// </summary>
public async Task DisconnectAsync()
{
@@ -72,7 +70,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
// UnAdvise + RemoveItem for all active subscriptions
foreach (var kvp in _addressToHandle)
{
try
{
_proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value);
@@ -82,14 +79,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key);
}
}
// Unwire events before unregister
DetachProxyEvents();
// Unregister
try { _proxy.Unregister(_connectionHandle); }
catch (Exception ex) { Log.Warning(ex, "Error during Unregister"); }
try
{
_proxy.Unregister(_connectionHandle);
}
catch (Exception ex)
{
Log.Warning(ex, "Error during Unregister");
}
});
_handleToAddress.Clear();
@@ -108,7 +110,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
/// </summary>
public async Task ReconnectAsync()
{

View File

@@ -1,6 +1,5 @@
using System;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
@@ -8,8 +7,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
public sealed partial class MxAccessClient
{
/// <summary>
/// COM event handler for MxAccess OnDataChange events.
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
/// COM event handler for MxAccess OnDataChange events.
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
/// </summary>
private void HandleOnDataChange(
int hLMXServerHandle,
@@ -31,30 +30,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
// Check MXSTATUS_PROXY — if success is false, use more specific quality
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
{
quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail);
}
var timestamp = ConvertTimestamp(pftItemTimeStamp);
var vtq = new Vtq(pvItemValue, timestamp, quality);
// Update probe timestamp
if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase))
{
_lastProbeValueTime = DateTime.UtcNow;
}
// Invoke stored subscription callback
if (_storedSubscriptions.TryGetValue(address, out var callback))
{
callback(address, vtq);
}
if (_storedSubscriptions.TryGetValue(address, out var callback)) callback(address, vtq);
if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads))
{
foreach (var pendingRead in pendingReads.Values)
pendingRead.TrySetResult(vtq);
}
// Global handler
OnTagValueChanged?.Invoke(address, vtq);
@@ -66,7 +56,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// COM event handler for MxAccess OnWriteComplete events.
/// COM event handler for MxAccess OnWriteComplete events.
/// </summary>
private void HandleOnWriteComplete(
int hLMXServerHandle,
@@ -77,7 +67,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
{
bool success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0;
var success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0;
if (success)
{
tcs.TrySetResult(true);

View File

@@ -1,7 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
@@ -9,7 +8,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
public sealed partial class MxAccessClient
{
/// <summary>
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
/// </summary>
public void StartMonitor()
{
@@ -19,7 +18,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Stops the background monitor loop.
/// Stops the background monitor loop.
/// </summary>
public void StopMonitor()
{
@@ -41,7 +40,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
try
{
if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) && _config.AutoReconnect)
if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) &&
_config.AutoReconnect)
{
Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state);
await ReconnectAsync();

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
public sealed partial class MxAccessClient
{
/// <summary>
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to read.</param>
/// <param name="ct">A token that cancels the read.</param>
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
});
var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
_ => new System.Collections.Concurrent.ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
_ => new ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
pendingReads[itemHandle] = tcs;
_handleToAddress[itemHandle] = fullTagReference;
@@ -86,12 +86,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true"/> when the runtime acknowledges success; otherwise, <see langword="false"/>.</returns>
/// <returns><see langword="true" /> when the runtime acknowledges success; otherwise, <see langword="false" />.</returns>
public async Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
if (_state != ConnectionState.Connected) return false;
@@ -121,7 +121,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds));
cts.Token.Register(() =>
{
Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference, _config.WriteTimeoutSeconds);
Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference,
_config.WriteTimeoutSeconds);
tcs.TrySetResult(false);
});

View File

@@ -1,6 +1,5 @@
using System;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
@@ -8,7 +7,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
public sealed partial class MxAccessClient
{
/// <summary>
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to monitor.</param>
/// <param name="callback">The callback that should receive runtime value changes.</param>
@@ -22,7 +21,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to stop monitoring.</param>
public async Task UnsubscribeAsync(string fullTagReference)
@@ -38,7 +37,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
_handleToAddress.TryRemove(itemHandle, out _);
if (_state == ConnectionState.Connected)
{
await _staThread.RunAsync(() =>
{
try
@@ -51,7 +49,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
Log.Warning(ex, "Error unsubscribing {Address}", fullTagReference);
}
});
}
}
}
@@ -95,7 +92,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
private async Task ReplayStoredSubscriptionsAsync()
{
foreach (var kvp in _storedSubscriptions)
{
try
{
await SubscribeInternalAsync(kvp.Key);
@@ -104,7 +100,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key);
}
}
Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count);
}

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
@@ -11,77 +10,55 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
/// <summary>
/// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction.
/// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor.
/// (MXA-001 through MXA-009)
/// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction.
/// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor.
/// (MXA-001 through MXA-009)
/// </summary>
public sealed partial class MxAccessClient : IMxAccessClient
{
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
private readonly StaComThread _staThread;
private readonly IMxProxy _proxy;
private readonly ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
private readonly MxAccessConfiguration _config;
// Handle mappings
private readonly ConcurrentDictionary<int, string> _handleToAddress = new();
private readonly PerformanceMetrics _metrics;
private readonly SemaphoreSlim _operationSemaphore;
private int _connectionHandle;
private volatile ConnectionState _state = ConnectionState.Disconnected;
private bool _proxyEventsAttached;
private CancellationTokenSource? _monitorCts;
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>
_pendingReadsByAddress
= new(StringComparer.OrdinalIgnoreCase);
// Handle mappings
private readonly ConcurrentDictionary<int, string> _handleToAddress = new ConcurrentDictionary<int, string>();
private readonly ConcurrentDictionary<string, int> _addressToHandle = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
// Pending writes
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites = new();
private readonly IMxProxy _proxy;
private readonly StaComThread _staThread;
// Subscription storage
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _storedSubscriptions
= new ConcurrentDictionary<string, Action<string, Vtq>>(StringComparer.OrdinalIgnoreCase);
= new(StringComparer.OrdinalIgnoreCase);
// Pending writes
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites
= new ConcurrentDictionary<int, TaskCompletionSource<bool>>();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>> _pendingReadsByAddress
= new ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>(StringComparer.OrdinalIgnoreCase);
private int _connectionHandle;
private DateTime _lastProbeValueTime = DateTime.UtcNow;
private CancellationTokenSource? _monitorCts;
// Probe
private string? _probeTag;
private DateTime _lastProbeValueTime = DateTime.UtcNow;
private bool _proxyEventsAttached;
private int _reconnectCount;
private volatile ConnectionState _state = ConnectionState.Disconnected;
/// <summary>
/// Gets the current runtime connection state for the MXAccess client.
/// </summary>
public ConnectionState State => _state;
/// <summary>
/// Gets the number of active tag subscriptions currently maintained against the runtime.
/// </summary>
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
/// <summary>
/// Gets the number of reconnect attempts performed since the client was created.
/// </summary>
public int ReconnectCount => _reconnectCount;
/// <summary>
/// Occurs when the MXAccess connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed runtime tag publishes a new value.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
/// </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)
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config,
PerformanceMetrics metrics)
{
_staThread = staThread;
_proxy = proxy;
@@ -90,17 +67,33 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
_operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations);
}
private void SetState(ConnectionState newState, string message = "")
{
var previous = _state;
if (previous == newState) return;
_state = newState;
Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message);
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
}
/// <summary>
/// Gets the current runtime connection state for the MXAccess client.
/// </summary>
public ConnectionState State => _state;
/// <summary>
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
/// Gets the number of active tag subscriptions currently maintained against the runtime.
/// </summary>
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
/// <summary>
/// Gets the number of reconnect attempts performed since the client was created.
/// </summary>
public int ReconnectCount => _reconnectCount;
/// <summary>
/// Occurs when the MXAccess connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed runtime tag publishes a new value.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
/// </summary>
public void Dispose()
{
@@ -119,5 +112,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
_monitorCts?.Dispose();
}
}
private void SetState(ConnectionState newState, string message = "")
{
var previous = _state;
if (previous == newState) return;
_state = newState;
Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message);
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
}
}
}

View File

@@ -6,25 +6,25 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
/// <summary>
/// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy.
/// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001)
/// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy.
/// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001)
/// </summary>
public sealed class MxProxyAdapter : IMxProxy
{
private LMXProxyServer? _lmxProxy;
/// <summary>
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
/// </summary>
public event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the COM proxy confirms completion of a write request.
/// Occurs when the COM proxy confirms completion of a write request.
/// </summary>
public event MxWriteCompleteHandler? OnWriteComplete;
/// <summary>
/// Creates and registers the COM proxy session that backs live MXAccess operations.
/// Creates and registers the COM proxy session that backs live MXAccess operations.
/// </summary>
/// <param name="clientName">The client name reported to the Wonderware runtime.</param>
/// <returns>The runtime connection handle assigned by the COM server.</returns>
@@ -43,13 +43,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Unregisters the COM proxy session and releases the underlying COM object.
/// Unregisters the COM proxy session and releases the underlying COM object.
/// </summary>
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)"/>.</param>
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)" />.</param>
public void Unregister(int handle)
{
if (_lmxProxy != null)
{
try
{
_lmxProxy.OnDataChange -= ProxyOnDataChange;
@@ -61,52 +60,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
Marshal.ReleaseComObject(_lmxProxy);
_lmxProxy = null;
}
}
}
/// <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.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
/// <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>
/// Removes an item handle from the active COM proxy session.
/// Removes an item handle from the active COM proxy session.
/// </summary>
/// <param name="handle">The runtime connection handle.</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>
/// Enables supervisory callbacks for the specified runtime item.
/// Enables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</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>
/// Disables supervisory callbacks for the specified runtime item.
/// Disables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</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>
/// Writes a value to the specified runtime item through the COM proxy.
/// Writes a value to the specified runtime item through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
public void Write(int handle, int itemHandle, object value, int securityClassification)
=> _lmxProxy!.Write(handle, itemHandle, value, securityClassification);
{
_lmxProxy!.Write(handle, itemHandle, value, securityClassification);
}
private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
{
OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp, ref ItemStatus);
OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp,
ref ItemStatus);
}
private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)

View File

@@ -8,8 +8,8 @@ using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
/// <summary>
/// Dedicated STA thread with a raw Win32 message pump for COM interop.
/// All MxAccess COM objects must be created and called on this thread. (MXA-001)
/// Dedicated STA thread with a raw Win32 message pump for COM interop.
/// All MxAccess COM objects must be created and called on this thread. (MXA-001)
/// </summary>
public sealed class StaComThread : IDisposable
{
@@ -18,21 +18,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
private readonly TaskCompletionSource<bool> _ready = new();
private readonly Thread _thread;
private readonly TaskCompletionSource<bool> _ready = new TaskCompletionSource<bool>();
private readonly ConcurrentQueue<Action> _workItems = new ConcurrentQueue<Action>();
private volatile uint _nativeThreadId;
private bool _disposed;
private long _totalMessages;
private readonly ConcurrentQueue<Action> _workItems = new();
private long _appMessages;
private long _dispatchedMessages;
private long _workItemsExecuted;
private bool _disposed;
private DateTime _lastLogTime;
private volatile uint _nativeThreadId;
private long _totalMessages;
private long _workItemsExecuted;
/// <summary>
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
/// </summary>
public StaComThread()
{
@@ -45,12 +45,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Gets a value indicating whether the STA thread is running and able to accept work.
/// Gets a value indicating whether the STA thread is running and able to accept work.
/// </summary>
public bool IsRunning => _nativeThreadId != 0 && !_disposed;
/// <summary>
/// Starts the STA thread and waits until its message pump is ready for COM work.
/// Stops the STA thread and releases the message-pump resources used for COM interop.
/// </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>
/// Starts the STA thread and waits until its message pump is ready for COM work.
/// </summary>
public void Start()
{
@@ -60,7 +82,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Queues an action to execute on the STA thread.
/// Queues an action to execute on the STA thread.
/// </summary>
/// <param name="action">The work item to execute on the STA thread.</param>
/// <returns>A task that completes when the action has finished executing.</returns>
@@ -86,7 +108,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
}
/// <summary>
/// Queues a function to execute on the STA thread and returns its result.
/// Queues a function to execute on the STA thread and returns its result.
/// </summary>
/// <typeparam name="T">The result type produced by the function.</typeparam>
/// <param name="func">The work item to execute on the STA thread.</param>
@@ -111,28 +133,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
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()
{
try
@@ -171,7 +171,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
LogPumpStatsIfDue();
}
Log.Information("STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
Log.Information(
"STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
}
catch (Exception ex)
@@ -186,8 +187,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
while (_workItems.TryDequeue(out var workItem))
{
_workItemsExecuted++;
try { workItem(); }
catch (Exception ex) { Log.Error(ex, "Unhandled exception in STA work item"); }
try
{
workItem();
}
catch (Exception ex)
{
Log.Error(ex, "Unhandled exception in STA work item");
}
}
}
@@ -195,7 +202,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
{
var now = DateTime.UtcNow;
if (now - _lastLogTime < PumpLogInterval) return;
Log.Debug("STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
Log.Debug(
"STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
_lastLogTime = now;
}
@@ -239,7 +247,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax,
uint wRemoveMsg);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();

View File

@@ -7,134 +7,16 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
/// <summary>
/// Builds the tag reference mappings from Galaxy hierarchy and attributes.
/// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004)
/// Builds the tag reference mappings from Galaxy hierarchy and attributes.
/// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004)
/// </summary>
public class AddressSpaceBuilder
{
private static readonly ILogger Log = Serilog.Log.ForContext<AddressSpaceBuilder>();
/// <summary>
/// Node info for the address space tree.
/// </summary>
public class NodeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier represented by this address-space node.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// Gets or sets the attribute nodes published beneath this object.
/// </summary>
public List<AttributeNodeInfo> Attributes { get; set; } = new();
/// <summary>
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
/// </summary>
public List<NodeInfo> Children { get; set; } = new();
}
/// <summary>
/// Lightweight description of an attribute node that will become an OPC UA variable.
/// </summary>
public class AttributeNodeInfo
{
/// <summary>
/// Gets or sets the Galaxy attribute name published under the object.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is modeled as an array.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length when the attribute is a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive name that groups the attribute under a sub-object node.
/// Empty for root-level attributes.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
/// </summary>
public int SecurityClassification { get; set; } = 1;
/// <summary>
/// Gets or sets a value indicating whether the attribute is historized.
/// </summary>
public bool IsHistorized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is an alarm.
/// </summary>
public bool IsAlarm { get; set; }
}
/// <summary>
/// Result of building the address space model.
/// </summary>
public class AddressSpaceModel
{
/// <summary>
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
/// </summary>
public List<NodeInfo> RootNodes { get; set; } = new();
/// <summary>
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
/// </summary>
public Dictionary<string, string> NodeIdToTagReference { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets the number of non-area Galaxy objects included in the model.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of variable nodes created from Galaxy attributes.
/// </summary>
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.
/// 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>
@@ -187,7 +69,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
model.ObjectCount++;
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
{
foreach (var attr in attrs)
{
node.Attributes.Add(new AttributeNodeInfo
@@ -206,7 +87,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
model.VariableCount++;
}
}
return node;
}
@@ -220,5 +100,125 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
: attr.FullTagReference;
}
/// <summary>
/// Node info for the address space tree.
/// </summary>
public class NodeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier represented by this address-space node.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// Gets or sets the attribute nodes published beneath this object.
/// </summary>
public List<AttributeNodeInfo> Attributes { get; set; } = new();
/// <summary>
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
/// </summary>
public List<NodeInfo> Children { get; set; } = new();
}
/// <summary>
/// Lightweight description of an attribute node that will become an OPC UA variable.
/// </summary>
public class AttributeNodeInfo
{
/// <summary>
/// Gets or sets the Galaxy attribute name published under the object.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is modeled as an array.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length when the attribute is a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive name that groups the attribute under a sub-object node.
/// Empty for root-level attributes.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
/// </summary>
public int SecurityClassification { get; set; } = 1;
/// <summary>
/// Gets or sets a value indicating whether the attribute is historized.
/// </summary>
public bool IsHistorized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is an alarm.
/// </summary>
public bool IsAlarm { get; set; }
}
/// <summary>
/// Result of building the address space model.
/// </summary>
public class AddressSpaceModel
{
/// <summary>
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
/// </summary>
public List<NodeInfo> RootNodes { get; set; } = new();
/// <summary>
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
/// </summary>
public Dictionary<string, string> NodeIdToTagReference { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets the number of non-area Galaxy objects included in the model.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of variable nodes created from Galaxy attributes.
/// </summary>
public int VariableCount { get; set; }
}
}
}

View File

@@ -5,12 +5,12 @@ using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
/// <summary>
/// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
/// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
/// </summary>
public static class AddressSpaceDiff
{
/// <summary>
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
/// </summary>
/// <param name="oldHierarchy">The previously published Galaxy object hierarchy snapshot.</param>
/// <param name="oldAttributes">The previously published Galaxy attribute snapshot keyed to the old hierarchy.</param>
@@ -27,24 +27,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
// Added objects
foreach (var id in newObjects.Keys)
{
if (!oldObjects.ContainsKey(id))
changed.Add(id);
}
// Removed objects
foreach (var id in oldObjects.Keys)
{
if (!newObjects.ContainsKey(id))
changed.Add(id);
}
// Modified objects
foreach (var kvp in newObjects)
{
if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
changed.Add(kvp.Key);
}
// Attribute changes — group by gobject_id and compare
var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
@@ -72,7 +66,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
/// <summary>
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
/// </summary>
/// <param name="changed">The root Galaxy objects that were detected as changed between snapshots.</param>
/// <param name="hierarchy">The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.</param>
@@ -88,13 +82,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
var id = queue.Dequeue();
if (childrenByParent.TryGetValue(id, out var children))
{
foreach (var childId in children)
{
if (expanded.Add(childId))
queue.Enqueue(childId);
}
}
}
return expanded;
@@ -103,10 +93,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b)
{
return a.TagName == b.TagName
&& a.BrowseName == b.BrowseName
&& a.ContainedName == b.ContainedName
&& a.ParentGobjectId == b.ParentGobjectId
&& a.IsArea == b.IsArea;
&& a.BrowseName == b.BrowseName
&& a.ContainedName == b.ContainedName
&& a.ParentGobjectId == b.ParentGobjectId
&& a.IsArea == b.IsArea;
}
private static bool AttributeSetsEqual(List<GalaxyAttributeInfo>? a, List<GalaxyAttributeInfo>? b)
@@ -119,11 +109,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
for (int i = 0; i < sortedA.Count; i++)
{
for (var i = 0; i < sortedA.Count; i++)
if (!AttributesEqual(sortedA[i], sortedB[i]))
return false;
}
return true;
}
@@ -131,14 +119,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b)
{
return a.AttributeName == b.AttributeName
&& a.FullTagReference == b.FullTagReference
&& a.MxDataType == b.MxDataType
&& a.IsArray == b.IsArray
&& a.ArrayDimension == b.ArrayDimension
&& a.PrimitiveName == b.PrimitiveName
&& a.SecurityClassification == b.SecurityClassification
&& a.IsHistorized == b.IsHistorized
&& a.IsAlarm == b.IsAlarm;
&& a.FullTagReference == b.FullTagReference
&& a.MxDataType == b.MxDataType
&& a.IsArray == b.IsArray
&& a.ArrayDimension == b.ArrayDimension
&& a.PrimitiveName == b.PrimitiveName
&& a.SecurityClassification == b.SecurityClassification
&& a.IsHistorized == b.IsHistorized
&& a.IsAlarm == b.IsAlarm;
}
}
}

Some files were not shown because too many files have changed in this diff Show More