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,45 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
public class FakeGalaxyRepository : IGalaxyRepository
{
public event Action? OnGalaxyChanged;
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new List<GalaxyObjectInfo>();
public List<GalaxyAttributeInfo> Attributes { get; set; } = new List<GalaxyAttributeInfo>();
public DateTime? LastDeployTime { get; set; } = DateTime.UtcNow;
public bool ConnectionSucceeds { get; set; } = true;
public bool ShouldThrow { get; set; }
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(Hierarchy);
}
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(Attributes);
}
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(LastDeployTime);
}
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
if (ShouldThrow) throw new Exception("Simulated DB failure");
return Task.FromResult(ConnectionSucceeds);
}
public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke();
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
public class FakeMxAccessClient : IMxAccessClient
{
public ConnectionState State { get; set; } = ConnectionState.Connected;
public int ActiveSubscriptionCount => _subscriptions.Count;
public int ReconnectCount { get; set; }
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
public event Action<string, Vtq>? OnTagValueChanged;
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _subscriptions = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Vtq> TagValues { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<(string Tag, object Value)> WrittenValues { get; } = new();
public bool WriteResult { get; set; } = true;
public Task ConnectAsync(CancellationToken ct = default)
{
State = ConnectionState.Connected;
return Task.CompletedTask;
}
public Task DisconnectAsync()
{
State = ConnectionState.Disconnected;
return Task.CompletedTask;
}
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
_subscriptions[fullTagReference] = callback;
return Task.CompletedTask;
}
public Task UnsubscribeAsync(string fullTagReference)
{
_subscriptions.TryRemove(fullTagReference, out _);
return Task.CompletedTask;
}
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
{
if (TagValues.TryGetValue(fullTagReference, out var vtq))
return Task.FromResult(vtq);
return Task.FromResult(Vtq.Bad(Quality.BadNotConnected));
}
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
WrittenValues.Add((fullTagReference, value));
return Task.FromResult(WriteResult);
}
public void SimulateDataChange(string address, Vtq vtq)
{
OnTagValueChanged?.Invoke(address, vtq);
if (_subscriptions.TryGetValue(address, out var callback))
callback(address, vtq);
}
public void RaiseConnectionStateChanged(ConnectionState prev, ConnectionState curr)
{
State = curr;
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(prev, curr));
}
public void Dispose() { }
}
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using ArchestrA.MxAccess;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
/// <summary>
/// Fake IMxProxy for testing without the MxAccess COM runtime.
/// Simulates connections, subscriptions, data changes, and writes.
/// </summary>
public class FakeMxProxy : IMxProxy
{
private int _nextHandle = 1;
private int _connectionHandle;
private bool _registered;
public event MxDataChangeHandler? OnDataChange;
public event MxWriteCompleteHandler? OnWriteComplete;
public ConcurrentDictionary<int, string> Items { get; } = new ConcurrentDictionary<int, string>();
public ConcurrentDictionary<int, bool> AdvisedItems { get; } = new ConcurrentDictionary<int, bool>();
public List<(string Address, object Value)> WrittenValues { get; } = new List<(string, object)>();
public bool IsRegistered => _registered;
public int RegisterCallCount { get; private set; }
public int UnregisterCallCount { get; private set; }
public bool ShouldFailRegister { get; set; }
public bool ShouldFailWrite { get; set; }
public int WriteCompleteStatus { get; set; } = 0; // 0 = success
public int Register(string clientName)
{
RegisterCallCount++;
if (ShouldFailRegister) throw new InvalidOperationException("Register failed (simulated)");
_registered = true;
_connectionHandle = Interlocked.Increment(ref _nextHandle);
return _connectionHandle;
}
public void Unregister(int handle)
{
UnregisterCallCount++;
_registered = false;
_connectionHandle = 0;
}
public int AddItem(int handle, string address)
{
var itemHandle = Interlocked.Increment(ref _nextHandle);
Items[itemHandle] = address;
return itemHandle;
}
public void RemoveItem(int handle, int itemHandle)
{
Items.TryRemove(itemHandle, out _);
}
public void AdviseSupervisory(int handle, int itemHandle)
{
AdvisedItems[itemHandle] = true;
}
public void UnAdviseSupervisory(int handle, int itemHandle)
{
AdvisedItems.TryRemove(itemHandle, out _);
}
public void Write(int handle, int itemHandle, object value, int securityClassification)
{
if (ShouldFailWrite) throw new InvalidOperationException("Write failed (simulated)");
if (Items.TryGetValue(itemHandle, out var address))
WrittenValues.Add((address, value));
// Simulate async write complete callback
var status = new MXSTATUS_PROXY[1];
if (WriteCompleteStatus == 0)
{
status[0].success = 1;
}
else
{
status[0].success = 0;
status[0].detail = (short)WriteCompleteStatus;
}
OnWriteComplete?.Invoke(_connectionHandle, itemHandle, ref status);
}
/// <summary>
/// Simulates an MXAccess data change event for a specific item handle.
/// </summary>
public void SimulateDataChange(int itemHandle, object value, int quality = 192, DateTime? timestamp = null)
{
var status = new MXSTATUS_PROXY[1];
status[0].success = 1;
OnDataChange?.Invoke(_connectionHandle, itemHandle, value, quality,
(object)(timestamp ?? DateTime.UtcNow), ref status);
}
/// <summary>
/// Simulates data change for a specific address (finds handle by address).
/// </summary>
public void SimulateDataChangeByAddress(string address, object value, int quality = 192, DateTime? timestamp = null)
{
foreach (var kvp in Items)
{
if (string.Equals(kvp.Value, address, StringComparison.OrdinalIgnoreCase))
{
SimulateDataChange(kvp.Key, value, quality, timestamp);
return;
}
}
}
}
}