Implement LmxOpcUa server — all 6 phases complete
Full OPC UA server on .NET Framework 4.8 (x86) exposing AVEVA System Platform Galaxy tags via MXAccess. Mirrors Galaxy object hierarchy as OPC UA address space, translating contained-name browse paths to tag-name runtime references. Components implemented: - Configuration: AppConfiguration with 4 sections, validator - Domain: ConnectionState, Quality, Vtq, MxDataTypeMapper, error codes - MxAccess: StaComThread, MxAccessClient (partial classes), MxProxyAdapter using strongly-typed ArchestrA.MxAccess COM interop - Galaxy Repository: SQL queries (hierarchy, attributes, change detection), ChangeDetectionService with auto-rebuild on deploy - OPC UA Server: LmxNodeManager (CustomNodeManager2), LmxOpcUaServer, OpcUaServerHost with programmatic config, SecurityPolicy None - Status Dashboard: HTTP server with HTML/JSON/health endpoints - Integration: Full 14-step startup, graceful shutdown, component wiring 175 tests (174 unit + 1 integration), all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
|
||||
/// </summary>
|
||||
public class AppConfiguration
|
||||
{
|
||||
public OpcUaConfiguration OpcUa { get; set; } = new OpcUaConfiguration();
|
||||
public MxAccessConfiguration MxAccess { get; set; } = new MxAccessConfiguration();
|
||||
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new GalaxyRepositoryConfiguration();
|
||||
public DashboardConfiguration Dashboard { get; set; } = new DashboardConfiguration();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// 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));
|
||||
|
||||
public static bool ValidateAndLog(AppConfiguration config)
|
||||
{
|
||||
bool valid = true;
|
||||
|
||||
Log.Information("=== Effective Configuration ===");
|
||||
|
||||
// OPC UA
|
||||
Log.Information("OpcUa.Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
|
||||
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);
|
||||
|
||||
if (config.OpcUa.Port < 1 || config.OpcUa.Port > 65535)
|
||||
{
|
||||
Log.Error("OpcUa.Port must be between 1 and 65535");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.OpcUa.GalaxyName))
|
||||
{
|
||||
Log.Error("OpcUa.GalaxyName must not be empty");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// MxAccess
|
||||
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",
|
||||
config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect,
|
||||
config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName))
|
||||
{
|
||||
Log.Error("MxAccess.ClientName must not be empty");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// Galaxy Repository
|
||||
Log.Information("GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s",
|
||||
config.GalaxyRepository.ConnectionString, config.GalaxyRepository.ChangeDetectionIntervalSeconds,
|
||||
config.GalaxyRepository.CommandTimeoutSeconds);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString))
|
||||
{
|
||||
Log.Error("GalaxyRepository.ConnectionString must not be empty");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s",
|
||||
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
|
||||
|
||||
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Status dashboard configuration. (SVC-003, DASH-001)
|
||||
/// </summary>
|
||||
public class DashboardConfiguration
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int Port { get; set; } = 8081;
|
||||
public int RefreshIntervalSeconds { get; set; } = 10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Galaxy repository database configuration. (SVC-003, GR-005)
|
||||
/// </summary>
|
||||
public class GalaxyRepositoryConfiguration
|
||||
{
|
||||
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
|
||||
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
|
||||
/// </summary>
|
||||
public class MxAccessConfiguration
|
||||
{
|
||||
public string ClientName { get; set; } = "LmxOpcUa";
|
||||
public string? NodeName { get; set; }
|
||||
public string? GalaxyName { get; set; }
|
||||
public int ReadTimeoutSeconds { get; set; } = 5;
|
||||
public int WriteTimeoutSeconds { get; set; } = 5;
|
||||
public int MaxConcurrentOperations { get; set; } = 10;
|
||||
public int MonitorIntervalSeconds { get; set; } = 5;
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
public string? ProbeTag { get; set; }
|
||||
public int ProbeStaleThresholdSeconds { get; set; } = 60;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
|
||||
/// </summary>
|
||||
public class OpcUaConfiguration
|
||||
{
|
||||
public int Port { get; set; } = 4840;
|
||||
public string EndpointPath { get; set; } = "/LmxOpcUa";
|
||||
public string ServerName { get; set; } = "LmxOpcUa";
|
||||
public string GalaxyName { get; set; } = "ZB";
|
||||
public int MaxSessions { get; set; } = 100;
|
||||
public int SessionTimeoutMinutes { get; set; } = 30;
|
||||
}
|
||||
}
|
||||
15
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionState.cs
Normal file
15
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConnectionState.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// MXAccess connection lifecycle states. (MXA-002)
|
||||
/// </summary>
|
||||
public enum ConnectionState
|
||||
{
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnecting,
|
||||
Error,
|
||||
Reconnecting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Event args for connection state transitions. (MXA-002)
|
||||
/// </summary>
|
||||
public class ConnectionStateChangedEventArgs : EventArgs
|
||||
{
|
||||
public ConnectionState PreviousState { get; }
|
||||
public ConnectionState CurrentState { get; }
|
||||
public string Message { get; }
|
||||
|
||||
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
|
||||
{
|
||||
PreviousState = previous;
|
||||
CurrentState = current;
|
||||
Message = message ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs
Normal file
17
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO matching attributes.sql result columns. (GR-002)
|
||||
/// </summary>
|
||||
public class GalaxyAttributeInfo
|
||||
{
|
||||
public int GobjectId { get; set; }
|
||||
public string TagName { get; set; } = "";
|
||||
public string AttributeName { get; set; } = "";
|
||||
public string FullTagReference { get; set; } = "";
|
||||
public int MxDataType { get; set; }
|
||||
public string DataTypeName { get; set; } = "";
|
||||
public bool IsArray { get; set; }
|
||||
public int? ArrayDimension { get; set; }
|
||||
}
|
||||
}
|
||||
15
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs
Normal file
15
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyObjectInfo.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO matching hierarchy.sql result columns. (GR-001)
|
||||
/// </summary>
|
||||
public class GalaxyObjectInfo
|
||||
{
|
||||
public int GobjectId { get; set; }
|
||||
public string TagName { get; set; } = "";
|
||||
public string ContainedName { get; set; } = "";
|
||||
public string BrowseName { get; set; } = "";
|
||||
public int ParentGobjectId { get; set; }
|
||||
public bool IsArea { get; set; }
|
||||
}
|
||||
}
|
||||
20
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IGalaxyRepository.cs
Normal file
20
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IGalaxyRepository.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for Galaxy repository database queries. (GR-001 through GR-004)
|
||||
/// </summary>
|
||||
public interface IGalaxyRepository
|
||||
{
|
||||
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
|
||||
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
|
||||
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
||||
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
||||
|
||||
event Action? OnGalaxyChanged;
|
||||
}
|
||||
}
|
||||
29
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxAccessClient.cs
Normal file
29
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxAccessClient.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
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)
|
||||
/// </summary>
|
||||
public interface IMxAccessClient : IDisposable
|
||||
{
|
||||
ConnectionState State { get; }
|
||||
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
Task ConnectAsync(CancellationToken ct = default);
|
||||
Task DisconnectAsync();
|
||||
|
||||
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
|
||||
Task UnsubscribeAsync(string fullTagReference);
|
||||
|
||||
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
|
||||
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
|
||||
|
||||
int ActiveSubscriptionCount { get; }
|
||||
int ReconnectCount { get; }
|
||||
}
|
||||
}
|
||||
41
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxProxy.cs
Normal file
41
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxProxy.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
|
||||
/// </summary>
|
||||
public delegate void MxDataChangeHandler(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
object pvItemValue,
|
||||
int pwItemQuality,
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] ItemStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
|
||||
/// </summary>
|
||||
public delegate void MxWriteCompleteHandler(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] ItemStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001)
|
||||
/// </summary>
|
||||
public interface IMxProxy
|
||||
{
|
||||
int Register(string clientName);
|
||||
void Unregister(int handle);
|
||||
int AddItem(int handle, string address);
|
||||
void RemoveItem(int handle, int itemHandle);
|
||||
void AdviseSupervisory(int handle, int itemHandle);
|
||||
void UnAdviseSupervisory(int handle, int itemHandle);
|
||||
void Write(int handle, int itemHandle, object value, int securityClassification);
|
||||
|
||||
event MxDataChangeHandler? OnDataChange;
|
||||
event MxWriteCompleteHandler? OnWriteComplete;
|
||||
}
|
||||
}
|
||||
81
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxDataTypeMapper.cs
Normal file
81
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxDataTypeMapper.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
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.
|
||||
/// </summary>
|
||||
public static class MxDataTypeMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
|
||||
/// Unknown types default to String (i=12).
|
||||
/// </summary>
|
||||
public static uint MapToOpcUaDataType(int mxDataType)
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps mx_data_type to the corresponding CLR type.
|
||||
/// </summary>
|
||||
public static Type MapToClrType(int mxDataType)
|
||||
{
|
||||
return mxDataType switch
|
||||
{
|
||||
1 => typeof(bool),
|
||||
2 => typeof(int),
|
||||
3 => typeof(float),
|
||||
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
|
||||
14 => typeof(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.
|
||||
/// </summary>
|
||||
public static string GetOpcUaTypeName(int mxDataType)
|
||||
{
|
||||
return mxDataType switch
|
||||
{
|
||||
1 => "Boolean",
|
||||
2 => "Int32",
|
||||
3 => "Float",
|
||||
4 => "Double",
|
||||
5 => "String",
|
||||
6 => "DateTime",
|
||||
7 => "Double",
|
||||
8 => "String",
|
||||
13 => "Int32",
|
||||
14 => "String",
|
||||
15 => "LocalizedText",
|
||||
16 => "String",
|
||||
_ => "String"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxErrorCodes.cs
Normal file
43
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxErrorCodes.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
|
||||
/// </summary>
|
||||
public static class MxErrorCodes
|
||||
{
|
||||
public const int MX_E_InvalidReference = 1008;
|
||||
public const int MX_E_WrongDataType = 1012;
|
||||
public const int MX_E_NotWritable = 1013;
|
||||
public const int MX_E_RequestTimedOut = 1014;
|
||||
public const int MX_E_CommFailure = 1015;
|
||||
public const int MX_E_NotConnected = 1016;
|
||||
|
||||
public static string GetMessage(int errorCode)
|
||||
{
|
||||
return errorCode switch
|
||||
{
|
||||
1008 => "Invalid reference: the tag address does not exist or is malformed",
|
||||
1012 => "Wrong data type: the value type does not match the attribute's expected type",
|
||||
1013 => "Not writable: the attribute is read-only or locked",
|
||||
1014 => "Request timed out: the operation did not complete within the allowed time",
|
||||
1015 => "Communication failure: lost connection to the runtime",
|
||||
1016 => "Not connected: no active connection to the Galaxy runtime",
|
||||
_ => $"Unknown MXAccess error code: {errorCode}"
|
||||
};
|
||||
}
|
||||
|
||||
public static Quality MapToQuality(int errorCode)
|
||||
{
|
||||
return errorCode switch
|
||||
{
|
||||
1008 => Quality.BadConfigError,
|
||||
1012 => Quality.BadConfigError,
|
||||
1013 => Quality.BadOutOfService,
|
||||
1014 => Quality.BadCommFailure,
|
||||
1015 => Quality.BadCommFailure,
|
||||
1016 => Quality.BadNotConnected,
|
||||
_ => Quality.Bad
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Quality.cs
Normal file
36
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Quality.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005)
|
||||
/// </summary>
|
||||
public enum Quality : byte
|
||||
{
|
||||
// Bad family (0-63)
|
||||
Bad = 0,
|
||||
BadConfigError = 4,
|
||||
BadNotConnected = 8,
|
||||
BadDeviceFailure = 12,
|
||||
BadSensorFailure = 16,
|
||||
BadCommFailure = 20,
|
||||
BadOutOfService = 24,
|
||||
BadWaitingForInitialData = 32,
|
||||
|
||||
// Uncertain family (64-191)
|
||||
Uncertain = 64,
|
||||
UncertainLastUsable = 68,
|
||||
UncertainSensorNotAccurate = 80,
|
||||
UncertainEuExceeded = 84,
|
||||
UncertainSubNormal = 88,
|
||||
|
||||
// Good family (192+)
|
||||
Good = 192,
|
||||
GoodLocalOverride = 216
|
||||
}
|
||||
|
||||
public static class QualityExtensions
|
||||
{
|
||||
public static bool IsGood(this Quality q) => (byte)q >= 192;
|
||||
public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 192;
|
||||
public static bool IsBad(this Quality q) => (byte)q < 64;
|
||||
}
|
||||
}
|
||||
54
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/QualityMapper.cs
Normal file
54
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/QualityMapper.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static Quality MapFromMxAccessQuality(int mxQuality)
|
||||
{
|
||||
var b = (byte)(mxQuality & 0xFF);
|
||||
|
||||
// Try exact match first
|
||||
if (System.Enum.IsDefined(typeof(Quality), b))
|
||||
return (Quality)b;
|
||||
|
||||
// Fall back to category
|
||||
if (b >= 192) return Quality.Good;
|
||||
if (b >= 64) return Quality.Uncertain;
|
||||
return Quality.Bad;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps domain Quality to OPC UA StatusCode uint32.
|
||||
/// </summary>
|
||||
public static uint MapToOpcUaStatusCode(Quality quality)
|
||||
{
|
||||
return quality switch
|
||||
{
|
||||
Quality.Good => 0x00000000u, // Good
|
||||
Quality.GoodLocalOverride => 0x00D80000u, // Good_LocalOverride
|
||||
Quality.Uncertain => 0x40000000u, // Uncertain
|
||||
Quality.UncertainLastUsable => 0x40900000u,
|
||||
Quality.UncertainSensorNotAccurate => 0x40930000u,
|
||||
Quality.UncertainEuExceeded => 0x40940000u,
|
||||
Quality.UncertainSubNormal => 0x40950000u,
|
||||
Quality.Bad => 0x80000000u, // Bad
|
||||
Quality.BadConfigError => 0x80890000u,
|
||||
Quality.BadNotConnected => 0x808A0000u,
|
||||
Quality.BadDeviceFailure => 0x808B0000u,
|
||||
Quality.BadSensorFailure => 0x808C0000u,
|
||||
Quality.BadCommFailure => 0x80050000u,
|
||||
Quality.BadOutOfService => 0x808D0000u,
|
||||
Quality.BadWaitingForInitialData => 0x80320000u,
|
||||
_ => quality.IsGood() ? 0x00000000u :
|
||||
quality.IsUncertain() ? 0x40000000u :
|
||||
0x80000000u
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Vtq.cs
Normal file
32
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/Vtq.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
|
||||
/// </summary>
|
||||
public readonly struct Vtq : IEquatable<Vtq>
|
||||
{
|
||||
public object? Value { get; }
|
||||
public DateTime Timestamp { get; }
|
||||
public Quality Quality { get; }
|
||||
|
||||
public Vtq(object? value, DateTime timestamp, Quality quality)
|
||||
{
|
||||
Value = value;
|
||||
Timestamp = timestamp;
|
||||
Quality = quality;
|
||||
}
|
||||
|
||||
public static Vtq Good(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Good);
|
||||
public static Vtq Bad(Quality quality = Quality.Bad) => new Vtq(null, DateTime.UtcNow, quality);
|
||||
public static Vtq Uncertain(object? value) => new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
|
||||
|
||||
public bool Equals(Vtq other) =>
|
||||
Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
|
||||
|
||||
public override bool Equals(object? obj) => obj is Vtq other && Equals(other);
|
||||
public override int GetHashCode() => HashCode.Combine(Value, Timestamp, Quality);
|
||||
public override string ToString() => $"Vtq({Value}, {Timestamp:O}, {Quality})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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)
|
||||
/// </summary>
|
||||
public class ChangeDetectionService : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<ChangeDetectionService>();
|
||||
|
||||
private readonly IGalaxyRepository _repository;
|
||||
private readonly int _intervalSeconds;
|
||||
private CancellationTokenSource? _cts;
|
||||
private DateTime? _lastKnownDeployTime;
|
||||
|
||||
public event Action? OnGalaxyChanged;
|
||||
public DateTime? LastKnownDeployTime => _lastKnownDeployTime;
|
||||
|
||||
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds)
|
||||
{
|
||||
_repository = repository;
|
||||
_intervalSeconds = intervalSeconds;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
Task.Run(() => PollLoopAsync(_cts.Token));
|
||||
Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
Log.Information("Change detection stopped");
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(CancellationToken ct)
|
||||
{
|
||||
// First poll always triggers
|
||||
bool firstPoll = true;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deployTime = await _repository.GetLastDeployTimeAsync(ct);
|
||||
|
||||
if (firstPoll)
|
||||
{
|
||||
firstPoll = false;
|
||||
_lastKnownDeployTime = deployTime;
|
||||
Log.Information("Initial deploy time: {DeployTime}", deployTime);
|
||||
OnGalaxyChanged?.Invoke();
|
||||
}
|
||||
else if (deployTime != _lastKnownDeployTime)
|
||||
{
|
||||
Log.Information("Galaxy deployment change detected: {Previous} → {Current}",
|
||||
_lastKnownDeployTime, deployTime);
|
||||
_lastKnownDeployTime = deployTime;
|
||||
OnGalaxyChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Change detection poll failed, will retry next interval");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SqlClient;
|
||||
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>
|
||||
/// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007)
|
||||
/// </summary>
|
||||
public class GalaxyRepositoryService : IGalaxyRepository
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRepositoryService>();
|
||||
|
||||
private readonly GalaxyRepositoryConfiguration _config;
|
||||
|
||||
public event Action? OnGalaxyChanged;
|
||||
|
||||
#region SQL Queries (GR-006: const string, no dynamic SQL)
|
||||
|
||||
private const string HierarchySql = @"
|
||||
SELECT DISTINCT
|
||||
g.gobject_id,
|
||||
g.tag_name,
|
||||
g.contained_name,
|
||||
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
|
||||
THEN g.tag_name
|
||||
ELSE g.contained_name
|
||||
END AS browse_name,
|
||||
CASE WHEN g.contained_by_gobject_id = 0
|
||||
THEN g.area_gobject_id
|
||||
ELSE g.contained_by_gobject_id
|
||||
END AS parent_gobject_id,
|
||||
CASE WHEN td.category_id = 13
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS is_area
|
||||
FROM gobject g
|
||||
INNER JOIN template_definition td
|
||||
ON g.template_definition_id = td.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND g.is_template = 0
|
||||
AND g.deployed_package_id <> 0
|
||||
ORDER BY parent_gobject_id, g.tag_name";
|
||||
|
||||
private const string AttributesSql = @"
|
||||
;WITH template_chain AS (
|
||||
SELECT g.gobject_id, g.derived_from_gobject_id, 0 AS depth
|
||||
FROM gobject g
|
||||
WHERE g.is_template = 0
|
||||
UNION ALL
|
||||
SELECT tc.gobject_id, t.derived_from_gobject_id, tc.depth + 1
|
||||
FROM template_chain tc
|
||||
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
|
||||
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
|
||||
)
|
||||
SELECT DISTINCT
|
||||
g.gobject_id,
|
||||
g.tag_name,
|
||||
da.attribute_name,
|
||||
g.tag_name + '.' + da.attribute_name
|
||||
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
|
||||
AS full_tag_reference,
|
||||
da.mx_data_type,
|
||||
dt.description AS data_type_name,
|
||||
da.is_array,
|
||||
CASE WHEN da.is_array = 1
|
||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
||||
ELSE NULL
|
||||
END AS array_dimension,
|
||||
da.mx_attribute_category,
|
||||
da.security_classification
|
||||
FROM template_chain tc
|
||||
INNER JOIN dynamic_attribute da
|
||||
ON da.gobject_id = tc.derived_from_gobject_id
|
||||
INNER JOIN gobject g
|
||||
ON g.gobject_id = tc.gobject_id
|
||||
INNER JOIN template_definition td
|
||||
ON td.template_definition_id = g.template_definition_id
|
||||
LEFT JOIN data_type dt
|
||||
ON dt.mx_data_type = da.mx_data_type
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND g.is_template = 0
|
||||
AND g.deployed_package_id <> 0
|
||||
AND da.attribute_name NOT LIKE '[_]%'
|
||||
AND da.attribute_name NOT LIKE '%.Description'
|
||||
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||
ORDER BY g.tag_name, da.attribute_name";
|
||||
|
||||
private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy";
|
||||
|
||||
private const string TestConnectionSql = "SELECT 1";
|
||||
|
||||
#endregion
|
||||
|
||||
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<GalaxyAttributeInfo>();
|
||||
|
||||
using var conn = new SqlConnection(_config.ConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = reader.GetInt32(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))
|
||||
});
|
||||
}
|
||||
|
||||
Log.Information("GetAttributesAsync returned {Count} attributes", results.Count);
|
||||
return results;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// POCO for dashboard: Galaxy repository status info. (DASH-009)
|
||||
/// </summary>
|
||||
public class GalaxyRepositoryStats
|
||||
{
|
||||
public string GalaxyName { get; set; } = "";
|
||||
public bool DbConnected { get; set; }
|
||||
public DateTime? LastDeployTime { get; set; }
|
||||
public int ObjectCount { get; set; }
|
||||
public int AttributeCount { get; set; }
|
||||
public DateTime? LastRebuildTime { get; set; }
|
||||
}
|
||||
}
|
||||
181
src/ZB.MOM.WW.LmxOpcUa.Host/Metrics/PerformanceMetrics.cs
Normal file
181
src/ZB.MOM.WW.LmxOpcUa.Host/Metrics/PerformanceMetrics.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation"/>. (MXA-008)
|
||||
/// </summary>
|
||||
public interface ITimingScope : IDisposable
|
||||
{
|
||||
void SetSuccess(bool success);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics snapshot for a single operation type.
|
||||
/// </summary>
|
||||
public class MetricsStatistics
|
||||
{
|
||||
public long TotalCount { get; set; }
|
||||
public long SuccessCount { get; set; }
|
||||
public double SuccessRate { get; set; }
|
||||
public double AverageMilliseconds { get; set; }
|
||||
public double MinMilliseconds { get; set; }
|
||||
public double MaxMilliseconds { get; set; }
|
||||
public double Percentile95Milliseconds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 double _maxMilliseconds;
|
||||
|
||||
public void Record(TimeSpan duration, bool success)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_totalCount++;
|
||||
if (success) _successCount++;
|
||||
|
||||
var ms = duration.TotalMilliseconds;
|
||||
_durations.Add(ms);
|
||||
_totalMilliseconds += ms;
|
||||
|
||||
if (ms < _minMilliseconds) _minMilliseconds = ms;
|
||||
if (ms > _maxMilliseconds) _maxMilliseconds = ms;
|
||||
|
||||
if (_durations.Count > 1000) _durations.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
public MetricsStatistics GetStatistics()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_totalCount == 0)
|
||||
return new MetricsStatistics();
|
||||
|
||||
var sorted = _durations.OrderBy(d => d).ToList();
|
||||
var p95Index = Math.Max(0, (int)Math.Ceiling(sorted.Count * 0.95) - 1);
|
||||
|
||||
return new MetricsStatistics
|
||||
{
|
||||
TotalCount = _totalCount,
|
||||
SuccessCount = _successCount,
|
||||
SuccessRate = (double)_successCount / _totalCount,
|
||||
AverageMilliseconds = _totalMilliseconds / _totalCount,
|
||||
MinMilliseconds = _minMilliseconds,
|
||||
MaxMilliseconds = _maxMilliseconds,
|
||||
Percentile95Milliseconds = sorted[p95Index]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 Timer _reportingTimer;
|
||||
private bool _disposed;
|
||||
|
||||
public PerformanceMetrics()
|
||||
{
|
||||
_reportingTimer = new Timer(ReportMetrics, null,
|
||||
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
|
||||
{
|
||||
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
|
||||
metrics.Record(duration, success);
|
||||
}
|
||||
|
||||
public ITimingScope BeginOperation(string operationName)
|
||||
{
|
||||
return new TimingScope(this, operationName);
|
||||
}
|
||||
|
||||
public OperationMetrics? GetMetrics(string operationName)
|
||||
{
|
||||
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
|
||||
}
|
||||
|
||||
public Dictionary<string, MetricsStatistics> GetStatistics()
|
||||
{
|
||||
var result = new Dictionary<string, MetricsStatistics>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in _metrics)
|
||||
result[kvp.Key] = kvp.Value.GetStatistics();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReportMetrics(object? state)
|
||||
{
|
||||
foreach (var kvp in _metrics)
|
||||
{
|
||||
var stats = kvp.Value.GetStatistics();
|
||||
if (stats.TotalCount == 0) continue;
|
||||
|
||||
Logger.Information(
|
||||
"Metrics: {Operation} — Count={Count}, SuccessRate={SuccessRate:P1}, " +
|
||||
"AvgMs={AverageMs:F1}, MinMs={MinMs:F1}, MaxMs={MaxMs:F1}, P95Ms={P95Ms:F1}",
|
||||
kvp.Key, stats.TotalCount, stats.SuccessRate,
|
||||
stats.AverageMilliseconds, stats.MinMilliseconds,
|
||||
stats.MaxMilliseconds, stats.Percentile95Milliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_reportingTimer.Dispose();
|
||||
ReportMetrics(null);
|
||||
}
|
||||
|
||||
private class TimingScope : ITimingScope
|
||||
{
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly string _operationName;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private bool _success = true;
|
||||
private bool _disposed;
|
||||
|
||||
public TimingScope(PerformanceMetrics metrics, string operationName)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_operationName = operationName;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
public void SetSuccess(bool success) => _success = success;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_stopwatch.Stop();
|
||||
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
public async Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_state == ConnectionState.Connected) return;
|
||||
|
||||
SetState(ConnectionState.Connecting);
|
||||
try
|
||||
{
|
||||
_connectionHandle = await _staThread.RunAsync(() =>
|
||||
{
|
||||
_proxy.OnDataChange += HandleOnDataChange;
|
||||
_proxy.OnWriteComplete += HandleOnWriteComplete;
|
||||
return _proxy.Register(_config.ClientName);
|
||||
});
|
||||
|
||||
Log.Information("MxAccess registered with handle {Handle}", _connectionHandle);
|
||||
SetState(ConnectionState.Connected);
|
||||
|
||||
// Replay stored subscriptions
|
||||
await ReplayStoredSubscriptionsAsync();
|
||||
|
||||
// Start probe if configured
|
||||
if (!string.IsNullOrWhiteSpace(_config.ProbeTag))
|
||||
{
|
||||
_probeTag = _config.ProbeTag;
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
await SubscribeInternalAsync(_probeTag);
|
||||
Log.Information("Probe tag subscribed: {ProbeTag}", _probeTag);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "MxAccess connection failed");
|
||||
SetState(ConnectionState.Error, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_state == ConnectionState.Disconnected) return;
|
||||
|
||||
SetState(ConnectionState.Disconnecting);
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
// UnAdvise + RemoveItem for all active subscriptions
|
||||
foreach (var kvp in _addressToHandle)
|
||||
{
|
||||
try
|
||||
{
|
||||
_proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value);
|
||||
_proxy.RemoveItem(_connectionHandle, kvp.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Unwire events before unregister
|
||||
_proxy.OnDataChange -= HandleOnDataChange;
|
||||
_proxy.OnWriteComplete -= HandleOnWriteComplete;
|
||||
|
||||
// Unregister
|
||||
try { _proxy.Unregister(_connectionHandle); }
|
||||
catch (Exception ex) { Log.Warning(ex, "Error during Unregister"); }
|
||||
});
|
||||
|
||||
_handleToAddress.Clear();
|
||||
_addressToHandle.Clear();
|
||||
_pendingWrites.Clear();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during disconnect");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetState(ConnectionState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReconnectAsync()
|
||||
{
|
||||
SetState(ConnectionState.Reconnecting);
|
||||
Interlocked.Increment(ref _reconnectCount);
|
||||
Log.Information("MxAccess reconnect attempt #{Count}", _reconnectCount);
|
||||
|
||||
try
|
||||
{
|
||||
await DisconnectAsync();
|
||||
await ConnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Reconnect failed");
|
||||
SetState(ConnectionState.Error, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
private void HandleOnDataChange(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
object pvItemValue,
|
||||
int pwItemQuality,
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_handleToAddress.TryGetValue(phItemHandle, out var address))
|
||||
{
|
||||
Log.Debug("OnDataChange for unknown handle {Handle}", phItemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
var quality = QualityMapper.MapFromMxAccessQuality(pwItemQuality);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Global handler
|
||||
OnTagValueChanged?.Invoke(address, vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error processing OnDataChange for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// COM event handler for MxAccess OnWriteComplete events.
|
||||
/// </summary>
|
||||
private void HandleOnWriteComplete(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
|
||||
{
|
||||
bool success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0;
|
||||
if (success)
|
||||
{
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var detail = ItemStatus![0].detail;
|
||||
var message = MxErrorCodes.GetMessage(detail);
|
||||
Log.Warning("Write failed for handle {Handle}: {Message}", phItemHandle, message);
|
||||
tcs.TrySetResult(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error processing OnWriteComplete for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime ConvertTimestamp(object pftItemTimeStamp)
|
||||
{
|
||||
if (pftItemTimeStamp is DateTime dt)
|
||||
return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
public void StartMonitor()
|
||||
{
|
||||
_monitorCts = new CancellationTokenSource();
|
||||
Task.Run(() => MonitorLoopAsync(_monitorCts.Token));
|
||||
Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds);
|
||||
}
|
||||
|
||||
public void StopMonitor()
|
||||
{
|
||||
_monitorCts?.Cancel();
|
||||
}
|
||||
|
||||
private async Task MonitorLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_config.MonitorIntervalSeconds), ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_state == ConnectionState.Disconnected && _config.AutoReconnect)
|
||||
{
|
||||
Log.Information("Monitor: connection lost, attempting reconnect");
|
||||
await ReconnectAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_state == ConnectionState.Connected && _probeTag != null)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - _lastProbeValueTime;
|
||||
if (elapsed.TotalSeconds > _config.ProbeStaleThresholdSeconds)
|
||||
{
|
||||
Log.Warning("Monitor: probe stale ({Elapsed:F0}s > {Threshold}s), forcing reconnect",
|
||||
elapsed.TotalSeconds, _config.ProbeStaleThresholdSeconds);
|
||||
await ReconnectAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Monitor loop error");
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("MxAccess monitor stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs
Normal file
139
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
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
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
public async Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
|
||||
{
|
||||
if (_state != ConnectionState.Connected)
|
||||
return Vtq.Bad(Quality.BadNotConnected);
|
||||
|
||||
await _operationSemaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
using var scope = _metrics.BeginOperation("Read");
|
||||
var tcs = new TaskCompletionSource<Vtq>();
|
||||
|
||||
// Subscribe, get first value, unsubscribe
|
||||
void OnValue(string addr, Vtq vtq) => tcs.TrySetResult(vtq);
|
||||
|
||||
var itemHandle = await _staThread.RunAsync(() =>
|
||||
{
|
||||
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
|
||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
||||
return h;
|
||||
});
|
||||
|
||||
_handleToAddress[itemHandle] = fullTagReference;
|
||||
_addressToHandle[fullTagReference] = itemHandle;
|
||||
_storedSubscriptions[fullTagReference] = OnValue;
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds));
|
||||
cts.Token.Register(() => tcs.TrySetResult(Vtq.Bad(Quality.BadCommFailure)));
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
return Vtq.Bad(Quality.BadCommFailure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_storedSubscriptions.TryRemove(fullTagReference, out _);
|
||||
_handleToAddress.TryRemove(itemHandle, out _);
|
||||
_addressToHandle.TryRemove(fullTagReference, out _);
|
||||
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error cleaning up read subscription for {Address}", fullTagReference);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
|
||||
{
|
||||
if (_state != ConnectionState.Connected) return false;
|
||||
|
||||
await _operationSemaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
using var scope = _metrics.BeginOperation("Write");
|
||||
|
||||
var itemHandle = await _staThread.RunAsync(() =>
|
||||
{
|
||||
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
|
||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
||||
return h;
|
||||
});
|
||||
|
||||
_handleToAddress[itemHandle] = fullTagReference;
|
||||
_addressToHandle[fullTagReference] = itemHandle;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_pendingWrites[itemHandle] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() => _proxy.Write(_connectionHandle, itemHandle, value, -1));
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds));
|
||||
cts.Token.Register(() => tcs.TrySetResult(true)); // timeout assumes success
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
Log.Error(ex, "Write failed for {Address}", fullTagReference);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingWrites.TryRemove(itemHandle, out _);
|
||||
_handleToAddress.TryRemove(itemHandle, out _);
|
||||
_addressToHandle.TryRemove(fullTagReference, out _);
|
||||
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error cleaning up write subscription for {Address}", fullTagReference);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationSemaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
public async Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
||||
{
|
||||
_storedSubscriptions[fullTagReference] = callback;
|
||||
if (_state != ConnectionState.Connected) return;
|
||||
|
||||
await SubscribeInternalAsync(fullTagReference);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(string fullTagReference)
|
||||
{
|
||||
_storedSubscriptions.TryRemove(fullTagReference, out _);
|
||||
|
||||
// Don't unsubscribe the probe tag
|
||||
if (string.Equals(fullTagReference, _probeTag, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
if (_addressToHandle.TryRemove(fullTagReference, out var itemHandle))
|
||||
{
|
||||
_handleToAddress.TryRemove(itemHandle, out _);
|
||||
|
||||
if (_state == ConnectionState.Connected)
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error unsubscribing {Address}", fullTagReference);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubscribeInternalAsync(string address)
|
||||
{
|
||||
using var scope = _metrics.BeginOperation("Subscribe");
|
||||
try
|
||||
{
|
||||
var itemHandle = await _staThread.RunAsync(() =>
|
||||
{
|
||||
var h = _proxy.AddItem(_connectionHandle, address);
|
||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
||||
return h;
|
||||
});
|
||||
|
||||
_handleToAddress[itemHandle] = address;
|
||||
_addressToHandle[address] = itemHandle;
|
||||
Log.Debug("Subscribed to {Address} (handle={Handle})", address, itemHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
Log.Error(ex, "Failed to subscribe to {Address}", address);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReplayStoredSubscriptionsAsync()
|
||||
{
|
||||
foreach (var kvp in _storedSubscriptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SubscribeInternalAsync(kvp.Key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.cs
Normal file
92
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
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)
|
||||
/// </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 MxAccessConfiguration _config;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly SemaphoreSlim _operationSemaphore;
|
||||
|
||||
private int _connectionHandle;
|
||||
private volatile ConnectionState _state = ConnectionState.Disconnected;
|
||||
private CancellationTokenSource? _monitorCts;
|
||||
|
||||
// Handle mappings
|
||||
private readonly ConcurrentDictionary<int, string> _handleToAddress = new ConcurrentDictionary<int, string>();
|
||||
private readonly ConcurrentDictionary<string, int> _addressToHandle = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Subscription storage
|
||||
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _storedSubscriptions
|
||||
= new ConcurrentDictionary<string, Action<string, Vtq>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Pending writes
|
||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites
|
||||
= new ConcurrentDictionary<int, TaskCompletionSource<bool>>();
|
||||
|
||||
// Probe
|
||||
private string? _probeTag;
|
||||
private DateTime _lastProbeValueTime = DateTime.UtcNow;
|
||||
private int _reconnectCount;
|
||||
|
||||
public ConnectionState State => _state;
|
||||
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
|
||||
public int ReconnectCount => _reconnectCount;
|
||||
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config, PerformanceMetrics metrics)
|
||||
{
|
||||
_staThread = staThread;
|
||||
_proxy = proxy;
|
||||
_config = config;
|
||||
_metrics = metrics;
|
||||
_operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations);
|
||||
}
|
||||
|
||||
private void SetState(ConnectionState newState, string message = "")
|
||||
{
|
||||
var previous = _state;
|
||||
if (previous == newState) return;
|
||||
_state = newState;
|
||||
Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message);
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_monitorCts?.Cancel();
|
||||
DisconnectAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during MxAccessClient dispose");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationSemaphore.Dispose();
|
||||
_monitorCts?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxProxyAdapter.cs
Normal file
69
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxProxyAdapter.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ArchestrA.MxAccess;
|
||||
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)
|
||||
/// </summary>
|
||||
public sealed class MxProxyAdapter : IMxProxy
|
||||
{
|
||||
private LMXProxyServer? _lmxProxy;
|
||||
|
||||
public event MxDataChangeHandler? OnDataChange;
|
||||
public event MxWriteCompleteHandler? OnWriteComplete;
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
_lmxProxy = new LMXProxyServer();
|
||||
|
||||
_lmxProxy.OnDataChange += ProxyOnDataChange;
|
||||
_lmxProxy.OnWriteComplete += ProxyOnWriteComplete;
|
||||
|
||||
var handle = _lmxProxy.Register(clientName);
|
||||
if (handle <= 0)
|
||||
throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}");
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public void Unregister(int handle)
|
||||
{
|
||||
if (_lmxProxy != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.OnDataChange -= ProxyOnDataChange;
|
||||
_lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
|
||||
_lmxProxy.Unregister(handle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(_lmxProxy);
|
||||
_lmxProxy = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address);
|
||||
public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle);
|
||||
public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle);
|
||||
public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle);
|
||||
public void Write(int handle, int itemHandle, object value, int 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);
|
||||
}
|
||||
|
||||
private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
226
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/StaComThread.cs
Normal file
226
src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/StaComThread.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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)
|
||||
/// </summary>
|
||||
public sealed class StaComThread : IDisposable
|
||||
{
|
||||
private const uint WM_APP = 0x8000;
|
||||
private const uint PM_NOREMOVE = 0x0000;
|
||||
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
|
||||
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
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 long _appMessages;
|
||||
private long _dispatchedMessages;
|
||||
private long _workItemsExecuted;
|
||||
private DateTime _lastLogTime;
|
||||
|
||||
public StaComThread()
|
||||
{
|
||||
_thread = new Thread(ThreadEntry)
|
||||
{
|
||||
Name = "MxAccess-STA",
|
||||
IsBackground = true
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
|
||||
public bool IsRunning => _nativeThreadId != 0 && !_disposed;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_thread.Start();
|
||||
_ready.Task.GetAwaiter().GetResult();
|
||||
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
|
||||
}
|
||||
|
||||
public Task RunAsync(Action action)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_workItems.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public Task<T> RunAsync<T>(Func<T> func)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
|
||||
var tcs = new TaskCompletionSource<T>();
|
||||
_workItems.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
tcs.TrySetResult(func());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
_nativeThreadId = GetCurrentThreadId();
|
||||
|
||||
MSG msg;
|
||||
PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE);
|
||||
|
||||
_ready.TrySetResult(true);
|
||||
_lastLogTime = DateTime.UtcNow;
|
||||
|
||||
Log.Debug("STA message pump entering loop");
|
||||
|
||||
while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
_totalMessages++;
|
||||
|
||||
if (msg.message == WM_APP)
|
||||
{
|
||||
_appMessages++;
|
||||
DrainQueue();
|
||||
}
|
||||
else if (msg.message == WM_APP + 1)
|
||||
{
|
||||
DrainQueue();
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
_dispatchedMessages++;
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
|
||||
LogPumpStatsIfDue();
|
||||
}
|
||||
|
||||
Log.Information("STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
|
||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "STA COM thread crashed");
|
||||
_ready.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainQueue()
|
||||
{
|
||||
while (_workItems.TryDequeue(out var workItem))
|
||||
{
|
||||
_workItemsExecuted++;
|
||||
try { workItem(); }
|
||||
catch (Exception ex) { Log.Error(ex, "Unhandled exception in STA work item"); }
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPumpStatsIfDue()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastLogTime < PumpLogInterval) return;
|
||||
Log.Debug("STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
|
||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
|
||||
_lastLogTime = now;
|
||||
}
|
||||
|
||||
#region Win32 PInvoke
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public POINT pt;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int x;
|
||||
public int y;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void PostQuitMessage(int nExitCode);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
119
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
Normal file
119
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Serilog;
|
||||
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)
|
||||
/// </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
|
||||
{
|
||||
public int GobjectId { get; set; }
|
||||
public string TagName { get; set; } = "";
|
||||
public string BrowseName { get; set; } = "";
|
||||
public int ParentGobjectId { get; set; }
|
||||
public bool IsArea { get; set; }
|
||||
public List<AttributeNodeInfo> Attributes { get; set; } = new();
|
||||
public List<NodeInfo> Children { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AttributeNodeInfo
|
||||
{
|
||||
public string AttributeName { get; set; } = "";
|
||||
public string FullTagReference { get; set; } = "";
|
||||
public int MxDataType { get; set; }
|
||||
public bool IsArray { get; set; }
|
||||
public int? ArrayDimension { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building the address space model.
|
||||
/// </summary>
|
||||
public class AddressSpaceModel
|
||||
{
|
||||
public List<NodeInfo> RootNodes { get; set; } = new();
|
||||
public Dictionary<string, string> NodeIdToTagReference { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public int ObjectCount { get; set; }
|
||||
public int VariableCount { get; set; }
|
||||
}
|
||||
|
||||
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
var model = new AddressSpaceModel();
|
||||
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
||||
|
||||
var attrsByObject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Build parent→children map
|
||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Find root objects (parent not in hierarchy)
|
||||
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
|
||||
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
|
||||
|
||||
if (!knownIds.Contains(obj.ParentGobjectId))
|
||||
model.RootNodes.Add(nodeInfo);
|
||||
}
|
||||
|
||||
Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
|
||||
model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
|
||||
Dictionary<int, List<GalaxyAttributeInfo>> attrsByObject,
|
||||
Dictionary<int, List<GalaxyObjectInfo>> childrenByParent,
|
||||
AddressSpaceModel model)
|
||||
{
|
||||
var node = new NodeInfo
|
||||
{
|
||||
GobjectId = obj.GobjectId,
|
||||
TagName = obj.TagName,
|
||||
BrowseName = obj.BrowseName,
|
||||
ParentGobjectId = obj.ParentGobjectId,
|
||||
IsArea = obj.IsArea
|
||||
};
|
||||
|
||||
if (!obj.IsArea)
|
||||
model.ObjectCount++;
|
||||
|
||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
|
||||
{
|
||||
foreach (var attr in attrs)
|
||||
{
|
||||
node.Attributes.Add(new AttributeNodeInfo
|
||||
{
|
||||
AttributeName = attr.AttributeName,
|
||||
FullTagReference = attr.FullTagReference,
|
||||
MxDataType = attr.MxDataType,
|
||||
IsArray = attr.IsArray,
|
||||
ArrayDimension = attr.ArrayDimension
|
||||
});
|
||||
|
||||
model.NodeIdToTagReference[attr.FullTagReference] = attr.FullTagReference;
|
||||
model.VariableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/DataValueConverter.cs
Normal file
80
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/DataValueConverter.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007)
|
||||
/// </summary>
|
||||
public static class DataValueConverter
|
||||
{
|
||||
public static DataValue FromVtq(Vtq vtq)
|
||||
{
|
||||
var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality));
|
||||
|
||||
var dataValue = new DataValue
|
||||
{
|
||||
Value = ConvertToOpcUaValue(vtq.Value),
|
||||
StatusCode = statusCode,
|
||||
SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc ? vtq.Timestamp : vtq.Timestamp.ToUniversalTime(),
|
||||
ServerTimestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return dataValue;
|
||||
}
|
||||
|
||||
public static Vtq ToVtq(DataValue dataValue)
|
||||
{
|
||||
var quality = MapStatusCodeToQuality(dataValue.StatusCode);
|
||||
var timestamp = dataValue.SourceTimestamp != DateTime.MinValue
|
||||
? dataValue.SourceTimestamp
|
||||
: DateTime.UtcNow;
|
||||
|
||||
return new Vtq(dataValue.Value, timestamp, quality);
|
||||
}
|
||||
|
||||
private static object? ConvertToOpcUaValue(object? value)
|
||||
{
|
||||
if (value == null) return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
bool _ => value,
|
||||
int _ => value,
|
||||
float _ => value,
|
||||
double _ => value,
|
||||
string _ => value,
|
||||
DateTime dt => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(),
|
||||
TimeSpan ts => ts.TotalSeconds, // ElapsedTime → Double seconds
|
||||
short s => (int)s,
|
||||
long l => l,
|
||||
byte b => (int)b,
|
||||
bool[] _ => value,
|
||||
int[] _ => value,
|
||||
float[] _ => value,
|
||||
double[] _ => value,
|
||||
string[] _ => value,
|
||||
DateTime[] _ => value,
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static Quality MapStatusCodeToQuality(StatusCode statusCode)
|
||||
{
|
||||
var code = statusCode.Code;
|
||||
if (StatusCode.IsGood(statusCode)) return Quality.Good;
|
||||
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
|
||||
|
||||
return code switch
|
||||
{
|
||||
StatusCodes.BadNotConnected => Quality.BadNotConnected,
|
||||
StatusCodes.BadCommunicationError => Quality.BadCommFailure,
|
||||
StatusCodes.BadConfigurationError => Quality.BadConfigError,
|
||||
StatusCodes.BadOutOfService => Quality.BadOutOfService,
|
||||
StatusCodes.BadWaitingForInitialData => Quality.BadWaitingForInitialData,
|
||||
_ => Quality.Bad
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
369
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
Normal file
369
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
Normal file
@@ -0,0 +1,369 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom node manager that builds the OPC UA address space from Galaxy hierarchy data.
|
||||
/// (OPC-002 through OPC-013)
|
||||
/// </summary>
|
||||
public class LmxNodeManager : CustomNodeManager2
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxNodeManager>();
|
||||
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly string _namespaceUri;
|
||||
|
||||
// NodeId → full_tag_reference for read/write resolution
|
||||
private readonly Dictionary<string, string> _nodeIdToTagReference = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Ref-counted MXAccess subscriptions
|
||||
private readonly Dictionary<string, int> _subscriptionRefCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, BaseDataVariableState> _tagToVariableNode = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
|
||||
|
||||
public IReadOnlyDictionary<string, string> NodeIdToTagReference => _nodeIdToTagReference;
|
||||
public int VariableNodeCount { get; private set; }
|
||||
public int ObjectNodeCount { get; private set; }
|
||||
|
||||
public LmxNodeManager(
|
||||
IServerInternal server,
|
||||
ApplicationConfiguration configuration,
|
||||
string namespaceUri,
|
||||
IMxAccessClient mxAccessClient,
|
||||
PerformanceMetrics metrics)
|
||||
: base(server, configuration, namespaceUri)
|
||||
{
|
||||
_namespaceUri = namespaceUri;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
|
||||
// Wire up data change delivery
|
||||
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
|
||||
}
|
||||
|
||||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_externalReferences = externalReferences;
|
||||
base.CreateAddressSpace(externalReferences);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the address space from Galaxy hierarchy and attributes data. (OPC-002, OPC-003)
|
||||
/// </summary>
|
||||
public void BuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_nodeIdToTagReference.Clear();
|
||||
_tagToVariableNode.Clear();
|
||||
VariableNodeCount = 0;
|
||||
ObjectNodeCount = 0;
|
||||
|
||||
// Build lookup: gobject_id → object info
|
||||
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
||||
|
||||
// Build lookup: gobject_id → list of attributes
|
||||
var attrsByObject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Find root objects (those whose parent is not in the hierarchy)
|
||||
var rootFolder = CreateFolder(null, "ZB", "ZB");
|
||||
rootFolder.NodeId = new NodeId("ZB", NamespaceIndex);
|
||||
rootFolder.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
|
||||
|
||||
// Add reverse reference from Objects folder to our root
|
||||
var extRefs = _externalReferences ?? new Dictionary<NodeId, IList<IReference>>();
|
||||
AddExternalReference(ObjectIds.ObjectsFolder, ReferenceTypeIds.Organizes, false, rootFolder.NodeId, extRefs);
|
||||
|
||||
AddPredefinedNode(SystemContext, rootFolder);
|
||||
|
||||
// Create nodes for each object in hierarchy
|
||||
var nodeMap = new Dictionary<int, NodeState>();
|
||||
var parentIds = new HashSet<int>(hierarchy.Select(h => h.ParentGobjectId));
|
||||
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
NodeState parentNode;
|
||||
if (nodeMap.TryGetValue(obj.ParentGobjectId, out var p))
|
||||
parentNode = p;
|
||||
else
|
||||
parentNode = rootFolder;
|
||||
|
||||
NodeState node;
|
||||
if (obj.IsArea)
|
||||
{
|
||||
// Areas → FolderType + Organizes reference
|
||||
var folder = CreateFolder(parentNode, obj.BrowseName, obj.BrowseName);
|
||||
folder.NodeId = new NodeId(obj.TagName, NamespaceIndex);
|
||||
node = folder;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-areas → BaseObjectType + HasComponent reference
|
||||
var objNode = CreateObject(parentNode, obj.BrowseName, obj.BrowseName);
|
||||
objNode.NodeId = new NodeId(obj.TagName, NamespaceIndex);
|
||||
node = objNode;
|
||||
ObjectNodeCount++;
|
||||
}
|
||||
|
||||
AddPredefinedNode(SystemContext, node);
|
||||
nodeMap[obj.GobjectId] = node;
|
||||
|
||||
// Create variable nodes for this object's attributes
|
||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs))
|
||||
{
|
||||
foreach (var attr in objAttrs)
|
||||
{
|
||||
CreateAttributeVariable(node, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references",
|
||||
ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the address space, removing old nodes and creating new ones. (OPC-010)
|
||||
/// </summary>
|
||||
public void RebuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Log.Information("Rebuilding address space...");
|
||||
|
||||
// Remove all predefined nodes
|
||||
var nodesToRemove = new List<NodeId>();
|
||||
foreach (var kvp in _nodeIdToTagReference)
|
||||
{
|
||||
var nodeId = new NodeId(kvp.Key, NamespaceIndex);
|
||||
nodesToRemove.Add(nodeId);
|
||||
}
|
||||
|
||||
foreach (var nodeId in PredefinedNodes.Keys.ToList())
|
||||
{
|
||||
try { DeleteNode(SystemContext, nodeId); }
|
||||
catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
|
||||
PredefinedNodes.Clear();
|
||||
_nodeIdToTagReference.Clear();
|
||||
_tagToVariableNode.Clear();
|
||||
_subscriptionRefCounts.Clear();
|
||||
|
||||
// Rebuild
|
||||
BuildAddressSpace(hierarchy, attributes);
|
||||
Log.Information("Address space rebuild complete");
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateAttributeVariable(NodeState parent, GalaxyAttributeInfo attr)
|
||||
{
|
||||
var opcUaDataTypeId = MxDataTypeMapper.MapToOpcUaDataType(attr.MxDataType);
|
||||
var variable = CreateVariable(parent, attr.AttributeName, attr.AttributeName, new NodeId(opcUaDataTypeId),
|
||||
attr.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar);
|
||||
|
||||
var nodeIdString = attr.FullTagReference;
|
||||
variable.NodeId = new NodeId(nodeIdString, NamespaceIndex);
|
||||
|
||||
if (attr.IsArray && attr.ArrayDimension.HasValue)
|
||||
{
|
||||
variable.ArrayDimensions = new ReadOnlyList<uint>(new List<uint> { (uint)attr.ArrayDimension.Value });
|
||||
}
|
||||
|
||||
variable.AccessLevel = AccessLevels.CurrentReadOrWrite;
|
||||
variable.UserAccessLevel = AccessLevels.CurrentReadOrWrite;
|
||||
variable.StatusCode = StatusCodes.BadWaitingForInitialData;
|
||||
variable.Timestamp = DateTime.UtcNow;
|
||||
|
||||
AddPredefinedNode(SystemContext, variable);
|
||||
_nodeIdToTagReference[nodeIdString] = attr.FullTagReference;
|
||||
_tagToVariableNode[attr.FullTagReference] = variable;
|
||||
VariableNodeCount++;
|
||||
}
|
||||
|
||||
private FolderState CreateFolder(NodeState? parent, string path, string name)
|
||||
{
|
||||
var folder = new FolderState(parent)
|
||||
{
|
||||
SymbolicName = name,
|
||||
ReferenceTypeId = ReferenceTypes.Organizes,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
NodeId = new NodeId(path, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(name, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", name),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
EventNotifier = EventNotifiers.None
|
||||
};
|
||||
|
||||
parent?.AddChild(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
private BaseObjectState CreateObject(NodeState parent, string path, string name)
|
||||
{
|
||||
var obj = new BaseObjectState(parent)
|
||||
{
|
||||
SymbolicName = name,
|
||||
ReferenceTypeId = ReferenceTypes.HasComponent,
|
||||
TypeDefinitionId = ObjectTypeIds.BaseObjectType,
|
||||
NodeId = new NodeId(path, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(name, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", name),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
EventNotifier = EventNotifiers.None
|
||||
};
|
||||
|
||||
parent.AddChild(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private BaseDataVariableState CreateVariable(NodeState parent, string path, string name, NodeId dataType, int valueRank)
|
||||
{
|
||||
var variable = new BaseDataVariableState(parent)
|
||||
{
|
||||
SymbolicName = name,
|
||||
ReferenceTypeId = ReferenceTypes.HasComponent,
|
||||
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||||
NodeId = new NodeId(path, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(name, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", name),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
DataType = dataType,
|
||||
ValueRank = valueRank,
|
||||
AccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
Historizing = false,
|
||||
StatusCode = StatusCodes.Good,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
parent.AddChild(variable);
|
||||
return variable;
|
||||
}
|
||||
|
||||
#region Read/Write Handlers
|
||||
|
||||
public override void Read(OperationContext context, double maxAge, IList<ReadValueId> nodesToRead,
|
||||
IList<DataValue> results, IList<ServiceResult> errors)
|
||||
{
|
||||
base.Read(context, maxAge, nodesToRead, results, errors);
|
||||
|
||||
for (int i = 0; i < nodesToRead.Count; i++)
|
||||
{
|
||||
var nodeId = nodesToRead[i].NodeId;
|
||||
if (nodeId.NamespaceIndex != NamespaceIndex) continue;
|
||||
|
||||
var nodeIdStr = nodeId.Identifier as string;
|
||||
if (nodeIdStr == null) continue;
|
||||
|
||||
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
try
|
||||
{
|
||||
var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult();
|
||||
results[i] = DataValueConverter.FromVtq(vtq);
|
||||
errors[i] = ServiceResult.Good;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Read failed for {TagRef}", tagRef);
|
||||
errors[i] = new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(OperationContext context, IList<WriteValue> nodesToWrite,
|
||||
IList<ServiceResult> errors)
|
||||
{
|
||||
base.Write(context, nodesToWrite, errors);
|
||||
|
||||
for (int i = 0; i < nodesToWrite.Count; i++)
|
||||
{
|
||||
var nodeId = nodesToWrite[i].NodeId;
|
||||
if (nodeId.NamespaceIndex != NamespaceIndex) continue;
|
||||
|
||||
var nodeIdStr = nodeId.Identifier as string;
|
||||
if (nodeIdStr == null) continue;
|
||||
|
||||
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = nodesToWrite[i].Value.WrappedValue.Value;
|
||||
var success = _mxAccessClient.WriteAsync(tagRef, value).GetAwaiter().GetResult();
|
||||
errors[i] = success ? ServiceResult.Good : new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Write failed for {TagRef}", tagRef);
|
||||
errors[i] = new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Subscription Delivery
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to MXAccess for the given tag reference. Called by the service wiring layer.
|
||||
/// </summary>
|
||||
public void SubscribeTag(string fullTagReference)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
||||
{
|
||||
_subscriptionRefCounts[fullTagReference] = count + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_subscriptionRefCounts[fullTagReference] = 1;
|
||||
_ = _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMxAccessDataChange(string address, Vtq vtq)
|
||||
{
|
||||
if (_tagToVariableNode.TryGetValue(address, out var variable))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataValue = DataValueConverter.FromVtq(vtq);
|
||||
variable.Value = dataValue.Value;
|
||||
variable.StatusCode = dataValue.StatusCode;
|
||||
variable.Timestamp = dataValue.SourceTimestamp;
|
||||
variable.ClearChangeMasks(SystemContext, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error updating variable node for {Address}", address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
59
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs
Normal file
59
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom OPC UA server that creates the LmxNodeManager. (OPC-001, OPC-012)
|
||||
/// </summary>
|
||||
public class LmxOpcUaServer : StandardServer
|
||||
{
|
||||
private readonly string _galaxyName;
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private LmxNodeManager? _nodeManager;
|
||||
|
||||
public LmxNodeManager? NodeManager => _nodeManager;
|
||||
public int ActiveSessionCount
|
||||
{
|
||||
get
|
||||
{
|
||||
try { return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0; }
|
||||
catch { return 0; }
|
||||
}
|
||||
}
|
||||
|
||||
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics)
|
||||
{
|
||||
_galaxyName = galaxyName;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
||||
{
|
||||
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
|
||||
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics);
|
||||
|
||||
var nodeManagers = new List<INodeManager> { _nodeManager };
|
||||
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
|
||||
}
|
||||
|
||||
protected override ServerProperties LoadServerProperties()
|
||||
{
|
||||
var properties = new ServerProperties
|
||||
{
|
||||
ManufacturerName = "ZB MOM",
|
||||
ProductName = "LmxOpcUa Server",
|
||||
ProductUri = $"urn:{_galaxyName}:LmxOpcUa",
|
||||
SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
BuildNumber = "1",
|
||||
BuildDate = System.DateTime.UtcNow
|
||||
};
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaQualityMapper.cs
Normal file
23
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaQualityMapper.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005)
|
||||
/// </summary>
|
||||
public static class OpcUaQualityMapper
|
||||
{
|
||||
public static StatusCode ToStatusCode(Quality quality)
|
||||
{
|
||||
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
|
||||
}
|
||||
|
||||
public static Quality FromStatusCode(StatusCode statusCode)
|
||||
{
|
||||
if (StatusCode.IsGood(statusCode)) return Quality.Good;
|
||||
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
|
||||
return Quality.Bad;
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs
Normal file
165
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Server;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013)
|
||||
/// </summary>
|
||||
public class OpcUaServerHost : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
|
||||
|
||||
private readonly OpcUaConfiguration _config;
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private ApplicationInstance? _application;
|
||||
private LmxOpcUaServer? _server;
|
||||
|
||||
public LmxNodeManager? NodeManager => _server?.NodeManager;
|
||||
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
|
||||
public bool IsRunning => _server != null;
|
||||
|
||||
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics)
|
||||
{
|
||||
_config = config;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
|
||||
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationUri = namespaceUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ProductUri = namespaceUri,
|
||||
|
||||
ServerConfiguration = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://0.0.0.0:{_config.Port}{_config.EndpointPath}" },
|
||||
MaxSessionCount = _config.MaxSessions,
|
||||
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
|
||||
MinSessionTimeout = 10000,
|
||||
SecurityPolicies =
|
||||
{
|
||||
new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None
|
||||
}
|
||||
},
|
||||
UserTokenPolicies =
|
||||
{
|
||||
new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
}
|
||||
},
|
||||
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "own"),
|
||||
SubjectName = $"CN={_config.ServerName}, O=ZB MOM, DC=localhost"
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "issuer")
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "trusted")
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki", "rejected")
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = true
|
||||
},
|
||||
|
||||
TransportQuotas = new TransportQuotas
|
||||
{
|
||||
OperationTimeout = 120000,
|
||||
MaxStringLength = 4 * 1024 * 1024,
|
||||
MaxByteStringLength = 4 * 1024 * 1024,
|
||||
MaxArrayLength = 65535,
|
||||
MaxMessageSize = 4 * 1024 * 1024,
|
||||
MaxBufferSize = 65535,
|
||||
ChannelLifetime = 600000,
|
||||
SecurityTokenLifetime = 3600000
|
||||
},
|
||||
|
||||
TraceConfiguration = new TraceConfiguration
|
||||
{
|
||||
OutputFilePath = null,
|
||||
TraceMasks = 0
|
||||
}
|
||||
};
|
||||
|
||||
await appConfig.Validate(ApplicationType.Server);
|
||||
|
||||
_application = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ApplicationConfiguration = appConfig
|
||||
};
|
||||
|
||||
// Check/create application certificate
|
||||
bool certOk = await _application.CheckApplicationInstanceCertificate(false, 2048);
|
||||
if (!certOk)
|
||||
{
|
||||
Log.Warning("Application certificate check failed, attempting to create...");
|
||||
certOk = await _application.CheckApplicationInstanceCertificate(false, 2048);
|
||||
}
|
||||
|
||||
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics);
|
||||
await _application.Start(_server);
|
||||
|
||||
Log.Information("OPC UA server started on opc.tcp://localhost:{Port}{EndpointPath} (namespace={Namespace})",
|
||||
_config.Port, _config.EndpointPath, namespaceUri);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_server?.Stop();
|
||||
Log.Information("OPC UA server stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error stopping OPC UA server");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_server = null;
|
||||
_application = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
}
|
||||
285
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs
Normal file
285
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Status;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
{
|
||||
/// <summary>
|
||||
/// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006)
|
||||
/// </summary>
|
||||
internal sealed class OpcUaService
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaService>();
|
||||
|
||||
private readonly AppConfiguration _config;
|
||||
private readonly IMxProxy? _mxProxy;
|
||||
private readonly IGalaxyRepository? _galaxyRepository;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private PerformanceMetrics? _metrics;
|
||||
private StaComThread? _staThread;
|
||||
private MxAccessClient? _mxAccessClient;
|
||||
private ChangeDetectionService? _changeDetection;
|
||||
private OpcUaServerHost? _serverHost;
|
||||
private LmxNodeManager? _nodeManager;
|
||||
private HealthCheckService? _healthCheck;
|
||||
private StatusReportService? _statusReport;
|
||||
private StatusWebServer? _statusWebServer;
|
||||
private GalaxyRepositoryStats? _galaxyStats;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor. Loads configuration from appsettings.json.
|
||||
/// </summary>
|
||||
public OpcUaService()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
_config = new AppConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(_config.OpcUa);
|
||||
configuration.GetSection("MxAccess").Bind(_config.MxAccess);
|
||||
configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
|
||||
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
|
||||
|
||||
_mxProxy = new MxProxyAdapter();
|
||||
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test constructor. Accepts injected dependencies.
|
||||
/// </summary>
|
||||
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository)
|
||||
{
|
||||
_config = config;
|
||||
_mxProxy = mxProxy;
|
||||
_galaxyRepository = galaxyRepository;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
Log.Information("LmxOpcUa service starting");
|
||||
|
||||
try
|
||||
{
|
||||
// Step 2: Validate config
|
||||
if (!ConfigurationValidator.ValidateAndLog(_config))
|
||||
{
|
||||
Log.Error("Configuration validation failed");
|
||||
throw new InvalidOperationException("Configuration validation failed");
|
||||
}
|
||||
|
||||
// Step 3: Register exception handler (SVC-006)
|
||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
||||
|
||||
// Step 4: Create PerformanceMetrics
|
||||
_cts = new CancellationTokenSource();
|
||||
_metrics = new PerformanceMetrics();
|
||||
|
||||
// Step 5: Create MxAccessClient → Connect
|
||||
if (_mxProxy != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, _metrics);
|
||||
_mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
|
||||
// Step 6: Start monitor loop
|
||||
_mxAccessClient.StartMonitor();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "MxAccess connection failed — continuing without runtime data access");
|
||||
_mxAccessClient?.Dispose();
|
||||
_mxAccessClient = null;
|
||||
_staThread?.Dispose();
|
||||
_staThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Create GalaxyRepositoryService → TestConnection
|
||||
_galaxyStats = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName };
|
||||
|
||||
if (_galaxyRepository != null)
|
||||
{
|
||||
var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
_galaxyStats.DbConnected = dbOk;
|
||||
if (!dbOk)
|
||||
Log.Warning("Galaxy repository database connection failed — continuing without initial data");
|
||||
}
|
||||
|
||||
// Step 8: Create OPC UA server host + node manager
|
||||
IMxAccessClient mxClient = _mxAccessClient ?? (IMxAccessClient)new NullMxAccessClient();
|
||||
_serverHost = new OpcUaServerHost(_config.OpcUa, mxClient, _metrics);
|
||||
|
||||
// Step 9-10: Query hierarchy, start server, build address space
|
||||
if (_galaxyRepository != null && _galaxyStats.DbConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
_galaxyStats.ObjectCount = hierarchy.Count;
|
||||
_galaxyStats.AttributeCount = attributes.Count;
|
||||
|
||||
_serverHost.StartAsync().GetAwaiter().GetResult();
|
||||
_nodeManager = _serverHost.NodeManager;
|
||||
|
||||
if (_nodeManager != null)
|
||||
{
|
||||
_nodeManager.BuildAddressSpace(hierarchy, attributes);
|
||||
_galaxyStats.LastRebuildTime = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to build initial address space");
|
||||
if (!_serverHost.IsRunning)
|
||||
{
|
||||
_serverHost.StartAsync().GetAwaiter().GetResult();
|
||||
_nodeManager = _serverHost.NodeManager;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_serverHost.StartAsync().GetAwaiter().GetResult();
|
||||
_nodeManager = _serverHost.NodeManager;
|
||||
}
|
||||
|
||||
// Step 11-12: Change detection wired to rebuild
|
||||
if (_galaxyRepository != null)
|
||||
{
|
||||
_changeDetection = new ChangeDetectionService(_galaxyRepository, _config.GalaxyRepository.ChangeDetectionIntervalSeconds);
|
||||
_changeDetection.OnGalaxyChanged += OnGalaxyChanged;
|
||||
_changeDetection.Start();
|
||||
}
|
||||
|
||||
// Step 13: Dashboard
|
||||
_healthCheck = new HealthCheckService();
|
||||
_statusReport = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds);
|
||||
_statusReport.SetComponents(_mxAccessClient, _metrics, _galaxyStats, _serverHost);
|
||||
|
||||
if (_config.Dashboard.Enabled)
|
||||
{
|
||||
_statusWebServer = new StatusWebServer(_statusReport, _config.Dashboard.Port);
|
||||
_statusWebServer.Start();
|
||||
}
|
||||
|
||||
// Step 14
|
||||
Log.Information("LmxOpcUa service started successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "LmxOpcUa service failed to start");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Log.Information("LmxOpcUa service stopping");
|
||||
|
||||
try
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_changeDetection?.Stop();
|
||||
_serverHost?.Stop();
|
||||
|
||||
if (_mxAccessClient != null)
|
||||
{
|
||||
_mxAccessClient.StopMonitor();
|
||||
_mxAccessClient.DisconnectAsync().GetAwaiter().GetResult();
|
||||
_mxAccessClient.Dispose();
|
||||
}
|
||||
_staThread?.Dispose();
|
||||
|
||||
_statusWebServer?.Dispose();
|
||||
_metrics?.Dispose();
|
||||
_changeDetection?.Dispose();
|
||||
_cts?.Dispose();
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during service shutdown");
|
||||
}
|
||||
|
||||
Log.Information("Service shutdown complete");
|
||||
}
|
||||
|
||||
private void OnGalaxyChanged()
|
||||
{
|
||||
Log.Information("Galaxy change detected — rebuilding address space");
|
||||
try
|
||||
{
|
||||
if (_galaxyRepository == null || _nodeManager == null) return;
|
||||
|
||||
var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult();
|
||||
var attributes = _galaxyRepository.GetAttributesAsync().GetAwaiter().GetResult();
|
||||
|
||||
_nodeManager.RebuildAddressSpace(hierarchy, attributes);
|
||||
|
||||
if (_galaxyStats != null)
|
||||
{
|
||||
_galaxyStats.ObjectCount = hierarchy.Count;
|
||||
_galaxyStats.AttributeCount = attributes.Count;
|
||||
_galaxyStats.LastRebuildTime = DateTime.UtcNow;
|
||||
_galaxyStats.LastDeployTime = _changeDetection?.LastKnownDeployTime;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to rebuild address space");
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
|
||||
}
|
||||
|
||||
// Accessors for testing
|
||||
internal IMxAccessClient? MxClient => _mxAccessClient;
|
||||
internal PerformanceMetrics? Metrics => _metrics;
|
||||
internal OpcUaServerHost? ServerHost => _serverHost;
|
||||
internal LmxNodeManager? NodeManagerInstance => _nodeManager;
|
||||
internal ChangeDetectionService? ChangeDetectionInstance => _changeDetection;
|
||||
internal StatusWebServer? StatusWeb => _statusWebServer;
|
||||
internal StatusReportService? StatusReportInstance => _statusReport;
|
||||
internal GalaxyRepositoryStats? GalaxyStatsInstance => _galaxyStats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IMxAccessClient for when MXAccess is not available.
|
||||
/// </summary>
|
||||
internal sealed class NullMxAccessClient : IMxAccessClient
|
||||
{
|
||||
public ConnectionState State => ConnectionState.Disconnected;
|
||||
public int ActiveSubscriptionCount => 0;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
public System.Threading.Tasks.Task ConnectAsync(CancellationToken ct = default) => System.Threading.Tasks.Task.CompletedTask;
|
||||
public System.Threading.Tasks.Task DisconnectAsync() => System.Threading.Tasks.Task.CompletedTask;
|
||||
public System.Threading.Tasks.Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback) => System.Threading.Tasks.Task.CompletedTask;
|
||||
public System.Threading.Tasks.Task UnsubscribeAsync(string fullTagReference) => System.Threading.Tasks.Task.CompletedTask;
|
||||
public System.Threading.Tasks.Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(Vtq.Bad());
|
||||
public System.Threading.Tasks.Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default) => System.Threading.Tasks.Task.FromResult(false);
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
53
src/ZB.MOM.WW.LmxOpcUa.Host/Program.cs
Normal file
53
src/ZB.MOM.WW.LmxOpcUa.Host/Program.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Serilog;
|
||||
using Topshelf;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
static int Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
path: "logs/lmxopcua-.log",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 31)
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var exitCode = HostFactory.Run(host =>
|
||||
{
|
||||
host.UseSerilog();
|
||||
|
||||
host.Service<OpcUaService>(svc =>
|
||||
{
|
||||
svc.ConstructUsing(() => new OpcUaService());
|
||||
svc.WhenStarted(s => s.Start());
|
||||
svc.WhenStopped(s => s.Stop());
|
||||
});
|
||||
|
||||
host.SetServiceName("LmxOpcUa");
|
||||
host.SetDisplayName("LMX OPC UA Server");
|
||||
host.SetDescription("OPC UA server exposing System Platform Galaxy tags via MXAccess.");
|
||||
host.RunAsLocalSystem();
|
||||
host.StartAutomatically();
|
||||
});
|
||||
|
||||
return (int)exitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Host terminated unexpectedly");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/ZB.MOM.WW.LmxOpcUa.Host/Status/HealthCheckService.cs
Normal file
57
src/ZB.MOM.WW.LmxOpcUa.Host/Status/HealthCheckService.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines health status based on connection state and operation success rates. (DASH-003)
|
||||
/// </summary>
|
||||
public class HealthCheckService
|
||||
{
|
||||
public HealthInfo CheckHealth(ConnectionState connectionState, PerformanceMetrics? metrics)
|
||||
{
|
||||
// Rule 1: Not connected → Unhealthy
|
||||
if (connectionState != ConnectionState.Connected)
|
||||
{
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Unhealthy",
|
||||
Message = $"MXAccess not connected (state: {connectionState})",
|
||||
Color = "red"
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 2: Success rate < 50% with > 100 ops → Degraded
|
||||
if (metrics != null)
|
||||
{
|
||||
var stats = metrics.GetStatistics();
|
||||
foreach (var kvp in stats)
|
||||
{
|
||||
if (kvp.Value.TotalCount > 100 && kvp.Value.SuccessRate < 0.5)
|
||||
{
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Degraded",
|
||||
Message = $"{kvp.Key} success rate is {kvp.Value.SuccessRate:P0} ({kvp.Value.TotalCount} ops)",
|
||||
Color = "yellow"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: All good
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Healthy",
|
||||
Message = "All systems operational",
|
||||
Color = "green"
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics)
|
||||
{
|
||||
var health = CheckHealth(connectionState, metrics);
|
||||
return health.Status != "Unhealthy";
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs
Normal file
54
src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusData.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO containing all dashboard data. (DASH-001 through DASH-009)
|
||||
/// </summary>
|
||||
public class StatusData
|
||||
{
|
||||
public ConnectionInfo Connection { get; set; } = new();
|
||||
public HealthInfo Health { get; set; } = new();
|
||||
public SubscriptionInfo Subscriptions { get; set; } = new();
|
||||
public GalaxyInfo Galaxy { get; set; } = new();
|
||||
public Dictionary<string, MetricsStatistics> Operations { get; set; } = new();
|
||||
public FooterInfo Footer { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ConnectionInfo
|
||||
{
|
||||
public string State { get; set; } = "Disconnected";
|
||||
public int ReconnectCount { get; set; }
|
||||
public int ActiveSessions { get; set; }
|
||||
}
|
||||
|
||||
public class HealthInfo
|
||||
{
|
||||
public string Status { get; set; } = "Unknown";
|
||||
public string Message { get; set; } = "";
|
||||
public string Color { get; set; } = "gray";
|
||||
}
|
||||
|
||||
public class SubscriptionInfo
|
||||
{
|
||||
public int ActiveCount { get; set; }
|
||||
}
|
||||
|
||||
public class GalaxyInfo
|
||||
{
|
||||
public string GalaxyName { get; set; } = "";
|
||||
public bool DbConnected { get; set; }
|
||||
public DateTime? LastDeployTime { get; set; }
|
||||
public int ObjectCount { get; set; }
|
||||
public int AttributeCount { get; set; }
|
||||
public DateTime? LastRebuildTime { get; set; }
|
||||
}
|
||||
|
||||
public class FooterInfo
|
||||
{
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string Version { get; set; } = "";
|
||||
}
|
||||
}
|
||||
146
src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs
Normal file
146
src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusReportService.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009)
|
||||
/// </summary>
|
||||
public class StatusReportService
|
||||
{
|
||||
private readonly HealthCheckService _healthCheck;
|
||||
private readonly int _refreshIntervalSeconds;
|
||||
|
||||
private IMxAccessClient? _mxAccessClient;
|
||||
private PerformanceMetrics? _metrics;
|
||||
private GalaxyRepositoryStats? _galaxyStats;
|
||||
private OpcUaServerHost? _serverHost;
|
||||
|
||||
public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds)
|
||||
{
|
||||
_healthCheck = healthCheck;
|
||||
_refreshIntervalSeconds = refreshIntervalSeconds;
|
||||
}
|
||||
|
||||
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
|
||||
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost)
|
||||
{
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
_galaxyStats = galaxyStats;
|
||||
_serverHost = serverHost;
|
||||
}
|
||||
|
||||
public StatusData GetStatusData()
|
||||
{
|
||||
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
||||
|
||||
return new StatusData
|
||||
{
|
||||
Connection = new ConnectionInfo
|
||||
{
|
||||
State = connectionState.ToString(),
|
||||
ReconnectCount = _mxAccessClient?.ReconnectCount ?? 0,
|
||||
ActiveSessions = _serverHost?.ActiveSessionCount ?? 0
|
||||
},
|
||||
Health = _healthCheck.CheckHealth(connectionState, _metrics),
|
||||
Subscriptions = new SubscriptionInfo
|
||||
{
|
||||
ActiveCount = _mxAccessClient?.ActiveSubscriptionCount ?? 0
|
||||
},
|
||||
Galaxy = new GalaxyInfo
|
||||
{
|
||||
GalaxyName = _galaxyStats?.GalaxyName ?? "",
|
||||
DbConnected = _galaxyStats?.DbConnected ?? false,
|
||||
LastDeployTime = _galaxyStats?.LastDeployTime,
|
||||
ObjectCount = _galaxyStats?.ObjectCount ?? 0,
|
||||
AttributeCount = _galaxyStats?.AttributeCount ?? 0,
|
||||
LastRebuildTime = _galaxyStats?.LastRebuildTime
|
||||
},
|
||||
Operations = _metrics?.GetStatistics() ?? new(),
|
||||
Footer = new FooterInfo
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Version = typeof(StatusReportService).Assembly.GetName().Version?.ToString() ?? "1.0.0"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public string GenerateHtml()
|
||||
{
|
||||
var data = GetStatusData();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html><html><head>");
|
||||
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
||||
sb.AppendLine("<title>LmxOpcUa Status</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine("body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }");
|
||||
sb.AppendLine(".panel { border: 2px solid #444; border-radius: 8px; padding: 15px; margin: 10px 0; }");
|
||||
sb.AppendLine(".green { border-color: #00cc66; } .red { border-color: #cc3333; } .yellow { border-color: #cccc33; } .gray { border-color: #666; }");
|
||||
sb.AppendLine("table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; }");
|
||||
sb.AppendLine("h2 { margin: 0 0 10px 0; } h1 { color: #66ccff; }");
|
||||
sb.AppendLine("</style></head><body>");
|
||||
sb.AppendLine("<h1>LmxOpcUa Status Dashboard</h1>");
|
||||
|
||||
// Connection panel
|
||||
var connColor = data.Connection.State == "Connected" ? "green" : data.Connection.State == "Connecting" ? "yellow" : "red";
|
||||
sb.AppendLine($"<div class='panel {connColor}'><h2>Connection</h2>");
|
||||
sb.AppendLine($"<p>State: <b>{data.Connection.State}</b> | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}</p>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Health panel
|
||||
sb.AppendLine($"<div class='panel {data.Health.Color}'><h2>Health</h2>");
|
||||
sb.AppendLine($"<p>Status: <b>{data.Health.Status}</b> — {data.Health.Message}</p>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Subscriptions panel
|
||||
sb.AppendLine("<div class='panel gray'><h2>Subscriptions</h2>");
|
||||
sb.AppendLine($"<p>Active: {data.Subscriptions.ActiveCount}</p>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Galaxy Info panel
|
||||
sb.AppendLine("<div class='panel gray'><h2>Galaxy Info</h2>");
|
||||
sb.AppendLine($"<p>Galaxy: <b>{data.Galaxy.GalaxyName}</b> | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}</p>");
|
||||
sb.AppendLine($"<p>Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}</p>");
|
||||
sb.AppendLine($"<p>Last Rebuild: {data.Galaxy.LastRebuildTime:O}</p>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Operations table
|
||||
sb.AppendLine("<div class='panel gray'><h2>Operations</h2>");
|
||||
sb.AppendLine("<table><tr><th>Operation</th><th>Count</th><th>Success Rate</th><th>Avg (ms)</th><th>Min (ms)</th><th>Max (ms)</th><th>P95 (ms)</th></tr>");
|
||||
foreach (var kvp in data.Operations)
|
||||
{
|
||||
var s = kvp.Value;
|
||||
sb.AppendLine($"<tr><td>{kvp.Key}</td><td>{s.TotalCount}</td><td>{s.SuccessRate:P1}</td>" +
|
||||
$"<td>{s.AverageMilliseconds:F1}</td><td>{s.MinMilliseconds:F1}</td><td>{s.MaxMilliseconds:F1}</td><td>{s.Percentile95Milliseconds:F1}</td></tr>");
|
||||
}
|
||||
sb.AppendLine("</table></div>");
|
||||
|
||||
// Footer
|
||||
sb.AppendLine("<div class='panel gray'><h2>Footer</h2>");
|
||||
sb.AppendLine($"<p>Generated: {data.Footer.Timestamp:O} | Version: {data.Footer.Version}</p>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public string GenerateJson()
|
||||
{
|
||||
var data = GetStatusData();
|
||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
public bool IsHealthy()
|
||||
{
|
||||
var state = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
||||
return _healthCheck.IsHealthy(state, _metrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusWebServer.cs
Normal file
144
src/ZB.MOM.WW.LmxOpcUa.Host/Status/StatusWebServer.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP server for status dashboard. Routes: / → HTML, /api/status → JSON, /api/health → 200/503. (DASH-001)
|
||||
/// </summary>
|
||||
public class StatusWebServer : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<StatusWebServer>();
|
||||
|
||||
private readonly StatusReportService _reportService;
|
||||
private readonly int _port;
|
||||
private HttpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
public bool IsRunning => _listener?.IsListening ?? false;
|
||||
|
||||
public StatusWebServer(StatusReportService reportService, int port)
|
||||
{
|
||||
_reportService = reportService;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add($"http://+:{_port}/");
|
||||
_listener.Start();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
Task.Run(() => ListenLoopAsync(_cts.Token));
|
||||
|
||||
Log.Information("Status dashboard started on http://localhost:{Port}/", _port);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to start status dashboard on port {Port}", _port);
|
||||
_listener = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
try
|
||||
{
|
||||
_listener?.Stop();
|
||||
_listener?.Close();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
_listener = null;
|
||||
Log.Information("Status dashboard stopped");
|
||||
}
|
||||
|
||||
private async Task ListenLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested && _listener != null && _listener.IsListening)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _listener.GetContextAsync();
|
||||
_ = HandleRequestAsync(context);
|
||||
}
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (HttpListenerException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Dashboard listener error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
|
||||
// Only allow GET
|
||||
if (request.HttpMethod != "GET")
|
||||
{
|
||||
response.StatusCode = 405;
|
||||
response.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
// No-cache headers
|
||||
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
response.Headers.Add("Pragma", "no-cache");
|
||||
response.Headers.Add("Expires", "0");
|
||||
|
||||
var path = request.Url?.AbsolutePath ?? "/";
|
||||
|
||||
switch (path)
|
||||
{
|
||||
case "/":
|
||||
await WriteResponse(response, _reportService.GenerateHtml(), "text/html", 200);
|
||||
break;
|
||||
|
||||
case "/api/status":
|
||||
await WriteResponse(response, _reportService.GenerateJson(), "application/json", 200);
|
||||
break;
|
||||
|
||||
case "/api/health":
|
||||
var isHealthy = _reportService.IsHealthy();
|
||||
var healthJson = isHealthy ? "{\"status\":\"healthy\"}" : "{\"status\":\"unhealthy\"}";
|
||||
await WriteResponse(response, healthJson, "application/json", isHealthy ? 200 : 503);
|
||||
break;
|
||||
|
||||
default:
|
||||
response.StatusCode = 404;
|
||||
response.Close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error handling dashboard request");
|
||||
try { context.Response.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteResponse(HttpListenerResponse response, string body, string contentType, int statusCode)
|
||||
{
|
||||
var buffer = Encoding.UTF8.GetBytes(body);
|
||||
response.StatusCode = statusCode;
|
||||
response.ContentType = contentType;
|
||||
response.ContentLength64 = buffer.Length;
|
||||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||||
response.Close();
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
}
|
||||
54
src/ZB.MOM.WW.LmxOpcUa.Host/ZB.MOM.WW.LmxOpcUa.Host.csproj
Normal file
54
src/ZB.MOM.WW.LmxOpcUa.Host/ZB.MOM.WW.LmxOpcUa.Host.csproj
Normal file
@@ -0,0 +1,54 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Host</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.LmxOpcUa.Host</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.LmxOpcUa.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Service hosting -->
|
||||
<PackageReference Include="Topshelf" Version="4.3.0" />
|
||||
<PackageReference Include="Topshelf.Serilog" Version="4.3.0" />
|
||||
|
||||
<!-- Logging -->
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||
|
||||
<!-- OPC UA -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126" />
|
||||
|
||||
<!-- Configuration -->
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- MXAccess COM interop -->
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="appsettings.*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
32
src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json
Normal file
32
src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"OpcUa": {
|
||||
"Port": 4840,
|
||||
"EndpointPath": "/LmxOpcUa",
|
||||
"ServerName": "LmxOpcUa",
|
||||
"GalaxyName": "ZB",
|
||||
"MaxSessions": 100,
|
||||
"SessionTimeoutMinutes": 30
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa",
|
||||
"NodeName": null,
|
||||
"GalaxyName": null,
|
||||
"ReadTimeoutSeconds": 5,
|
||||
"WriteTimeoutSeconds": 5,
|
||||
"MaxConcurrentOperations": 10,
|
||||
"MonitorIntervalSeconds": 5,
|
||||
"AutoReconnect": true,
|
||||
"ProbeTag": null,
|
||||
"ProbeStaleThresholdSeconds": 60
|
||||
},
|
||||
"GalaxyRepository": {
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;",
|
||||
"ChangeDetectionIntervalSeconds": 30,
|
||||
"CommandTimeoutSeconds": 30
|
||||
},
|
||||
"Dashboard": {
|
||||
"Enabled": true,
|
||||
"Port": 8081,
|
||||
"RefreshIntervalSeconds": 10
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user