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:
Joseph Doherty
2026-03-25 05:55:27 -04:00
commit a7576ffb38
283 changed files with 16493 additions and 0 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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
}
}

View File

@@ -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 ?? "";
}
}
}

View 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; }
}
}

View 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; }
}
}

View 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;
}
}

View 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; }
}
}

View 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;
}
}

View 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"
};
}
}
}

View 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
};
}
}
}

View 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;
}
}

View 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
};
}
}
}

View 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})";
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}
}

View 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);
}
}
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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");
}
}
}

View 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();
}
}
}
}

View File

@@ -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);
}
}
}

View 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();
}
}
}
}

View 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);
}
}
}

View 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
}
}

View 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;
}
}
}

View 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
};
}
}
}

View 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
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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();
}
}

View 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() { }
}
}

View 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();
}
}
}
}

View 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";
}
}
}

View 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; } = "";
}
}

View 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);
}
}
}

View 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();
}
}

View 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>

View 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
}
}