Compare commits
18 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eea31dcc4e | ||
| 8a692d4ba8 | |||
|
|
268b12edec | ||
| edce1be742 | |||
|
|
18b3e24710 | ||
| f6a12dafe9 | |||
|
|
058c3dddd3 | ||
| 52791952dd | |||
|
|
860deb8e0d | ||
| f5e7173de3 | |||
|
|
22d3b0d23c | ||
| 55696a8750 | |||
|
|
dd3a449308 | ||
| 3c1dc334f9 | |||
|
|
46834a43bd | ||
| 7683b94287 | |||
|
|
f53c39a598 | ||
| d569c39f30 |
@@ -8,8 +8,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
@@ -24,9 +23,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
106
docs/v2/lmx-followups.md
Normal file
106
docs/v2/lmx-followups.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# LMX Galaxy bridge — remaining follow-ups
|
||||
|
||||
State after PR 19: the Galaxy driver is functionally at v1 parity through the
|
||||
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
|
||||
Basic256Sha256 endpoints and alarms are observable through
|
||||
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
|
||||
specific before the stack can fully replace the v1 deployment, in
|
||||
rough priority order.
|
||||
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
|
||||
|
||||
**Status**: Host-side IPC shipped (PR 10 + PR 11). Proxy consumer not written.
|
||||
|
||||
PR 10 added `HistoryReadAtTimeRequest/Response` on the IPC wire and
|
||||
`MxAccessGalaxyBackend.HistoryReadAtTimeAsync` delegates to
|
||||
`HistorianDataSource.ReadAtTimeAsync`. PR 11 did the same for events
|
||||
(`HistoryReadEventsRequest/Response` + `GalaxyHistoricalEvent`). The Proxy
|
||||
side (`GalaxyProxyDriver`) doesn't call those yet — `Core.Abstractions.IHistoryProvider`
|
||||
only exposes `ReadRawAsync` + `ReadProcessedAsync`.
|
||||
|
||||
**To do**:
|
||||
- Extend `IHistoryProvider` with `ReadAtTimeAsync(string, DateTime[], …)` and
|
||||
`ReadEventsAsync(string?, DateTime, DateTime, int, …)`.
|
||||
- `GalaxyProxyDriver` calls the new IPC message kinds.
|
||||
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
|
||||
`AtTime` + `Events` service handlers.
|
||||
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||
|
||||
## 2. Write-gating by role
|
||||
|
||||
**Status**: `RoleBasedIdentity.Roles` populated on the session (PR 19) but
|
||||
`DriverNodeManager.OnWriteValue` doesn't consult it.
|
||||
|
||||
CLAUDE.md defines the role set: `ReadOnly` / `WriteOperate` / `WriteTune` /
|
||||
`WriteConfigure` / `AlarmAck`. Each `DriverAttributeInfo.SecurityClassification`
|
||||
maps to a required role for writes.
|
||||
|
||||
**To do**:
|
||||
- Add a `RoleRequirements` table: `SecurityClassification` → required role.
|
||||
- `OnWriteValue` reads `context.UserIdentity` → cast to `RoleBasedIdentity`
|
||||
→ check role membership before calling `IWritable.WriteAsync`. Return
|
||||
`BadUserAccessDenied` on miss.
|
||||
- Unit test against a fake `ISystemContext` with varying role sets.
|
||||
|
||||
## 3. Admin UI client-cert trust management
|
||||
|
||||
**Status**: Server side auto-accepts untrusted client certs when the
|
||||
`AutoAcceptUntrustedClientCertificates` option is true (dev default).
|
||||
Production deployments want operator-controlled trust via the Admin UI.
|
||||
|
||||
**To do**:
|
||||
- Surface the server's rejected-certificate store in the Admin UI.
|
||||
- Page to move certs between `rejected` / `trusted`.
|
||||
- Flip `AutoAcceptUntrustedClientCertificates` to false once Admin UI is the
|
||||
trust gate.
|
||||
|
||||
## 4. Live-LDAP integration test
|
||||
|
||||
**Status**: PR 19 unit-tested the auth-flow shape; the live bind path is
|
||||
exercised only by the pre-existing `Admin.Tests/LdapLiveBindTests.cs` which
|
||||
uses the same Novell library against a running GLAuth at `localhost:3893`.
|
||||
|
||||
**To do**:
|
||||
- Add `OpcUaServerIntegrationTests.Valid_username_authenticates_against_live_ldap`
|
||||
with the same skip-when-unreachable guard.
|
||||
- Assert `session.Identity` on the server side carries the expected role
|
||||
after bind — requires exposing a test hook or reading identity from a
|
||||
new `IHostConnectivityProbe`-style "whoami" variable in the address space.
|
||||
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
||||
|
||||
**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13
|
||||
probe manager, PR 14 alarm tracker), but the full loop — OPC UA client →
|
||||
`OtOpcUaServer` → `GalaxyProxyDriver` (in-process) → named-pipe to
|
||||
Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has
|
||||
no single end-to-end smoke test.
|
||||
|
||||
**To do**:
|
||||
- Test that spawns the full topology, discovers a deployed Galaxy object,
|
||||
subscribes to one of its attributes, writes a value back, and asserts the
|
||||
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
||||
|
||||
## 6. Second driver instance on the same server
|
||||
|
||||
**Status**: `DriverHost.RegisterAsync` supports multiple drivers; the OPC UA
|
||||
server creates one `DriverNodeManager` per driver and isolates their
|
||||
subtrees under distinct namespace URIs. Not proven with two active
|
||||
`GalaxyProxyDriver` instances pointing at different Galaxies.
|
||||
|
||||
**To do**:
|
||||
- Integration test that registers two driver instances, each with a distinct
|
||||
`DriverInstanceId` + endpoint in its own session, asserts nodes from both
|
||||
appear under the correct subtrees, alarm events land on the correct
|
||||
instance's condition nodes.
|
||||
|
||||
## 7. Host-status per-AppEngine granularity → Admin UI dashboard
|
||||
|
||||
**Status**: PR 13 ships per-platform/per-AppEngine `ScanState` probing; PR 17
|
||||
surfaces the resulting `OnHostStatusChanged` events through OPC UA. Admin
|
||||
UI doesn't render a per-host dashboard yet.
|
||||
|
||||
**To do**:
|
||||
- SignalR hub push of `HostStatusChangedEventArgs` to the Admin UI.
|
||||
- Dashboard page showing each tracked host, current state, last transition
|
||||
time, failure count.
|
||||
@@ -24,6 +24,17 @@ public sealed class DriverHost : IAsyncDisposable
|
||||
return _drivers.TryGetValue(driverInstanceId, out var d) ? d.GetHealth() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up a registered driver by instance id. Used by the OPC UA server runtime
|
||||
/// (<c>OtOpcUaServer</c>) to instantiate one <c>DriverNodeManager</c> per driver at
|
||||
/// startup. Returns null when the driver is not registered.
|
||||
/// </summary>
|
||||
public IDriver? GetDriver(string driverInstanceId)
|
||||
{
|
||||
lock (_lock)
|
||||
return _drivers.TryGetValue(driverInstanceId, out var d) ? d : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the driver and calls <see cref="IDriver.InitializeAsync"/>. If initialization
|
||||
/// throws, the driver is kept in the registry so the operator can retry; quality on its
|
||||
|
||||
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the Modbus TCP socket. Takes a <c>PDU</c> (function code + data, excluding
|
||||
/// the 7-byte MBAP header) and returns the response PDU — the transport owns transaction-id
|
||||
/// pairing, framing, and socket I/O. Tests supply in-memory fakes.
|
||||
/// </summary>
|
||||
public interface IModbusTransport : IAsyncDisposable
|
||||
{
|
||||
Task ConnectAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Send a Modbus PDU (function code + function-specific data) and read the response PDU.
|
||||
/// Throws <see cref="ModbusException"/> when the server returns an exception PDU
|
||||
/// (function code + 0x80 + exception code).
|
||||
/// </summary>
|
||||
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ModbusException(byte functionCode, byte exceptionCode, string message)
|
||||
: Exception(message)
|
||||
{
|
||||
public byte FunctionCode { get; } = functionCode;
|
||||
public byte ExceptionCode { get; } = exceptionCode;
|
||||
}
|
||||
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
@@ -0,0 +1,583 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
|
||||
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
|
||||
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
|
||||
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model;
|
||||
/// subscriptions would need a polling loop over the declared tags — additive PR). Historian
|
||||
/// + alarm capabilities are out of scope (the protocol doesn't express them).
|
||||
/// </remarks>
|
||||
public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// Active polling subscriptions. Each subscription owns a background Task that polls the
|
||||
// tags at its configured interval, diffs against _lastKnownValues, and fires OnDataChange
|
||||
// per changed tag. UnsubscribeAsync cancels the task via the CTS stored on the handle.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
|
||||
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
|
||||
// display the PLC endpoint uniformly with Galaxy platforms/engines.
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _probeCts;
|
||||
private readonly ModbusDriverOptions _options = options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
|
||||
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
|
||||
|
||||
private IModbusTransport? _transport;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
_transport = _transportFactory(_options);
|
||||
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
// PR 23: kick off the probe loop once the transport is up. Initial state stays
|
||||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||
// Running transition before any register round-trip has happened.
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
_probeCts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try { _probeCts?.Cancel(); } catch { }
|
||||
_probeCts?.Dispose();
|
||||
_probeCts = null;
|
||||
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
_transport = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder("Modbus", "Modbus");
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
FullName: t.Name,
|
||||
DriverDataType: MapDataType(t.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var transport = RequireTransport();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
{
|
||||
var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return (resp[2] & 0x01) == 1;
|
||||
}
|
||||
case ModbusRegion.DiscreteInputs:
|
||||
{
|
||||
var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return (resp[2] & 0x01) == 1;
|
||||
}
|
||||
case ModbusRegion.HoldingRegisters:
|
||||
case ModbusRegion.InputRegisters:
|
||||
{
|
||||
var quantity = RegisterCount(tag);
|
||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||
return DecodeRegister(data, tag);
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var transport = RequireTransport();
|
||||
var results = new WriteResult[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotWritable);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadInternalError);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
{
|
||||
var on = Convert.ToBoolean(value);
|
||||
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
on ? (byte)0xFF : (byte)0x00, 0x00 };
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
case ModbusRegion.HoldingRegisters:
|
||||
{
|
||||
var bytes = EncodeRegister(value, tag);
|
||||
if (bytes.Length == 2)
|
||||
{
|
||||
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
bytes[0], bytes[1] };
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||
var qty = (ushort)(bytes.Length / 2);
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
||||
pdu[5] = (byte)bytes.Length;
|
||||
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var cts = new CancellationTokenSource();
|
||||
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
|
||||
? TimeSpan.FromMilliseconds(100) // floor — Modbus can't sustain < 100ms polling reliably
|
||||
: publishingInterval;
|
||||
var handle = new ModbusSubscriptionHandle(id);
|
||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||
_subscriptions[id] = state;
|
||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is ModbusSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
{
|
||||
state.Cts.Cancel();
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
// Initial-data push: read every tag once at subscribe time so OPC UA clients see the
|
||||
// current value per Part 4 convention, even if the value never changes thereafter.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error — polling continues */ }
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient polling error — loop continues, health surface reflects it */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
var current = snapshots[i];
|
||||
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||
|
||||
// Raise on first read (forceRaise) OR when the boxed value differs from last-known.
|
||||
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(
|
||||
ModbusSubscriptionHandle Handle,
|
||||
IReadOnlyList<string> TagReferences,
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record ModbusSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"modbus-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
|
||||
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
|
||||
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
|
||||
/// </summary>
|
||||
public string HostName => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
private async Task ProbeLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var transport = _transport; // captured reference; disposal tears the loop down via ct
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(_options.Probe.Timeout);
|
||||
var pdu = new byte[] { 0x03,
|
||||
(byte)(_options.Probe.ProbeAddress >> 8),
|
||||
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
|
||||
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
|
||||
success = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// transport / timeout / exception PDU — treated as Stopped below
|
||||
}
|
||||
|
||||
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
// ---- codec ----
|
||||
|
||||
/// <summary>
|
||||
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
|
||||
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
|
||||
/// from 2 chars per register).
|
||||
/// </summary>
|
||||
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
||||
{
|
||||
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
|
||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
||||
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
|
||||
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
|
||||
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Word-swap the input into the big-endian layout the decoders expect. For 2-register
|
||||
/// types this reverses the two words; for 4-register types it reverses the four words
|
||||
/// (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]).
|
||||
/// </summary>
|
||||
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
|
||||
{
|
||||
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
|
||||
var result = new byte[data.Length];
|
||||
for (var word = 0; word < data.Length / 2; word++)
|
||||
{
|
||||
var srcWord = data.Length / 2 - 1 - word;
|
||||
result[word * 2] = data[srcWord * 2];
|
||||
result[word * 2 + 1] = data[srcWord * 2 + 1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
|
||||
{
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
|
||||
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
case ModbusDataType.BitInRegister:
|
||||
{
|
||||
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
return (raw & (1 << tag.BitIndex)) != 0;
|
||||
}
|
||||
case ModbusDataType.Int32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadInt32BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.UInt32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadUInt32BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.Float32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadSingleBigEndian(b);
|
||||
}
|
||||
case ModbusDataType.Int64:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadInt64BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.UInt64:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadUInt64BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.Float64:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadDoubleBigEndian(b);
|
||||
}
|
||||
case ModbusDataType.String:
|
||||
{
|
||||
// ASCII, 2 chars per register, packed high byte = first char.
|
||||
// Respect the caller's StringLength (truncate nul-padded regions).
|
||||
var chars = new char[tag.StringLength];
|
||||
for (var i = 0; i < tag.StringLength; i++)
|
||||
{
|
||||
var b = data[i];
|
||||
if (b == 0) { return new string(chars, 0, i); }
|
||||
chars[i] = (char)b;
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
|
||||
{
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case ModbusDataType.Int16:
|
||||
{
|
||||
var v = Convert.ToInt16(value);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
|
||||
}
|
||||
case ModbusDataType.UInt16:
|
||||
{
|
||||
var v = Convert.ToUInt16(value);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
||||
}
|
||||
case ModbusDataType.Int32:
|
||||
{
|
||||
var v = Convert.ToInt32(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.UInt32:
|
||||
{
|
||||
var v = Convert.ToUInt32(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Float32:
|
||||
{
|
||||
var v = Convert.ToSingle(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Int64:
|
||||
{
|
||||
var v = Convert.ToInt64(value);
|
||||
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.UInt64:
|
||||
{
|
||||
var v = Convert.ToUInt64(value);
|
||||
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Float64:
|
||||
{
|
||||
var v = Convert.ToDouble(value);
|
||||
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.String:
|
||||
{
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
var regs = (tag.StringLength + 1) / 2;
|
||||
var b = new byte[regs * 2];
|
||||
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
|
||||
// remaining bytes stay 0 — nul-padded per PLC convention
|
||||
return b;
|
||||
}
|
||||
case ModbusDataType.BitInRegister:
|
||||
throw new InvalidOperationException(
|
||||
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
|
||||
default:
|
||||
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
||||
{
|
||||
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
|
||||
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
|
||||
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
|
||||
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
|
||||
ModbusDataType.Float32 => DriverDataType.Float32,
|
||||
ModbusDataType.Float64 => DriverDataType.Float64,
|
||||
ModbusDataType.String => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
private IModbusTransport RequireTransport() =>
|
||||
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
|
||||
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
_transport = null;
|
||||
}
|
||||
}
|
||||
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
|
||||
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
|
||||
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverOptions
|
||||
{
|
||||
public string Host { get; init; } = "127.0.0.1";
|
||||
public int Port { get; init; } = 502;
|
||||
public byte UnitId { get; init; } = 1;
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
|
||||
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
|
||||
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
|
||||
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
|
||||
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
|
||||
/// <see cref="IHostConnectivityProbe"/>.
|
||||
/// </summary>
|
||||
public ModbusProbeOptions Probe { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ModbusProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
/// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
|
||||
public ushort ProbeAddress { get; init; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
|
||||
/// the documentation's 1-based coil/register conventions). Multi-register types
|
||||
/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
|
||||
/// <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
|
||||
/// </summary>
|
||||
/// <param name="Name">
|
||||
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
|
||||
/// unique within the driver.
|
||||
/// </param>
|
||||
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
|
||||
/// <param name="Address">Zero-based address within the region.</param>
|
||||
/// <param name="DataType">
|
||||
/// Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
|
||||
/// </param>
|
||||
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
|
||||
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
|
||||
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
|
||||
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
|
||||
public sealed record ModbusTagDefinition(
|
||||
string Name,
|
||||
ModbusRegion Region,
|
||||
ushort Address,
|
||||
ModbusDataType DataType,
|
||||
bool Writable = true,
|
||||
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||
byte BitIndex = 0,
|
||||
ushort StringLength = 0);
|
||||
|
||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||
|
||||
public enum ModbusDataType
|
||||
{
|
||||
Bool,
|
||||
Int16,
|
||||
UInt16,
|
||||
Int32,
|
||||
UInt32,
|
||||
Int64,
|
||||
UInt64,
|
||||
Float32,
|
||||
Float64,
|
||||
/// <summary>Single bit within a holding register. <see cref="ModbusTagDefinition.BitIndex"/> selects 0-15 LSB-first.</summary>
|
||||
BitInRegister,
|
||||
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
|
||||
String,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Word ordering for multi-register types. Modbus TCP standard is <see cref="BigEndian"/>
|
||||
/// (ABCD for 32-bit: high word at the lower address). Many PLCs — Siemens S7, several
|
||||
/// Allen-Bradley series, some Modicon families — use <see cref="WordSwap"/> (CDAB), which
|
||||
/// keeps bytes big-endian within each register but reverses the word pair(s).
|
||||
/// </summary>
|
||||
public enum ModbusByteOrder
|
||||
{
|
||||
BigEndian,
|
||||
WordSwap,
|
||||
}
|
||||
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
||||
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||
/// </summary>
|
||||
public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
|
||||
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
_client = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
var adu = new byte[7 + pdu.Length];
|
||||
adu[0] = (byte)(txId >> 8);
|
||||
adu[1] = (byte)(txId & 0xFF);
|
||||
// protocol id already zero
|
||||
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||
adu[4] = (byte)(len >> 8);
|
||||
adu[5] = (byte)(len & 0xFF);
|
||||
adu[6] = unitId;
|
||||
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
|
||||
var respLen = (ushort)((header[4] << 8) | header[5]);
|
||||
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Exception PDU: function code has high bit set.
|
||||
if ((respPdu[0] & 0x80) != 0)
|
||||
{
|
||||
var fc = (byte)(respPdu[0] & 0x7F);
|
||||
var ex = respPdu[1];
|
||||
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||
}
|
||||
|
||||
return respPdu;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||
if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response");
|
||||
read += n;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,15 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
||||
{
|
||||
/// <summary>
|
||||
/// Reflection entry point invoked by <c>HistorianPluginLoader</c> in the Host. Kept
|
||||
/// deliberately simple so the plugin contract is a single static factory method.
|
||||
/// </summary>
|
||||
public static class AvevaHistorianPluginEntry
|
||||
{
|
||||
public static IHistorianDataSource Create(HistorianConfiguration config)
|
||||
=> new HistorianDataSource(config);
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which
|
||||
/// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands
|
||||
/// out an ordered list of eligible candidates for the data source to try in sequence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Design notes:
|
||||
/// <list type="bullet">
|
||||
/// <item>No SDK dependency — fully unit-testable with an injected clock.</item>
|
||||
/// <item>Per-node state is guarded by a single lock; operations are microsecond-scale
|
||||
/// so contention is a non-issue.</item>
|
||||
/// <item>Cooldown is purely passive: a node re-enters the healthy pool the next time
|
||||
/// it is queried after its cooldown window elapses. There is no background probe.</item>
|
||||
/// <item>Nodes are returned in configuration order so operators can express a
|
||||
/// preference (primary first, fallback second).</item>
|
||||
/// <item>When <see cref="HistorianConfiguration.ServerNames"/> is empty, the picker is
|
||||
/// initialized with a single entry from <see cref="HistorianConfiguration.ServerName"/>
|
||||
/// so legacy deployments continue to work unchanged.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal sealed class HistorianClusterEndpointPicker
|
||||
{
|
||||
private readonly Func<DateTime> _clock;
|
||||
private readonly TimeSpan _cooldown;
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<NodeEntry> _nodes;
|
||||
|
||||
public HistorianClusterEndpointPicker(HistorianConfiguration config)
|
||||
: this(config, () => DateTime.UtcNow) { }
|
||||
|
||||
internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func<DateTime> clock)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds));
|
||||
|
||||
var names = (config.ServerNames != null && config.ServerNames.Count > 0)
|
||||
? config.ServerNames
|
||||
: new List<string> { config.ServerName };
|
||||
|
||||
_nodes = names
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.Select(n => n.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(n => new NodeEntry { Name = n })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of configured cluster nodes. Stable — nodes are never added
|
||||
/// or removed after construction.
|
||||
/// </summary>
|
||||
public int NodeCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
return _nodes.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an ordered snapshot of nodes currently eligible for a connection attempt,
|
||||
/// with any node whose cooldown has elapsed automatically restored to the pool.
|
||||
/// An empty list means all nodes are in active cooldown.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetHealthyNodes()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
return _nodes
|
||||
.Where(n => IsHealthyAt(n, now))
|
||||
.Select(n => n.Name)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of nodes currently eligible for a connection attempt (i.e., not in cooldown).
|
||||
/// </summary>
|
||||
public int HealthyNodeCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
return _nodes.Count(n => IsHealthyAt(n, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places <paramref name="node"/> into cooldown starting at the current clock time.
|
||||
/// Increments the node's failure counter and stores the latest error message for
|
||||
/// surfacing on the dashboard. Unknown node names are ignored.
|
||||
/// </summary>
|
||||
public void MarkFailed(string node, string? error)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = FindEntry(node);
|
||||
if (entry == null)
|
||||
return;
|
||||
|
||||
var now = _clock();
|
||||
entry.FailureCount++;
|
||||
entry.LastError = error;
|
||||
entry.LastFailureTime = now;
|
||||
entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks <paramref name="node"/> as healthy immediately — clears any active cooldown but
|
||||
/// leaves the cumulative failure counter intact for operator diagnostics. Unknown node
|
||||
/// names are ignored.
|
||||
/// </summary>
|
||||
public void MarkHealthy(string node)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = FindEntry(node);
|
||||
if (entry == null)
|
||||
return;
|
||||
entry.CooldownUntil = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current per-node state for the health dashboard. Freshly computed from
|
||||
/// <see cref="_clock"/> so recently-expired cooldowns are reported as healthy.
|
||||
/// </summary>
|
||||
public List<HistorianClusterNodeState> SnapshotNodeStates()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
return _nodes.Select(n => new HistorianClusterNodeState
|
||||
{
|
||||
Name = n.Name,
|
||||
IsHealthy = IsHealthyAt(n, now),
|
||||
CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil,
|
||||
FailureCount = n.FailureCount,
|
||||
LastError = n.LastError,
|
||||
LastFailureTime = n.LastFailureTime
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHealthyAt(NodeEntry entry, DateTime now)
|
||||
{
|
||||
return entry.CooldownUntil == null || entry.CooldownUntil <= now;
|
||||
}
|
||||
|
||||
private NodeEntry? FindEntry(string node)
|
||||
{
|
||||
for (var i = 0; i < _nodes.Count; i++)
|
||||
if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase))
|
||||
return _nodes[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class NodeEntry
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public DateTime? CooldownUntil { get; set; }
|
||||
public int FailureCount { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StringCollection = System.Collections.Specialized.StringCollection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA;
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
|
||||
/// </summary>
|
||||
public sealed class HistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<HistorianDataSource>();
|
||||
|
||||
private readonly HistorianConfiguration _config;
|
||||
private readonly object _connectionLock = new object();
|
||||
private readonly object _eventConnectionLock = new object();
|
||||
private readonly IHistorianConnectionFactory _factory;
|
||||
private HistorianAccess? _connection;
|
||||
private HistorianAccess? _eventConnection;
|
||||
private bool _disposed;
|
||||
|
||||
// Runtime query health state. Guarded by _healthLock — updated on every read
|
||||
// method exit (success or failure) so the dashboard can distinguish "plugin
|
||||
// loaded but never queried" from "plugin loaded and queries are failing".
|
||||
private readonly object _healthLock = new object();
|
||||
private long _totalSuccesses;
|
||||
private long _totalFailures;
|
||||
private int _consecutiveFailures;
|
||||
private DateTime? _lastSuccessTime;
|
||||
private DateTime? _lastFailureTime;
|
||||
private string? _lastError;
|
||||
private string? _activeProcessNode;
|
||||
private string? _activeEventNode;
|
||||
|
||||
// Cluster endpoint picker — shared across process + event paths so a node that
|
||||
// fails on one silo is skipped on the other. Initialized from config at construction.
|
||||
private readonly HistorianClusterEndpointPicker _picker;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian SDK queries.
|
||||
/// </summary>
|
||||
/// <param name="config">The Historian SDK connection settings used for runtime history lookups.</param>
|
||||
public HistorianDataSource(HistorianConfiguration config)
|
||||
: this(config, new SdkHistorianConnectionFactory(), null) { }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a Historian reader with a custom connection factory for testing. When
|
||||
/// <paramref name="picker"/> is <see langword="null"/> a new picker is built from
|
||||
/// <paramref name="config"/>, preserving backward compatibility with existing tests.
|
||||
/// </summary>
|
||||
internal HistorianDataSource(
|
||||
HistorianConfiguration config,
|
||||
IHistorianConnectionFactory factory,
|
||||
HistorianClusterEndpointPicker? picker = null)
|
||||
{
|
||||
_config = config;
|
||||
_factory = factory;
|
||||
_picker = picker ?? new HistorianClusterEndpointPicker(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates the picker's healthy node list, cloning the configuration per attempt and
|
||||
/// handing it to the factory. Marks each tried node as healthy on success or failed on
|
||||
/// exception. Returns the winning connection + node name; throws when no nodes succeed.
|
||||
/// </summary>
|
||||
private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type)
|
||||
{
|
||||
var candidates = _picker.GetHealthyNodes();
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
var total = _picker.NodeCount;
|
||||
throw new InvalidOperationException(
|
||||
total == 0
|
||||
? "No historian nodes configured"
|
||||
: $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to");
|
||||
}
|
||||
|
||||
Exception? lastException = null;
|
||||
foreach (var node in candidates)
|
||||
{
|
||||
var attemptConfig = CloneConfigWithServerName(node);
|
||||
try
|
||||
{
|
||||
var conn = _factory.CreateAndConnect(attemptConfig, type);
|
||||
_picker.MarkHealthy(node);
|
||||
return (conn, node);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_picker.MarkFailed(node, ex.Message);
|
||||
lastException = ex;
|
||||
Log.Warning(ex,
|
||||
"Historian node {Node} failed during connect attempt; trying next candidate", node);
|
||||
}
|
||||
}
|
||||
|
||||
var inner = lastException?.Message ?? "(no detail)";
|
||||
throw new InvalidOperationException(
|
||||
$"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}",
|
||||
lastException);
|
||||
}
|
||||
|
||||
private HistorianConfiguration CloneConfigWithServerName(string serverName)
|
||||
{
|
||||
return new HistorianConfiguration
|
||||
{
|
||||
Enabled = _config.Enabled,
|
||||
ServerName = serverName,
|
||||
ServerNames = _config.ServerNames,
|
||||
FailureCooldownSeconds = _config.FailureCooldownSeconds,
|
||||
IntegratedSecurity = _config.IntegratedSecurity,
|
||||
UserName = _config.UserName,
|
||||
Password = _config.Password,
|
||||
Port = _config.Port,
|
||||
CommandTimeoutSeconds = _config.CommandTimeoutSeconds,
|
||||
MaxValuesPerRead = _config.MaxValuesPerRead
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
{
|
||||
var nodeStates = _picker.SnapshotNodeStates();
|
||||
var healthyCount = 0;
|
||||
foreach (var n in nodeStates)
|
||||
if (n.IsHealthy)
|
||||
healthyCount++;
|
||||
|
||||
lock (_healthLock)
|
||||
{
|
||||
return new HistorianHealthSnapshot
|
||||
{
|
||||
TotalQueries = _totalSuccesses + _totalFailures,
|
||||
TotalSuccesses = _totalSuccesses,
|
||||
TotalFailures = _totalFailures,
|
||||
ConsecutiveFailures = _consecutiveFailures,
|
||||
LastSuccessTime = _lastSuccessTime,
|
||||
LastFailureTime = _lastFailureTime,
|
||||
LastError = _lastError,
|
||||
ProcessConnectionOpen = Volatile.Read(ref _connection) != null,
|
||||
EventConnectionOpen = Volatile.Read(ref _eventConnection) != null,
|
||||
ActiveProcessNode = _activeProcessNode,
|
||||
ActiveEventNode = _activeEventNode,
|
||||
NodeCount = nodeStates.Count,
|
||||
HealthyNodeCount = healthyCount,
|
||||
Nodes = nodeStates
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSuccess()
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
_totalSuccesses++;
|
||||
_lastSuccessTime = DateTime.UtcNow;
|
||||
_consecutiveFailures = 0;
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFailure(string error)
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
_totalFailures++;
|
||||
_lastFailureTime = DateTime.UtcNow;
|
||||
_consecutiveFailures++;
|
||||
_lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
|
||||
// Fast path: already connected (no lock needed)
|
||||
if (Volatile.Read(ref _connection) != null)
|
||||
return;
|
||||
|
||||
// Create and wait for connection outside the lock so concurrent history
|
||||
// requests are not serialized behind a slow Historian handshake. The cluster
|
||||
// picker iterates configured nodes and returns the first that successfully connects.
|
||||
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process);
|
||||
|
||||
lock (_connectionLock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
}
|
||||
|
||||
if (_connection != null)
|
||||
{
|
||||
// Another thread connected while we were waiting
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
_connection = conn;
|
||||
lock (_healthLock)
|
||||
_activeProcessNode = winningNode;
|
||||
Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConnectionError(Exception? ex = null)
|
||||
{
|
||||
lock (_connectionLock)
|
||||
{
|
||||
if (_connection == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_connection.CloseConnection(out _);
|
||||
_connection.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery");
|
||||
}
|
||||
|
||||
_connection = null;
|
||||
string? failedNode;
|
||||
lock (_healthLock)
|
||||
{
|
||||
failedNode = _activeProcessNode;
|
||||
_activeProcessNode = null;
|
||||
}
|
||||
|
||||
if (failedNode != null)
|
||||
_picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
|
||||
Log.Warning(ex, "Historian SDK connection reset (node={Node}) — will reconnect on next request",
|
||||
failedNode ?? "(unknown)");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureEventConnected()
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
|
||||
if (Volatile.Read(ref _eventConnection) != null)
|
||||
return;
|
||||
|
||||
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event);
|
||||
|
||||
lock (_eventConnectionLock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
}
|
||||
|
||||
if (_eventConnection != null)
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
_eventConnection = conn;
|
||||
lock (_healthLock)
|
||||
_activeEventNode = winningNode;
|
||||
Log.Information("Historian SDK event connection opened to {Server}:{Port}",
|
||||
winningNode, _config.Port);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEventConnectionError(Exception? ex = null)
|
||||
{
|
||||
lock (_eventConnectionLock)
|
||||
{
|
||||
if (_eventConnection == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_eventConnection.CloseConnection(out _);
|
||||
_eventConnection.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery");
|
||||
}
|
||||
|
||||
_eventConnection = null;
|
||||
string? failedNode;
|
||||
lock (_healthLock)
|
||||
{
|
||||
failedNode = _activeEventNode;
|
||||
_activeEventNode = null;
|
||||
}
|
||||
|
||||
if (failedNode != null)
|
||||
_picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
|
||||
Log.Warning(ex, "Historian SDK event connection reset (node={Node}) — will reconnect on next request",
|
||||
failedNode ?? "(unknown)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<DataValue>> ReadRawAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<DataValue>();
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
using var query = _connection!.CreateHistoryQuery();
|
||||
var args = new HistoryQueryArgs
|
||||
{
|
||||
TagNames = new StringCollection { tagName },
|
||||
StartDateTime = startTime,
|
||||
EndDateTime = endTime,
|
||||
RetrievalMode = HistorianRetrievalMode.Full
|
||||
};
|
||||
|
||||
if (maxValues > 0)
|
||||
args.BatchSize = (uint)maxValues;
|
||||
else if (_config.MaxValuesPerRead > 0)
|
||||
args.BatchSize = (uint)_config.MaxValuesPerRead;
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
Log.Warning("Historian SDK raw query start failed for {Tag}: {Error}", tagName, error.ErrorCode);
|
||||
RecordFailure($"raw StartQuery: {error.ErrorCode}");
|
||||
HandleConnectionError();
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead;
|
||||
|
||||
while (query.MoveNext(out error))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = query.QueryResult;
|
||||
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
||||
|
||||
object? value;
|
||||
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
|
||||
value = result.StringValue;
|
||||
else
|
||||
value = result.Value;
|
||||
|
||||
var quality = (byte)(result.OpcQuality & 0xFF);
|
||||
|
||||
results.Add(new DataValue
|
||||
{
|
||||
Value = new Variant(value),
|
||||
SourceTimestamp = timestamp,
|
||||
ServerTimestamp = timestamp,
|
||||
StatusCode = QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality))
|
||||
});
|
||||
|
||||
count++;
|
||||
if (limit > 0 && count >= limit)
|
||||
break;
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName);
|
||||
RecordFailure($"raw: {ex.Message}");
|
||||
HandleConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})",
|
||||
tagName, results.Count, startTime, endTime);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<DataValue>> ReadAggregateAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime,
|
||||
double intervalMs, string aggregateColumn,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<DataValue>();
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
using var query = _connection!.CreateAnalogSummaryQuery();
|
||||
var args = new AnalogSummaryQueryArgs
|
||||
{
|
||||
TagNames = new StringCollection { tagName },
|
||||
StartDateTime = startTime,
|
||||
EndDateTime = endTime,
|
||||
Resolution = (ulong)intervalMs
|
||||
};
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
Log.Warning("Historian SDK aggregate query start failed for {Tag}: {Error}", tagName,
|
||||
error.ErrorCode);
|
||||
RecordFailure($"aggregate StartQuery: {error.ErrorCode}");
|
||||
HandleConnectionError();
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
while (query.MoveNext(out error))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = query.QueryResult;
|
||||
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
||||
var value = ExtractAggregateValue(result, aggregateColumn);
|
||||
|
||||
results.Add(new DataValue
|
||||
{
|
||||
Value = new Variant(value),
|
||||
SourceTimestamp = timestamp,
|
||||
ServerTimestamp = timestamp,
|
||||
StatusCode = value != null ? StatusCodes.Good : StatusCodes.BadNoData
|
||||
});
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName);
|
||||
RecordFailure($"aggregate: {ex.Message}");
|
||||
HandleConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values",
|
||||
aggregateColumn, tagName, results.Count);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<DataValue>> ReadAtTimeAsync(
|
||||
string tagName, DateTime[] timestamps,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<DataValue>();
|
||||
|
||||
if (timestamps == null || timestamps.Length == 0)
|
||||
return Task.FromResult(results);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
foreach (var timestamp in timestamps)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
using var query = _connection!.CreateHistoryQuery();
|
||||
var args = new HistoryQueryArgs
|
||||
{
|
||||
TagNames = new StringCollection { tagName },
|
||||
StartDateTime = timestamp,
|
||||
EndDateTime = timestamp,
|
||||
RetrievalMode = HistorianRetrievalMode.Interpolated,
|
||||
BatchSize = 1
|
||||
};
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
results.Add(new DataValue
|
||||
{
|
||||
Value = Variant.Null,
|
||||
SourceTimestamp = timestamp,
|
||||
ServerTimestamp = timestamp,
|
||||
StatusCode = StatusCodes.BadNoData
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (query.MoveNext(out error))
|
||||
{
|
||||
var result = query.QueryResult;
|
||||
object? value;
|
||||
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
|
||||
value = result.StringValue;
|
||||
else
|
||||
value = result.Value;
|
||||
|
||||
var quality = (byte)(result.OpcQuality & 0xFF);
|
||||
results.Add(new DataValue
|
||||
{
|
||||
Value = new Variant(value),
|
||||
SourceTimestamp = timestamp,
|
||||
ServerTimestamp = timestamp,
|
||||
StatusCode = QualityMapper.MapToOpcUaStatusCode(
|
||||
QualityMapper.MapFromMxAccessQuality(quality))
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new DataValue
|
||||
{
|
||||
Value = Variant.Null,
|
||||
SourceTimestamp = timestamp,
|
||||
ServerTimestamp = timestamp,
|
||||
StatusCode = StatusCodes.BadNoData
|
||||
});
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
}
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName);
|
||||
RecordFailure($"at-time: {ex.Message}");
|
||||
HandleConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps",
|
||||
tagName, results.Count, timestamps.Length);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(
|
||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<HistorianEventDto>();
|
||||
|
||||
try
|
||||
{
|
||||
EnsureEventConnected();
|
||||
|
||||
using var query = _eventConnection!.CreateEventQuery();
|
||||
var args = new EventQueryArgs
|
||||
{
|
||||
StartDateTime = startTime,
|
||||
EndDateTime = endTime,
|
||||
EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead,
|
||||
QueryType = HistorianEventQueryType.Events,
|
||||
EventOrder = HistorianEventOrder.Ascending
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sourceName))
|
||||
{
|
||||
query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _);
|
||||
}
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
Log.Warning("Historian SDK event query start failed: {Error}", error.ErrorCode);
|
||||
RecordFailure($"events StartQuery: {error.ErrorCode}");
|
||||
HandleEventConnectionError();
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
while (query.MoveNext(out error))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
results.Add(ToDto(query.QueryResult));
|
||||
count++;
|
||||
if (maxEvents > 0 && count >= maxEvents)
|
||||
break;
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)");
|
||||
RecordFailure($"events: {ex.Message}");
|
||||
HandleEventConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})",
|
||||
sourceName ?? "(all)", results.Count, startTime, endTime);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
private static HistorianEventDto ToDto(HistorianEvent evt)
|
||||
{
|
||||
return new HistorianEventDto
|
||||
{
|
||||
Id = evt.Id,
|
||||
Source = evt.Source,
|
||||
EventTime = evt.EventTime,
|
||||
ReceivedTime = evt.ReceivedTime,
|
||||
DisplayText = evt.DisplayText,
|
||||
Severity = (ushort)evt.Severity
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the requested aggregate value from an <see cref="AnalogSummaryQueryResult"/> by column name.
|
||||
/// </summary>
|
||||
internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column)
|
||||
{
|
||||
switch (column)
|
||||
{
|
||||
case "Average": return result.Average;
|
||||
case "Minimum": return result.Minimum;
|
||||
case "Maximum": return result.Maximum;
|
||||
case "ValueCount": return result.ValueCount;
|
||||
case "First": return result.First;
|
||||
case "Last": return result.Last;
|
||||
case "StdDev": return result.StdDev;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the Historian SDK connection and releases resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_connection?.CloseConnection(out _);
|
||||
_connection?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error closing Historian SDK connection");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_eventConnection?.CloseConnection(out _);
|
||||
_eventConnection?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error closing Historian SDK event connection");
|
||||
}
|
||||
|
||||
_connection = null;
|
||||
_eventConnection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using ArchestrA;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and opens Historian SDK connections. Extracted so tests can inject
|
||||
/// fakes that control connection success, failure, and timeout behavior.
|
||||
/// </summary>
|
||||
internal interface IHistorianConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new Historian SDK connection, opens it, and waits until it is ready.
|
||||
/// Throws on connection failure or timeout.
|
||||
/// </summary>
|
||||
HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production implementation that creates real Historian SDK connections.
|
||||
/// </summary>
|
||||
internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
|
||||
{
|
||||
var conn = new HistorianAccess();
|
||||
|
||||
var args = new HistorianConnectionArgs
|
||||
{
|
||||
ServerName = config.ServerName,
|
||||
TcpPort = (ushort)config.Port,
|
||||
IntegratedSecurity = config.IntegratedSecurity,
|
||||
UseArchestrAUser = config.IntegratedSecurity,
|
||||
ConnectionType = type,
|
||||
ReadOnly = true,
|
||||
PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000)
|
||||
};
|
||||
|
||||
if (!config.IntegratedSecurity)
|
||||
{
|
||||
args.UserName = config.UserName ?? string.Empty;
|
||||
args.Password = config.Password ?? string.Empty;
|
||||
}
|
||||
|
||||
if (!conn.OpenConnection(args, out var error))
|
||||
{
|
||||
conn.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}");
|
||||
}
|
||||
|
||||
// The SDK connects asynchronously — poll until the connection is ready
|
||||
var timeoutMs = config.CommandTimeoutSeconds * 1000;
|
||||
var elapsed = 0;
|
||||
while (elapsed < timeoutMs)
|
||||
{
|
||||
var status = new HistorianConnectionStatus();
|
||||
conn.GetConnectionStatus(ref status);
|
||||
|
||||
if (status.ConnectedToServer)
|
||||
return conn;
|
||||
|
||||
if (status.ErrorOccurred)
|
||||
{
|
||||
conn.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Historian SDK connection failed: {status.Error}");
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
elapsed += 250;
|
||||
}
|
||||
|
||||
conn.Dispose();
|
||||
throw new TimeoutException(
|
||||
$"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Historian.Aveva</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Historian.Aveva</AssemblyName>
|
||||
<!-- Plugin is loaded at runtime via Assembly.LoadFrom; never copy it as a CopyLocal dep. -->
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
|
||||
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. Plugs into the legacy in-process Host's
|
||||
Wonderware Historian loader. Will be ported into Driver.Galaxy.Host's
|
||||
Backend/Historian/ subtree when MxAccessGalaxyBackend.HistoryReadAsync is
|
||||
wired (Task B.1.h follow-up). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Logging -->
|
||||
<PackageReference Include="Serilog" Version="2.10.0"/>
|
||||
|
||||
<!-- OPC UA (for DataValue/StatusCodes used by the IHistorianDataSource surface) -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Private=false: the plugin binds to Host types at compile time but Host.exe must not be
|
||||
copied into the plugin's output folder (it is already in the process). -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj">
|
||||
<Private>false</Private>
|
||||
<ReferenceOutputAssembly>true</ReferenceOutputAssembly>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Wonderware Historian SDK -->
|
||||
<Reference Include="aahClientManaged">
|
||||
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="aahClientCommon">
|
||||
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Historian SDK native dependencies — copied beside the plugin DLL so the AssemblyResolve
|
||||
handler in HistorianPluginLoader can find them when the plugin first JITs. -->
|
||||
<None Include="..\..\lib\aahClient.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\aahClientCommon.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\aahClientManaged.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\Historian.CBE.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\Historian.DPAPI.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="StageHistorianPluginForHost" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<_HistorianStageFiles Include="$(OutDir)aahClient.dll"/>
|
||||
<_HistorianStageFiles Include="$(OutDir)aahClientCommon.dll"/>
|
||||
<_HistorianStageFiles Include="$(OutDir)aahClientManaged.dll"/>
|
||||
<_HistorianStageFiles Include="$(OutDir)Historian.CBE.dll"/>
|
||||
<_HistorianStageFiles Include="$(OutDir)Historian.DPAPI.dll"/>
|
||||
<_HistorianStageFiles Include="$(OutDir)ArchestrA.CloudHistorian.Contract.dll"/>
|
||||
<_HistorianStageFiles Include="$(OutDir)$(AssemblyName).dll"/>
|
||||
<_HistorianStageFiles Include="$(OutDir)$(AssemblyName).pdb" Condition="Exists('$(OutDir)$(AssemblyName).pdb')"/>
|
||||
</ItemGroup>
|
||||
<MakeDir Directories="$(HistorianPluginOutputDir)"/>
|
||||
<Copy SourceFiles="@(_HistorianStageFiles)" DestinationFolder="$(HistorianPluginOutputDir)" SkipUnchangedFiles="true"/>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the template-based alarm object filter under <c>OpcUa.AlarmFilter</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each entry in <see cref="ObjectFilters"/> is a wildcard pattern matched against the template
|
||||
/// derivation chain of every Galaxy object. Supported wildcard: <c>*</c>. Matching is case-insensitive
|
||||
/// and the leading <c>$</c> used by Galaxy template tag_names is normalized away, so operators can
|
||||
/// write <c>TestMachine*</c> instead of <c>$TestMachine*</c>. An entry may itself contain comma-separated
|
||||
/// patterns for convenience (e.g., <c>"TestMachine*, Pump_*"</c>). An empty list disables the filter,
|
||||
/// restoring current behavior: all alarm-bearing objects are monitored when
|
||||
/// <see cref="OpcUaConfiguration.AlarmTrackingEnabled"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
public class AlarmFilterConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the wildcard patterns that select which Galaxy objects contribute alarm conditions.
|
||||
/// An object is included when any template in its derivation chain matches any pattern, and the
|
||||
/// inclusion propagates to all descendants in the containment hierarchy. Each object is evaluated
|
||||
/// once: overlapping matches never create duplicate alarm subscriptions.
|
||||
/// </summary>
|
||||
public List<string> ObjectFilters { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
|
||||
/// </summary>
|
||||
public class AppConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space.
|
||||
/// </summary>
|
||||
public OpcUaConfiguration OpcUa { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes.
|
||||
/// </summary>
|
||||
public MxAccessConfiguration MxAccess { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the repository settings used to query Galaxy metadata for address-space construction.
|
||||
/// </summary>
|
||||
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the embedded dashboard settings used to surface service health to operators.
|
||||
/// </summary>
|
||||
public DashboardConfiguration Dashboard { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data.
|
||||
/// </summary>
|
||||
public HistorianConfiguration Historian { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authentication and role-based access control settings.
|
||||
/// </summary>
|
||||
public AuthenticationConfiguration Authentication { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
|
||||
/// </summary>
|
||||
public SecurityProfileConfiguration Security { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redundancy settings that control how this server participates in a redundant pair.
|
||||
/// </summary>
|
||||
public RedundancyConfiguration Redundancy { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Authentication and role-based access control settings for the OPC UA server.
|
||||
/// </summary>
|
||||
public class AuthenticationConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
|
||||
/// </summary>
|
||||
public bool AllowAnonymous { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether anonymous users can write tag values.
|
||||
/// When false, only authenticated users can write. Existing security classification restrictions still apply.
|
||||
/// </summary>
|
||||
public bool AnonymousCanWrite { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
|
||||
/// credentials are validated against the LDAP server and group membership determines permissions.
|
||||
/// </summary>
|
||||
public LdapConfiguration Ldap { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SqlClient;
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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));
|
||||
|
||||
/// <summary>
|
||||
/// Validates the effective host configuration and writes the resolved values to the startup log before service
|
||||
/// initialization continues.
|
||||
/// </summary>
|
||||
/// <param name="config">
|
||||
/// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries,
|
||||
/// and dashboard behavior.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> when the required settings are present and within supported bounds; otherwise,
|
||||
/// <see langword="false" />.
|
||||
/// </returns>
|
||||
public static bool ValidateAndLog(AppConfiguration config)
|
||||
{
|
||||
var valid = true;
|
||||
|
||||
Log.Information("=== Effective Configuration ===");
|
||||
|
||||
// OPC UA
|
||||
Log.Information(
|
||||
"OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
|
||||
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName,
|
||||
config.OpcUa.GalaxyName);
|
||||
Log.Information("OpcUa.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;
|
||||
}
|
||||
|
||||
// Alarm filter
|
||||
var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0;
|
||||
Log.Information(
|
||||
"OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]",
|
||||
config.OpcUa.AlarmTrackingEnabled,
|
||||
alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters));
|
||||
if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled)
|
||||
Log.Warning(
|
||||
"OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect",
|
||||
alarmFilterCount);
|
||||
|
||||
// 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);
|
||||
Log.Information(
|
||||
"MxAccess.RuntimeStatusProbesEnabled={Enabled}, RuntimeStatusUnknownTimeoutSeconds={Timeout}s, RequestTimeoutSeconds={RequestTimeout}s",
|
||||
config.MxAccess.RuntimeStatusProbesEnabled, config.MxAccess.RuntimeStatusUnknownTimeoutSeconds,
|
||||
config.MxAccess.RequestTimeoutSeconds);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName))
|
||||
{
|
||||
Log.Error("MxAccess.ClientName must not be empty");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (config.MxAccess.RuntimeStatusUnknownTimeoutSeconds < 5)
|
||||
Log.Warning(
|
||||
"MxAccess.RuntimeStatusUnknownTimeoutSeconds={Timeout} is below the recommended floor of 5s; initial probe resolution may time out before MxAccess has delivered the first callback",
|
||||
config.MxAccess.RuntimeStatusUnknownTimeoutSeconds);
|
||||
|
||||
if (config.MxAccess.RequestTimeoutSeconds < 1)
|
||||
{
|
||||
Log.Error("MxAccess.RequestTimeoutSeconds must be at least 1");
|
||||
valid = false;
|
||||
}
|
||||
else if (config.MxAccess.RequestTimeoutSeconds <
|
||||
Math.Max(config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds))
|
||||
{
|
||||
Log.Warning(
|
||||
"MxAccess.RequestTimeoutSeconds={RequestTimeout} is below Read/Write inner timeouts ({Read}s/{Write}s); outer safety bound may fire before the inner client completes its own error path",
|
||||
config.MxAccess.RequestTimeoutSeconds,
|
||||
config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds);
|
||||
}
|
||||
|
||||
// Galaxy Repository
|
||||
Log.Information(
|
||||
"GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
|
||||
SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds,
|
||||
config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
|
||||
|
||||
var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)
|
||||
? Environment.MachineName
|
||||
: config.GalaxyRepository.PlatformName;
|
||||
Log.Information(
|
||||
"GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}",
|
||||
config.GalaxyRepository.Scope,
|
||||
config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform
|
||||
? effectivePlatformName
|
||||
: "(n/a)");
|
||||
|
||||
if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform &&
|
||||
string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName))
|
||||
Log.Information(
|
||||
"GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'",
|
||||
Environment.MachineName);
|
||||
|
||||
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);
|
||||
|
||||
// Security
|
||||
Log.Information(
|
||||
"Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
|
||||
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
|
||||
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
|
||||
|
||||
Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath ?? "(default)");
|
||||
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject ?? "(default)");
|
||||
Log.Information("Security.CertificateLifetimeMonths={Months}", config.Security.CertificateLifetimeMonths);
|
||||
|
||||
var unknownProfiles = config.Security.Profiles
|
||||
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (unknownProfiles.Count > 0)
|
||||
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
|
||||
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
|
||||
|
||||
if (config.Security.MinimumCertificateKeySize < 2048)
|
||||
{
|
||||
Log.Error("Security.MinimumCertificateKeySize must be at least 2048");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (config.Security.AutoAcceptClientCertificates)
|
||||
Log.Warning(
|
||||
"Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
|
||||
|
||||
if (config.Security.Profiles.Count == 1 &&
|
||||
config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase))
|
||||
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
|
||||
|
||||
// Historian
|
||||
var clusterNodes = config.Historian.ServerNames ?? new List<string>();
|
||||
var effectiveNodes = clusterNodes.Count > 0
|
||||
? string.Join(",", clusterNodes)
|
||||
: config.Historian.ServerName;
|
||||
Log.Information(
|
||||
"Historian.Enabled={Enabled}, Nodes=[{Nodes}], IntegratedSecurity={IntegratedSecurity}, Port={Port}",
|
||||
config.Historian.Enabled, effectiveNodes, config.Historian.IntegratedSecurity,
|
||||
config.Historian.Port);
|
||||
Log.Information(
|
||||
"Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}, FailureCooldownSeconds={Cooldown}, RequestTimeoutSeconds={RequestTimeout}",
|
||||
config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead,
|
||||
config.Historian.FailureCooldownSeconds, config.Historian.RequestTimeoutSeconds);
|
||||
|
||||
if (config.Historian.Enabled)
|
||||
{
|
||||
if (clusterNodes.Count == 0 && string.IsNullOrWhiteSpace(config.Historian.ServerName))
|
||||
{
|
||||
Log.Error("Historian.ServerName (or ServerNames) must not be empty when Historian is enabled");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (config.Historian.FailureCooldownSeconds < 0)
|
||||
{
|
||||
Log.Error("Historian.FailureCooldownSeconds must be zero or positive");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (config.Historian.RequestTimeoutSeconds < 1)
|
||||
{
|
||||
Log.Error("Historian.RequestTimeoutSeconds must be at least 1");
|
||||
valid = false;
|
||||
}
|
||||
else if (config.Historian.RequestTimeoutSeconds < config.Historian.CommandTimeoutSeconds)
|
||||
{
|
||||
Log.Warning(
|
||||
"Historian.RequestTimeoutSeconds={RequestTimeout} is below CommandTimeoutSeconds={CmdTimeout}; outer safety bound may fire before the inner SDK completes its own error path",
|
||||
config.Historian.RequestTimeoutSeconds, config.Historian.CommandTimeoutSeconds);
|
||||
}
|
||||
|
||||
if (clusterNodes.Count > 0 && !string.IsNullOrWhiteSpace(config.Historian.ServerName)
|
||||
&& config.Historian.ServerName != "localhost")
|
||||
Log.Warning(
|
||||
"Historian.ServerName='{ServerName}' is ignored because Historian.ServerNames has {Count} entries",
|
||||
config.Historian.ServerName, clusterNodes.Count);
|
||||
|
||||
if (config.Historian.Port < 1 || config.Historian.Port > 65535)
|
||||
{
|
||||
Log.Error("Historian.Port must be between 1 and 65535");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.UserName))
|
||||
{
|
||||
Log.Error("Historian.UserName must not be empty when IntegratedSecurity is disabled");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.Password))
|
||||
Log.Warning("Historian.Password is empty — authentication may fail");
|
||||
}
|
||||
|
||||
// Authentication
|
||||
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
|
||||
config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
|
||||
|
||||
if (config.Authentication.Ldap.Enabled)
|
||||
{
|
||||
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
|
||||
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port,
|
||||
config.Authentication.Ldap.BaseDN);
|
||||
Log.Information(
|
||||
"Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
|
||||
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
|
||||
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
|
||||
config.Authentication.Ldap.AlarmAckGroup);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
|
||||
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
|
||||
}
|
||||
|
||||
// Redundancy
|
||||
if (config.OpcUa.ApplicationUri != null)
|
||||
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
|
||||
|
||||
Log.Information(
|
||||
"Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
|
||||
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role,
|
||||
config.Redundancy.ServiceLevelBase);
|
||||
|
||||
if (config.Redundancy.ServerUris.Count > 0)
|
||||
Log.Information("Redundancy.ServerUris=[{ServerUris}]",
|
||||
string.Join(", ", config.Redundancy.ServerUris));
|
||||
|
||||
if (config.Redundancy.Enabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
|
||||
{
|
||||
Log.Error(
|
||||
"OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (config.Redundancy.ServerUris.Count < 2)
|
||||
Log.Warning(
|
||||
"Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
|
||||
|
||||
if (config.OpcUa.ApplicationUri != null &&
|
||||
!config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
|
||||
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris",
|
||||
config.OpcUa.ApplicationUri);
|
||||
|
||||
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
|
||||
if (mode == RedundancySupport.None)
|
||||
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None",
|
||||
config.Redundancy.Mode);
|
||||
}
|
||||
|
||||
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
|
||||
{
|
||||
Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
|
||||
return valid;
|
||||
}
|
||||
|
||||
private static string SanitizeConnectionString(string connectionString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
return "(empty)";
|
||||
try
|
||||
{
|
||||
var builder = new SqlConnectionStringBuilder(connectionString);
|
||||
if (!string.IsNullOrEmpty(builder.Password))
|
||||
builder.Password = "********";
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "(unparseable)";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Status dashboard configuration. (SVC-003, DASH-001)
|
||||
/// </summary>
|
||||
public class DashboardConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 8081;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
|
||||
/// </summary>
|
||||
public int RefreshIntervalSeconds { get; set; } = 10;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Galaxy repository database configuration. (SVC-003, GR-005)
|
||||
/// </summary>
|
||||
public class GalaxyRepositoryConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space
|
||||
/// rebuild.
|
||||
/// </summary>
|
||||
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
|
||||
/// </summary>
|
||||
public bool ExtendedAttributes { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space.
|
||||
/// <c>Galaxy</c> loads all deployed objects (default). <c>LocalPlatform</c> loads only
|
||||
/// objects hosted by the platform deployed on this machine.
|
||||
/// </summary>
|
||||
public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an explicit platform node name for <see cref="GalaxyScope.LocalPlatform" /> filtering.
|
||||
/// When <see langword="null" />, the local machine name (<c>Environment.MachineName</c>) is used.
|
||||
/// </summary>
|
||||
public string? PlatformName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space.
|
||||
/// </summary>
|
||||
public enum GalaxyScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior).
|
||||
/// </summary>
|
||||
Galaxy,
|
||||
|
||||
/// <summary>
|
||||
/// Load only objects hosted by the local platform and the structural areas needed to reach them.
|
||||
/// </summary>
|
||||
LocalPlatform
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Wonderware Historian SDK configuration for OPC UA historical data access.
|
||||
/// </summary>
|
||||
public class HistorianConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether OPC UA historical data access is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the single Historian server hostname used when <see cref="ServerNames"/>
|
||||
/// is empty. Preserved for backward compatibility with pre-cluster deployments.
|
||||
/// </summary>
|
||||
public string ServerName { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ordered list of Historian cluster nodes. When non-empty, this list
|
||||
/// supersedes <see cref="ServerName"/>: the data source attempts each node in order on
|
||||
/// connect, falling through to the next on failure. A failed node is placed in cooldown
|
||||
/// for <see cref="FailureCooldownSeconds"/> before being re-eligible.
|
||||
/// </summary>
|
||||
public List<string> ServerNames { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cooldown window, in seconds, that a historian node is skipped after
|
||||
/// a connection failure. A value of zero retries the node on every request. Default 60s.
|
||||
/// </summary>
|
||||
public int FailureCooldownSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether Windows Integrated Security is used.
|
||||
/// When false, <see cref="UserName"/> and <see cref="Password"/> are used instead.
|
||||
/// </summary>
|
||||
public bool IntegratedSecurity { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the username for Historian authentication when <see cref="IntegratedSecurity"/> is false.
|
||||
/// </summary>
|
||||
public string? UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the password for Historian authentication when <see cref="IntegratedSecurity"/> is false.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Historian server TCP port.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 32568;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the packet timeout in seconds for Historian SDK operations.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of values returned per HistoryRead request.
|
||||
/// </summary>
|
||||
public int MaxValuesPerRead { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async Historian
|
||||
/// operations invoked from the OPC UA stack thread (HistoryReadRaw, HistoryReadProcessed,
|
||||
/// HistoryReadAtTime, HistoryReadEvents). This is a backstop for the case where a
|
||||
/// historian query hangs outside <see cref="CommandTimeoutSeconds"/> — e.g., a slow SDK
|
||||
/// reconnect or mid-failover cluster node. Must be comfortably larger than
|
||||
/// <see cref="CommandTimeoutSeconds"/> so normal operation is never affected. Default 60s.
|
||||
/// </summary>
|
||||
public int RequestTimeoutSeconds { get; set; } = 60;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// LDAP authentication and group-to-role mapping settings.
|
||||
/// </summary>
|
||||
public class LdapConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether LDAP authentication is enabled.
|
||||
/// When true, user credentials are validated against the configured LDAP server
|
||||
/// and group membership determines OPC UA permissions.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP server hostname or IP address.
|
||||
/// </summary>
|
||||
public string Host { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP server port.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 3893;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base DN for LDAP operations.
|
||||
/// </summary>
|
||||
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bind DN template. Use {username} as a placeholder.
|
||||
/// </summary>
|
||||
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service account DN used for LDAP searches (group lookups).
|
||||
/// </summary>
|
||||
public string ServiceAccountDn { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service account password.
|
||||
/// </summary>
|
||||
public string ServiceAccountPassword { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP connection timeout in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants read-only access.
|
||||
/// </summary>
|
||||
public string ReadOnlyGroup { get; set; } = "ReadOnly";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
|
||||
/// </summary>
|
||||
public string WriteOperateGroup { get; set; } = "WriteOperate";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
|
||||
/// </summary>
|
||||
public string WriteTuneGroup { get; set; } = "WriteTune";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
|
||||
/// </summary>
|
||||
public string WriteConfigureGroup { get; set; } = "WriteConfigure";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
|
||||
/// </summary>
|
||||
public string AlarmAckGroup { get; set; } = "AlarmAck";
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
|
||||
/// </summary>
|
||||
public class MxAccessConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
|
||||
/// </summary>
|
||||
public string ClientName { get; set; } = "LmxOpcUa";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
|
||||
/// </summary>
|
||||
public string? NodeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
|
||||
/// </summary>
|
||||
public string? GalaxyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
|
||||
/// </summary>
|
||||
public int ReadTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
|
||||
/// </summary>
|
||||
public int WriteTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async MxAccess
|
||||
/// operations invoked from the OPC UA stack thread (Read, Write, address-space rebuild probe
|
||||
/// sync). This is a backstop for the case where an async path hangs outside the inner
|
||||
/// <see cref="ReadTimeoutSeconds"/> / <see cref="WriteTimeoutSeconds"/> bounds — e.g., a
|
||||
/// slow reconnect or a scheduler stall. Must be comfortably larger than the inner timeouts
|
||||
/// so normal operation is never affected. Default 30s.
|
||||
/// </summary>
|
||||
public int RequestTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
|
||||
/// </summary>
|
||||
public int MaxConcurrentOperations { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
|
||||
/// </summary>
|
||||
public int MonitorIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess
|
||||
/// session.
|
||||
/// </summary>
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
|
||||
/// </summary>
|
||||
public string? ProbeTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
|
||||
/// </summary>
|
||||
public int ProbeStaleThresholdSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the bridge advises <c><ObjectName>.ScanState</c> for every
|
||||
/// deployed <c>$WinPlatform</c> and <c>$AppEngine</c>, reporting per-host runtime state on the status
|
||||
/// dashboard and proactively invalidating OPC UA variable quality when a host transitions to Stopped.
|
||||
/// Enabled by default. Disable to return to legacy behavior where host runtime state is invisible and
|
||||
/// MxAccess's per-tag bad-quality fan-out is the only stop signal.
|
||||
/// </summary>
|
||||
public bool RuntimeStatusProbesEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum seconds to wait for the initial probe callback before marking a host as
|
||||
/// Stopped. Only applies to the Unknown → Stopped transition. Because <c>ScanState</c> is delivered
|
||||
/// on-change only, a stably Running host does not time out — no starvation check runs on Running
|
||||
/// entries. Default 15s.
|
||||
/// </summary>
|
||||
public int RuntimeStatusUnknownTimeoutSeconds { get; set; } = 15;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
|
||||
/// </summary>
|
||||
public class OpcUaConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the IP address or hostname the OPC UA server binds to.
|
||||
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
|
||||
/// </summary>
|
||||
public string BindAddress { get; set; } = "0.0.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 4840;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
|
||||
/// </summary>
|
||||
public string EndpointPath { get; set; } = "/LmxOpcUa";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
|
||||
/// </summary>
|
||||
public string ServerName { get; set; } = "LmxOpcUa";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
|
||||
/// </summary>
|
||||
public string GalaxyName { get; set; } = "ZB";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the explicit application URI for this server instance.
|
||||
/// When <see langword="null" />, defaults to <c>urn:{GalaxyName}:LmxOpcUa</c>.
|
||||
/// Must be set to a unique value per instance when redundancy is enabled.
|
||||
/// </summary>
|
||||
public string? ApplicationUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
|
||||
/// </summary>
|
||||
public int MaxSessions { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
|
||||
/// </summary>
|
||||
public int SessionTimeoutMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether alarm tracking is enabled.
|
||||
/// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored.
|
||||
/// </summary>
|
||||
public bool AlarmTrackingEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the template-based alarm object filter. When <see cref="AlarmFilterConfiguration.ObjectFilters"/>
|
||||
/// is empty, all alarm-bearing objects are monitored (current behavior). When patterns are supplied, only
|
||||
/// objects whose template derivation chain matches a pattern (and their descendants) have alarms monitored.
|
||||
/// </summary>
|
||||
public AlarmFilterConfiguration AlarmFilter { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-transparent redundancy settings that control how the server advertises itself
|
||||
/// within a redundant pair and computes its dynamic ServiceLevel.
|
||||
/// </summary>
|
||||
public class RedundancyConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether redundancy is enabled. When <see langword="false" /> (default),
|
||||
/// the server reports <c>RedundancySupport.None</c> and <c>ServiceLevel = 255</c>.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redundancy mode. Valid values: <c>Warm</c>, <c>Hot</c>.
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = "Warm";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the role of this instance. Valid values: <c>Primary</c>, <c>Secondary</c>.
|
||||
/// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
|
||||
/// </summary>
|
||||
public string Role { get; set; } = "Primary";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
|
||||
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
|
||||
/// </summary>
|
||||
public List<string> ServerUris { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base ServiceLevel when the server is fully healthy.
|
||||
/// The secondary automatically receives <c>ServiceLevelBase - 50</c>.
|
||||
/// Valid range: 1-255.
|
||||
/// </summary>
|
||||
public int ServiceLevelBase { get; set; } = 200;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport security settings that control which OPC UA security profiles the server exposes and how client
|
||||
/// certificates are handled.
|
||||
/// </summary>
|
||||
public class SecurityProfileConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of security profile names to expose as server endpoints.
|
||||
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
|
||||
/// Defaults to ["None"] for backward compatibility.
|
||||
/// </summary>
|
||||
public List<string> Profiles { get; set; } = new() { "None" };
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the server automatically accepts client certificates
|
||||
/// that are not in the trusted store. Should be <see langword="false" /> in production.
|
||||
/// </summary>
|
||||
public bool AutoAcceptClientCertificates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
|
||||
/// </summary>
|
||||
public bool RejectSHA1Certificates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum RSA key size required for client certificates.
|
||||
/// </summary>
|
||||
public int MinimumCertificateKeySize { get; set; } = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional override for the PKI root directory.
|
||||
/// When <see langword="null" />, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
|
||||
/// </summary>
|
||||
public string? PkiRootPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional override for the server certificate subject name.
|
||||
/// When <see langword="null" />, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
|
||||
/// </summary>
|
||||
public string? CertificateSubject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lifetime of the auto-generated server certificate in months.
|
||||
/// Defaults to 60 months (5 years).
|
||||
/// </summary>
|
||||
public int CertificateLifetimeMonths { get; set; } = 60;
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiles and applies wildcard template patterns against Galaxy objects to decide which
|
||||
/// objects should contribute alarm conditions. The filter is pure data — no OPC UA, no DB —
|
||||
/// so it is fully unit-testable with synthetic hierarchies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Matching rules:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>An object is included when any template name in its derivation chain matches
|
||||
/// any configured pattern.</item>
|
||||
/// <item>Matching is case-insensitive and ignores the Galaxy leading <c>$</c> prefix on
|
||||
/// both the chain entry and the user pattern, so <c>TestMachine*</c> matches the stored
|
||||
/// <c>$TestMachine</c>.</item>
|
||||
/// <item>Inclusion propagates to every descendant of a matched object (containment subtree).</item>
|
||||
/// <item>Each object is evaluated once — overlapping matches never produce duplicate
|
||||
/// inclusions (set semantics).</item>
|
||||
/// </list>
|
||||
/// <para>Pattern syntax: literal text plus <c>*</c> wildcards (zero or more characters).
|
||||
/// Other regex metacharacters in the raw pattern are escaped and treated literally.</para>
|
||||
/// </remarks>
|
||||
public class AlarmObjectFilter
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<AlarmObjectFilter>();
|
||||
|
||||
private readonly List<Regex> _patterns;
|
||||
private readonly List<string> _rawPatterns;
|
||||
private readonly HashSet<string> _matchedRawPatterns;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new alarm object filter from the supplied configuration section.
|
||||
/// </summary>
|
||||
/// <param name="config">The alarm filter configuration whose <see cref="AlarmFilterConfiguration.ObjectFilters"/>
|
||||
/// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns.</param>
|
||||
public AlarmObjectFilter(AlarmFilterConfiguration? config)
|
||||
{
|
||||
_patterns = new List<Regex>();
|
||||
_rawPatterns = new List<string>();
|
||||
_matchedRawPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (config?.ObjectFilters == null)
|
||||
return;
|
||||
|
||||
foreach (var entry in config.ObjectFilters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
continue;
|
||||
|
||||
foreach (var piece in entry.Split(','))
|
||||
{
|
||||
var trimmed = piece.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var normalized = Normalize(trimmed);
|
||||
var regex = GlobToRegex(normalized);
|
||||
_patterns.Add(regex);
|
||||
_rawPatterns.Add(trimmed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to compile alarm filter pattern {Pattern} — skipping", trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the filter has any compiled patterns. When <see langword="false"/>,
|
||||
/// callers should treat alarm tracking as unfiltered (current behavior preserved).
|
||||
/// </summary>
|
||||
public bool Enabled => _patterns.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of compiled patterns the filter will evaluate against each object.
|
||||
/// </summary>
|
||||
public int PatternCount => _patterns.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw pattern strings that did not match any object in the most recent call to
|
||||
/// <see cref="ResolveIncludedObjects"/>. Useful for startup warnings about operator typos.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> UnmatchedPatterns =>
|
||||
_rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw pattern strings exactly as supplied by the operator after comma-splitting
|
||||
/// and trimming. Surfaced on the status dashboard so operators can confirm the active filter.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> RawPatterns => _rawPatterns;
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when any template name in <paramref name="chain"/> matches any
|
||||
/// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern
|
||||
/// equal to <c>*</c> (which collapses to an empty-matching regex after normalization).
|
||||
/// </summary>
|
||||
/// <param name="chain">The template derivation chain to test (own template first, ancestors after).</param>
|
||||
public bool MatchesTemplateChain(IReadOnlyList<string>? chain)
|
||||
{
|
||||
if (chain == null || chain.Count == 0 || _patterns.Count == 0)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < _patterns.Count; i++)
|
||||
{
|
||||
var regex = _patterns[i];
|
||||
for (var j = 0; j < chain.Count; j++)
|
||||
{
|
||||
var entry = chain[j];
|
||||
if (string.IsNullOrEmpty(entry))
|
||||
continue;
|
||||
if (regex.IsMatch(Normalize(entry)))
|
||||
{
|
||||
_matchedRawPatterns.Add(_rawPatterns[i]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the hierarchy top-down from each root and returns the set of gobject IDs whose alarms
|
||||
/// should be monitored, honoring both template matching and descendant propagation. Returns
|
||||
/// <see langword="null"/> when the filter is disabled so callers can skip the containment check
|
||||
/// entirely.
|
||||
/// </summary>
|
||||
/// <param name="hierarchy">The full deployed Galaxy hierarchy, as returned by the repository service.</param>
|
||||
/// <returns>The set of included gobject IDs, or <see langword="null"/> when filtering is disabled.</returns>
|
||||
public HashSet<int>? ResolveIncludedObjects(IReadOnlyList<GalaxyObjectInfo>? hierarchy)
|
||||
{
|
||||
if (!Enabled)
|
||||
return null;
|
||||
|
||||
_matchedRawPatterns.Clear();
|
||||
var included = new HashSet<int>();
|
||||
if (hierarchy == null || hierarchy.Count == 0)
|
||||
return included;
|
||||
|
||||
var byId = new Dictionary<int, GalaxyObjectInfo>(hierarchy.Count);
|
||||
foreach (var obj in hierarchy)
|
||||
byId[obj.GobjectId] = obj;
|
||||
|
||||
var childrenByParent = new Dictionary<int, List<int>>();
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
var parentId = obj.ParentGobjectId;
|
||||
if (parentId != 0 && !byId.ContainsKey(parentId))
|
||||
parentId = 0; // orphan → treat as root
|
||||
if (!childrenByParent.TryGetValue(parentId, out var list))
|
||||
{
|
||||
list = new List<int>();
|
||||
childrenByParent[parentId] = list;
|
||||
}
|
||||
list.Add(obj.GobjectId);
|
||||
}
|
||||
|
||||
var roots = childrenByParent.TryGetValue(0, out var rootList)
|
||||
? rootList
|
||||
: new List<int>();
|
||||
|
||||
var visited = new HashSet<int>();
|
||||
var queue = new Queue<(int Id, bool ParentIncluded)>();
|
||||
foreach (var rootId in roots)
|
||||
queue.Enqueue((rootId, false));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (id, parentIncluded) = queue.Dequeue();
|
||||
if (!visited.Add(id))
|
||||
continue; // cycle defense
|
||||
|
||||
if (!byId.TryGetValue(id, out var obj))
|
||||
continue;
|
||||
|
||||
var nodeIncluded = parentIncluded || MatchesTemplateChain(obj.TemplateChain);
|
||||
if (nodeIncluded)
|
||||
included.Add(id);
|
||||
|
||||
if (childrenByParent.TryGetValue(id, out var children))
|
||||
foreach (var childId in children)
|
||||
queue.Enqueue((childId, nodeIncluded));
|
||||
}
|
||||
|
||||
return included;
|
||||
}
|
||||
|
||||
private static Regex GlobToRegex(string normalized)
|
||||
{
|
||||
var segments = normalized.Split('*');
|
||||
var parts = segments.Select(Regex.Escape);
|
||||
var body = string.Join(".*", parts);
|
||||
return new Regex("^" + body + "$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
private static string Normalize(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("$", StringComparison.Ordinal))
|
||||
return trimmed.Substring(1);
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// MXAccess connection lifecycle states. (MXA-002)
|
||||
/// </summary>
|
||||
public enum ConnectionState
|
||||
{
|
||||
/// <summary>
|
||||
/// No active session exists to the Galaxy runtime.
|
||||
/// </summary>
|
||||
Disconnected,
|
||||
|
||||
/// <summary>
|
||||
/// The bridge is opening a new MXAccess session to the runtime.
|
||||
/// </summary>
|
||||
Connecting,
|
||||
|
||||
/// <summary>
|
||||
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
|
||||
/// </summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>
|
||||
/// The bridge is closing the current MXAccess session and draining runtime resources.
|
||||
/// </summary>
|
||||
Disconnecting,
|
||||
|
||||
/// <summary>
|
||||
/// The bridge detected a connection fault that requires operator attention or recovery logic.
|
||||
/// </summary>
|
||||
Error,
|
||||
|
||||
/// <summary>
|
||||
/// The bridge is attempting to restore service after a runtime communication failure.
|
||||
/// </summary>
|
||||
Reconnecting
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Event args for connection state transitions. (MXA-002)
|
||||
/// </summary>
|
||||
public class ConnectionStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
|
||||
/// </summary>
|
||||
/// <param name="previous">The connection state being exited.</param>
|
||||
/// <param name="current">The connection state being entered.</param>
|
||||
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
|
||||
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
|
||||
{
|
||||
PreviousState = previous;
|
||||
CurrentState = current;
|
||||
Message = message ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous MXAccess connection state before the transition was raised.
|
||||
/// </summary>
|
||||
public ConnectionState PreviousState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the new MXAccess connection state that the bridge moved into.
|
||||
/// </summary>
|
||||
public ConnectionState CurrentState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an operator-facing message that explains why the connection state changed.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO matching attributes.sql result columns. (GR-002)
|
||||
/// </summary>
|
||||
public class GalaxyAttributeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy object identifier that owns the attribute.
|
||||
/// </summary>
|
||||
public int GobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
|
||||
/// </summary>
|
||||
public string TagName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
|
||||
/// </summary>
|
||||
public string AttributeName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
|
||||
/// </summary>
|
||||
public string FullTagReference { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
|
||||
/// </summary>
|
||||
public int MxDataType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
|
||||
/// </summary>
|
||||
public string DataTypeName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
|
||||
/// </summary>
|
||||
public bool IsArray { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
|
||||
/// </summary>
|
||||
public int? ArrayDimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
|
||||
/// </summary>
|
||||
public string PrimitiveName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation,
|
||||
/// or runtime data.
|
||||
/// </summary>
|
||||
public string AttributeSource { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
|
||||
/// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly.
|
||||
/// </summary>
|
||||
public int SecurityClassification { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the
|
||||
/// Wonderware Historian.
|
||||
/// </summary>
|
||||
public bool IsHistorized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm.
|
||||
/// </summary>
|
||||
public bool IsAlarm { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO matching hierarchy.sql result columns. (GR-001)
|
||||
/// </summary>
|
||||
public class GalaxyObjectInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
|
||||
/// </summary>
|
||||
public int GobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
|
||||
/// </summary>
|
||||
public string TagName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the contained name shown for the object inside its parent area or object.
|
||||
/// </summary>
|
||||
public string ContainedName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
|
||||
/// </summary>
|
||||
public string BrowseName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
|
||||
/// </summary>
|
||||
public int ParentGobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
|
||||
/// </summary>
|
||||
public bool IsArea { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the template derivation chain for this object. Index 0 is the object's own template;
|
||||
/// subsequent entries walk up toward the most ancestral template before <c>$Object</c>. Populated by
|
||||
/// the recursive CTE in <c>hierarchy.sql</c> on <c>gobject.derived_from_gobject_id</c>. Used by
|
||||
/// <see cref="AlarmObjectFilter"/> to decide whether an object's alarms should be monitored.
|
||||
/// </summary>
|
||||
public List<string> TemplateChain { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy template category id for this object. Category 1 is $WinPlatform,
|
||||
/// 3 is $AppEngine, 13 is $Area, 10 is $UserDefined, and so on. Populated from
|
||||
/// <c>template_definition.category_id</c> by <c>hierarchy.sql</c> and consumed by the runtime
|
||||
/// status probe manager to identify hosts that should receive a <c>ScanState</c> probe.
|
||||
/// </summary>
|
||||
public int CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy object id of this object's runtime host, populated from
|
||||
/// <c>gobject.hosted_by_gobject_id</c>. Walk this chain upward to find the nearest
|
||||
/// <c>$WinPlatform</c> or <c>$AppEngine</c> ancestor for subtree quality invalidation when
|
||||
/// a runtime host is reported Stopped. Zero for root objects that have no host.
|
||||
/// </summary>
|
||||
public int HostedByGobjectId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime state of a deployed Galaxy runtime host ($WinPlatform or $AppEngine) as
|
||||
/// observed by the bridge via its <c>ScanState</c> probe.
|
||||
/// </summary>
|
||||
public enum GalaxyRuntimeState
|
||||
{
|
||||
/// <summary>
|
||||
/// Probe advised but no callback received yet. Transitions to <see cref="Running"/>
|
||||
/// on the first successful <c>ScanState = true</c> callback, or to <see cref="Stopped"/>
|
||||
/// once the unknown-resolution timeout elapses.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// Last probe callback reported <c>ScanState = true</c> with a successful item status.
|
||||
/// The host is on scan and executing.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// Last probe callback reported <c>ScanState != true</c>, or a failed item status, or
|
||||
/// the initial probe never resolved before the unknown timeout elapsed. The host is
|
||||
/// off scan or unreachable.
|
||||
/// </summary>
|
||||
Stopped
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time runtime state of a single Galaxy runtime host ($WinPlatform or $AppEngine)
|
||||
/// as tracked by the <c>GalaxyRuntimeProbeManager</c>. Surfaced on the status dashboard and
|
||||
/// consumed by <c>HealthCheckService</c> so operators can detect a stopped host before
|
||||
/// downstream clients notice the stale data.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRuntimeStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy tag_name of the host (e.g., <c>DevPlatform</c> or
|
||||
/// <c>DevAppEngine</c>).
|
||||
/// </summary>
|
||||
public string ObjectName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy gobject_id of the host.
|
||||
/// </summary>
|
||||
public int GobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy template category name — <c>$WinPlatform</c> or
|
||||
/// <c>$AppEngine</c>. Used by the dashboard to group hosts by kind.
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current runtime state.
|
||||
/// </summary>
|
||||
public GalaxyRuntimeState State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the most recent probe callback, whether it
|
||||
/// reported success or failure. <see langword="null"/> before the first callback.
|
||||
/// </summary>
|
||||
public DateTime? LastStateCallbackTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the most recent <see cref="State"/> transition.
|
||||
/// Backs the dashboard "Since" column. <see langword="null"/> in the initial Unknown
|
||||
/// state before any transition.
|
||||
/// </summary>
|
||||
public DateTime? LastStateChangeTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last <c>ScanState</c> value received from the probe, or
|
||||
/// <see langword="null"/> before the first update or when the last callback carried
|
||||
/// a non-success item status (no value delivered).
|
||||
/// </summary>
|
||||
public bool? LastScanState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the detail message from the most recent failure callback, cleared on
|
||||
/// the next successful <c>ScanState = true</c> delivery.
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cumulative number of callbacks where <c>ScanState = true</c>.
|
||||
/// </summary>
|
||||
public long GoodUpdateCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cumulative number of callbacks where <c>ScanState != true</c>
|
||||
/// or the item status reported failure.
|
||||
/// </summary>
|
||||
public long FailureCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for Galaxy repository database queries. (GR-001 through GR-004)
|
||||
/// </summary>
|
||||
public interface IGalaxyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the repository query.</param>
|
||||
/// <returns>A list of Galaxy objects ordered for address-space construction.</returns>
|
||||
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the repository query.</param>
|
||||
/// <returns>A list of attribute definitions with MXAccess references and type metadata.</returns>
|
||||
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the repository query.</param>
|
||||
/// <returns>The latest deploy timestamp, or <see langword="null" /> when it cannot be determined.</returns>
|
||||
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the connectivity check.</param>
|
||||
/// <returns><see langword="true" /> when repository access succeeds; otherwise, <see langword="false" />.</returns>
|
||||
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
|
||||
/// </summary>
|
||||
event Action? OnGalaxyChanged;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current runtime connectivity state for the bridge.
|
||||
/// </summary>
|
||||
ConnectionState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
|
||||
/// </summary>
|
||||
int ActiveSubscriptionCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of reconnect cycles attempted since the client was created.
|
||||
/// </summary>
|
||||
int ReconnectCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
|
||||
/// </summary>
|
||||
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
|
||||
/// </summary>
|
||||
event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the connection attempt.</param>
|
||||
Task ConnectAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Closes the MXAccess session and releases runtime resources.
|
||||
/// </summary>
|
||||
Task DisconnectAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
||||
/// <param name="callback">The callback to invoke when the runtime publishes a new value for the attribute.</param>
|
||||
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
|
||||
|
||||
/// <summary>
|
||||
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
||||
Task UnsubscribeAsync(string fullTagReference);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current runtime value for a Galaxy attribute.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
||||
/// <param name="ct">A token that cancels the read.</param>
|
||||
/// <returns>The value, timestamp, and quality returned by the runtime.</returns>
|
||||
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a new runtime value to a writable Galaxy attribute.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
||||
/// <param name="value">The value to write to the runtime.</param>
|
||||
/// <param name="ct">A token that cancels the write.</param>
|
||||
/// <returns><see langword="true" /> when the write is accepted by the runtime; otherwise, <see langword="false" />.</returns>
|
||||
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
|
||||
/// </summary>
|
||||
/// <param name="hLMXServerHandle">The runtime connection handle that raised the change.</param>
|
||||
/// <param name="phItemHandle">The runtime item handle for the attribute that changed.</param>
|
||||
/// <param name="pvItemValue">The new raw runtime value for the attribute.</param>
|
||||
/// <param name="pwItemQuality">The OPC DA quality code supplied by the runtime.</param>
|
||||
/// <param name="pftItemTimeStamp">The timestamp object supplied by the runtime for the value.</param>
|
||||
/// <param name="ItemStatus">The MXAccess status payload associated with the callback.</param>
|
||||
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>
|
||||
/// <param name="hLMXServerHandle">The runtime connection handle that processed the write.</param>
|
||||
/// <param name="phItemHandle">The runtime item handle that was written.</param>
|
||||
/// <param name="ItemStatus">The MXAccess status payload describing the write outcome.</param>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the bridge as an MXAccess client with the runtime proxy.
|
||||
/// </summary>
|
||||
/// <param name="clientName">The client identity reported to the runtime for diagnostics and session tracking.</param>
|
||||
/// <returns>The runtime connection handle assigned to the client session.</returns>
|
||||
int Register(string clientName);
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
|
||||
/// </summary>
|
||||
/// <param name="handle">The connection handle returned by <see cref="Register(string)" />.</param>
|
||||
void Unregister(int handle);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a Galaxy attribute reference to the active runtime session.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="address">The fully qualified attribute reference to resolve.</param>
|
||||
/// <returns>The runtime item handle assigned to the attribute.</returns>
|
||||
int AddItem(int handle, string address);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously registered attribute from the runtime session.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)" />.</param>
|
||||
void RemoveItem(int handle, int itemHandle);
|
||||
|
||||
/// <summary>
|
||||
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle to monitor.</param>
|
||||
void AdviseSupervisory(int handle, int itemHandle);
|
||||
|
||||
/// <summary>
|
||||
/// Stops supervisory updates for an attribute.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle to stop monitoring.</param>
|
||||
void UnAdviseSupervisory(int handle, int itemHandle);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a new value to a runtime attribute through the COM proxy.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle to write.</param>
|
||||
/// <param name="value">The new value to push into the runtime.</param>
|
||||
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
|
||||
void Write(int handle, int itemHandle, object value, int securityClassification);
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
|
||||
/// </summary>
|
||||
event MxDataChangeHandler? OnDataChange;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the runtime acknowledges completion of a write request.
|
||||
/// </summary>
|
||||
event MxWriteCompleteHandler? OnWriteComplete;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP,
|
||||
/// etc.).
|
||||
/// </summary>
|
||||
public interface IUserAuthenticationProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a username/password combination.
|
||||
/// </summary>
|
||||
bool ValidateCredentials(string username, string password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended interface for providers that can resolve application-level roles for authenticated users.
|
||||
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
|
||||
/// to control write and alarm-ack permissions.
|
||||
/// </summary>
|
||||
public interface IRoleProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the set of application-level roles granted to the user.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetUserRoles(string username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known application-level role names used for permission enforcement.
|
||||
/// </summary>
|
||||
public static class AppRoles
|
||||
{
|
||||
public const string ReadOnly = "ReadOnly";
|
||||
public const string WriteOperate = "WriteOperate";
|
||||
public const string WriteTune = "WriteTune";
|
||||
public const string WriteConfigure = "WriteConfigure";
|
||||
public const string AlarmAck = "AlarmAck";
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.DirectoryServices.Protocols;
|
||||
using System.Net;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates credentials via LDAP bind and resolves group membership to application roles.
|
||||
/// </summary>
|
||||
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<LdapAuthenticationProvider>();
|
||||
|
||||
private readonly LdapConfiguration _config;
|
||||
private readonly Dictionary<string, string> _groupToRole;
|
||||
|
||||
public LdapAuthenticationProvider(LdapConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ config.ReadOnlyGroup, AppRoles.ReadOnly },
|
||||
{ config.WriteOperateGroup, AppRoles.WriteOperate },
|
||||
{ config.WriteTuneGroup, AppRoles.WriteTune },
|
||||
{ config.WriteConfigureGroup, AppRoles.WriteConfigure },
|
||||
{ config.AlarmAckGroup, AppRoles.AlarmAck }
|
||||
};
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetUserRoles(string username)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var connection = CreateConnection())
|
||||
{
|
||||
// Bind with service account to search
|
||||
connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword));
|
||||
|
||||
var request = new SearchRequest(
|
||||
_config.BaseDN,
|
||||
$"(cn={EscapeLdapFilter(username)})",
|
||||
SearchScope.Subtree,
|
||||
"memberOf");
|
||||
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
|
||||
if (response.Entries.Count == 0)
|
||||
{
|
||||
Log.Warning("LDAP search returned no entries for {Username}", username);
|
||||
return new[] { AppRoles.ReadOnly }; // safe fallback
|
||||
}
|
||||
|
||||
var entry = response.Entries[0];
|
||||
var memberOf = entry.Attributes["memberOf"];
|
||||
if (memberOf == null || memberOf.Count == 0)
|
||||
{
|
||||
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
|
||||
return new[] { AppRoles.ReadOnly };
|
||||
}
|
||||
|
||||
var roles = new List<string>();
|
||||
for (var i = 0; i < memberOf.Count; i++)
|
||||
{
|
||||
var dn = memberOf[i]?.ToString() ?? "";
|
||||
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
|
||||
var groupName = ExtractGroupName(dn);
|
||||
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role);
|
||||
}
|
||||
|
||||
if (roles.Count == 0)
|
||||
{
|
||||
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
|
||||
roles.Add(AppRoles.ReadOnly);
|
||||
}
|
||||
|
||||
Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
|
||||
return new[] { AppRoles.ReadOnly };
|
||||
}
|
||||
}
|
||||
|
||||
public bool ValidateCredentials(string username, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
|
||||
using (var connection = CreateConnection())
|
||||
{
|
||||
connection.Bind(new NetworkCredential(bindDn, password));
|
||||
}
|
||||
|
||||
Log.Debug("LDAP bind succeeded for {Username}", username);
|
||||
return true;
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private LdapConnection CreateConnection()
|
||||
{
|
||||
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
|
||||
var connection = new LdapConnection(identifier)
|
||||
{
|
||||
AuthType = AuthType.Basic,
|
||||
Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds)
|
||||
};
|
||||
connection.SessionOptions.ProtocolVersion = 3;
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static string? ExtractGroupName(string dn)
|
||||
{
|
||||
// Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..."
|
||||
if (string.IsNullOrEmpty(dn)) return null;
|
||||
var parts = dn.Split(',');
|
||||
if (parts.Length == 0) return null;
|
||||
var first = parts[0].Trim();
|
||||
var eqIdx = first.IndexOf('=');
|
||||
return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null;
|
||||
}
|
||||
|
||||
private static string EscapeLdapFilter(string input)
|
||||
{
|
||||
return input
|
||||
.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
|
||||
/// The namespace URI is registered in the server namespace table at startup,
|
||||
/// and the string identifiers are resolved to runtime NodeIds before use.
|
||||
/// </summary>
|
||||
public static class LmxRoleIds
|
||||
{
|
||||
public const string NamespaceUri = "urn:zbmom:lmxopcua:roles";
|
||||
|
||||
public const string ReadOnly = "Role.ReadOnly";
|
||||
public const string WriteOperate = "Role.WriteOperate";
|
||||
public const string WriteTune = "Role.WriteTune";
|
||||
public const string WriteConfigure = "Role.WriteConfigure";
|
||||
public const string AlarmAck = "Role.AlarmAck";
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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>
|
||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
||||
/// <returns>The OPC UA built-in data type node identifier.</returns>
|
||||
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>
|
||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
||||
/// <returns>The CLR type used to represent runtime values for the MX type.</returns>
|
||||
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>
|
||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
||||
/// <returns>The OPC UA type name used in diagnostics.</returns>
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
|
||||
/// </summary>
|
||||
public static class MxErrorCodes
|
||||
{
|
||||
/// <summary>
|
||||
/// The requested Galaxy attribute reference does not resolve in the runtime.
|
||||
/// </summary>
|
||||
public const int MX_E_InvalidReference = 1008;
|
||||
|
||||
/// <summary>
|
||||
/// The supplied value does not match the attribute's configured data type.
|
||||
/// </summary>
|
||||
public const int MX_E_WrongDataType = 1012;
|
||||
|
||||
/// <summary>
|
||||
/// The target attribute cannot be written because it is read-only or protected.
|
||||
/// </summary>
|
||||
public const int MX_E_NotWritable = 1013;
|
||||
|
||||
/// <summary>
|
||||
/// The runtime did not complete the operation within the configured timeout.
|
||||
/// </summary>
|
||||
public const int MX_E_RequestTimedOut = 1014;
|
||||
|
||||
/// <summary>
|
||||
/// Communication with the MXAccess runtime failed during the operation.
|
||||
/// </summary>
|
||||
public const int MX_E_CommFailure = 1015;
|
||||
|
||||
/// <summary>
|
||||
/// The operation was attempted without an active MXAccess session.
|
||||
/// </summary>
|
||||
public const int MX_E_NotConnected = 1016;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a numeric MXAccess error code into an operator-facing message.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
|
||||
/// <returns>A human-readable description of the runtime failure.</returns>
|
||||
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}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
|
||||
/// <returns>The quality classification that best represents the runtime failure.</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a deployed Galaxy platform to the hostname where it executes.
|
||||
/// </summary>
|
||||
public class PlatformInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the gobject_id of the platform object in the Galaxy repository.
|
||||
/// </summary>
|
||||
public int GobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hostname (node_name) where the platform is deployed.
|
||||
/// </summary>
|
||||
public string NodeName { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.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)
|
||||
/// <summary>
|
||||
/// No valid process value is available.
|
||||
/// </summary>
|
||||
Bad = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The value is invalid because the Galaxy attribute definition or mapping is wrong.
|
||||
/// </summary>
|
||||
BadConfigError = 4,
|
||||
|
||||
/// <summary>
|
||||
/// The bridge is not currently connected to the Galaxy runtime.
|
||||
/// </summary>
|
||||
BadNotConnected = 8,
|
||||
|
||||
/// <summary>
|
||||
/// The runtime device or adapter failed while obtaining the value.
|
||||
/// </summary>
|
||||
BadDeviceFailure = 12,
|
||||
|
||||
/// <summary>
|
||||
/// The underlying field source reported a bad sensor condition.
|
||||
/// </summary>
|
||||
BadSensorFailure = 16,
|
||||
|
||||
/// <summary>
|
||||
/// Communication with the runtime failed while retrieving the value.
|
||||
/// </summary>
|
||||
BadCommFailure = 20,
|
||||
|
||||
/// <summary>
|
||||
/// The attribute is intentionally unavailable for service, such as a locked or unwritable value.
|
||||
/// </summary>
|
||||
BadOutOfService = 24,
|
||||
|
||||
/// <summary>
|
||||
/// The bridge is still waiting for the first usable value after startup or resubscription.
|
||||
/// </summary>
|
||||
BadWaitingForInitialData = 32,
|
||||
|
||||
// Uncertain family (64-191)
|
||||
/// <summary>
|
||||
/// A value is available, but it should be treated cautiously.
|
||||
/// </summary>
|
||||
Uncertain = 64,
|
||||
|
||||
/// <summary>
|
||||
/// The last usable value is being repeated because a newer one is unavailable.
|
||||
/// </summary>
|
||||
UncertainLastUsable = 68,
|
||||
|
||||
/// <summary>
|
||||
/// The sensor or source is providing a value with reduced accuracy.
|
||||
/// </summary>
|
||||
UncertainSensorNotAccurate = 80,
|
||||
|
||||
/// <summary>
|
||||
/// The value exceeds its engineered limits.
|
||||
/// </summary>
|
||||
UncertainEuExceeded = 84,
|
||||
|
||||
/// <summary>
|
||||
/// The source is operating in a degraded or subnormal state.
|
||||
/// </summary>
|
||||
UncertainSubNormal = 88,
|
||||
|
||||
// Good family (192+)
|
||||
/// <summary>
|
||||
/// The value is current and suitable for normal client use.
|
||||
/// </summary>
|
||||
Good = 192,
|
||||
|
||||
/// <summary>
|
||||
/// The value is good but currently overridden locally rather than flowing from the live source.
|
||||
/// </summary>
|
||||
GoodLocalOverride = 216
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for reasoning about OPC quality families used by the bridge.
|
||||
/// </summary>
|
||||
public static class QualityExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
|
||||
/// </summary>
|
||||
/// <param name="q">The quality code to inspect.</param>
|
||||
/// <returns><see langword="true" /> when the value is in the good quality range; otherwise, <see langword="false" />.</returns>
|
||||
public static bool IsGood(this Quality q)
|
||||
{
|
||||
return (byte)q >= 192;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the quality represents an uncertain runtime value that should be treated cautiously.
|
||||
/// </summary>
|
||||
/// <param name="q">The quality code to inspect.</param>
|
||||
/// <returns><see langword="true" /> when the value is in the uncertain range; otherwise, <see langword="false" />.</returns>
|
||||
public static bool IsUncertain(this Quality q)
|
||||
{
|
||||
return (byte)q >= 64 && (byte)q < 192;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the quality represents a bad runtime value that should not be used as valid process data.
|
||||
/// </summary>
|
||||
/// <param name="q">The quality code to inspect.</param>
|
||||
/// <returns><see langword="true" /> when the value is in the bad range; otherwise, <see langword="false" />.</returns>
|
||||
public static bool IsBad(this Quality q)
|
||||
{
|
||||
return (byte)q < 64;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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>
|
||||
/// <param name="mxQuality">The raw MXAccess quality integer.</param>
|
||||
/// <returns>The mapped bridge quality value.</returns>
|
||||
public static Quality MapFromMxAccessQuality(int mxQuality)
|
||||
{
|
||||
var b = (byte)(mxQuality & 0xFF);
|
||||
|
||||
// Try exact match first
|
||||
if (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>
|
||||
/// <param name="quality">The bridge quality value.</param>
|
||||
/// <returns>The OPC UA status code represented as a 32-bit unsigned integer.</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps Galaxy security classification values to OPC UA write access decisions.
|
||||
/// See gr/data_type_mapping.md for the full mapping table.
|
||||
/// </summary>
|
||||
public static class SecurityClassificationMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether an attribute with the given security classification should allow writes.
|
||||
/// </summary>
|
||||
/// <param name="securityClassification">The Galaxy security classification value.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true" /> for FreeAccess (0), Operate (1), Tune (4), Configure (5);
|
||||
/// <see langword="false" /> for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).
|
||||
/// </returns>
|
||||
public static bool IsWritable(int securityClassification)
|
||||
{
|
||||
switch (securityClassification)
|
||||
{
|
||||
case 2: // SecuredWrite
|
||||
case 3: // VerifiedWrite
|
||||
case 6: // ViewOnly
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
|
||||
/// </summary>
|
||||
public readonly struct Vtq : IEquatable<Vtq>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the runtime value returned for the Galaxy attribute.
|
||||
/// </summary>
|
||||
public object? Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp associated with the runtime value.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
|
||||
/// </summary>
|
||||
public Quality Quality { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Vtq" /> struct for a Galaxy attribute value.
|
||||
/// </summary>
|
||||
/// <param name="value">The runtime value returned by MXAccess.</param>
|
||||
/// <param name="timestamp">The timestamp assigned to the runtime value.</param>
|
||||
/// <param name="quality">The quality classification for the runtime value.</param>
|
||||
public Vtq(object? value, DateTime timestamp, Quality quality)
|
||||
{
|
||||
Value = value;
|
||||
Timestamp = timestamp;
|
||||
Quality = quality;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
|
||||
/// </summary>
|
||||
/// <param name="value">The runtime value to wrap.</param>
|
||||
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
|
||||
public static Vtq Good(object? value)
|
||||
{
|
||||
return new Vtq(value, DateTime.UtcNow, Quality.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bad-quality VTQ snapshot when no usable runtime value is available.
|
||||
/// </summary>
|
||||
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
|
||||
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
|
||||
public static Vtq Bad(Quality quality = Quality.Bad)
|
||||
{
|
||||
return new Vtq(null, DateTime.UtcNow, quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously.
|
||||
/// </summary>
|
||||
/// <param name="value">The runtime value to wrap.</param>
|
||||
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
|
||||
public static Vtq Uncertain(object? value)
|
||||
{
|
||||
return new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
|
||||
/// </summary>
|
||||
/// <param name="other">The other VTQ snapshot to compare.</param>
|
||||
/// <returns><see langword="true" /> when all fields match; otherwise, <see langword="false" />.</returns>
|
||||
public bool Equals(Vtq other)
|
||||
{
|
||||
return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is Vtq other && Equals(other);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Value, Timestamp, Quality);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Vtq({Value}, {Timestamp:O}, {Quality})";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<Costura>
|
||||
<ExcludeAssemblies>
|
||||
ArchestrA.MxAccess
|
||||
</ExcludeAssemblies>
|
||||
</Costura>
|
||||
</Weavers>
|
||||
@@ -1,176 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
|
||||
<xs:element name="Weavers">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX86Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinArm64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DisableCompression" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DisableCleanup" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DisableEventSubscription" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ExcludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UnmanagedWinX86Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UnmanagedWinX64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UnmanagedWinArm64Assemblies" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="PreloadOrder" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
<xs:attribute name="VerifyAssembly" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="GenerateXsd" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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 int _intervalSeconds;
|
||||
|
||||
private readonly IGalaxyRepository _repository;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pollTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new change detector for Galaxy deploy timestamps.
|
||||
/// </summary>
|
||||
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
|
||||
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
|
||||
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
|
||||
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds,
|
||||
DateTime? initialDeployTime = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_intervalSeconds = intervalSeconds;
|
||||
LastKnownDeployTime = initialDeployTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last deploy timestamp observed by the polling loop.
|
||||
/// </summary>
|
||||
public DateTime? LastKnownDeployTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stops the polling loop and disposes the underlying cancellation resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
|
||||
/// </summary>
|
||||
public event Action? OnGalaxyChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background polling loop that watches for Galaxy deploy changes.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_cts != null)
|
||||
Stop();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_pollTask = Task.Run(() => PollLoopAsync(_cts.Token));
|
||||
Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the background polling loop.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
try { _pollTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
|
||||
_pollTask = null;
|
||||
Log.Information("Change detection stopped");
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(CancellationToken ct)
|
||||
{
|
||||
// If no initial deploy time was provided, first poll triggers unconditionally
|
||||
var firstPoll = LastKnownDeployTime == null;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SqlClient;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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;
|
||||
|
||||
/// <summary>
|
||||
/// When <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering is active, caches the set of
|
||||
/// gobject_ids that passed the hierarchy filter so <see cref="GetAttributesAsync" /> can apply the same scope.
|
||||
/// Populated by <see cref="GetHierarchyAsync" /> and consumed by <see cref="GetAttributesAsync" />.
|
||||
/// </summary>
|
||||
private HashSet<int>? _scopeFilteredGobjectIds;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
|
||||
/// </summary>
|
||||
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
|
||||
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
|
||||
/// </summary>
|
||||
public event Action? OnGalaxyChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the database query.</param>
|
||||
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
|
||||
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<GalaxyObjectInfo>();
|
||||
|
||||
using var conn = new SqlConnection(_config.ConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var templateChainRaw = reader.IsDBNull(8) ? "" : reader.GetString(8);
|
||||
var templateChain = string.IsNullOrEmpty(templateChainRaw)
|
||||
? new List<string>()
|
||||
: templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
|
||||
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,
|
||||
CategoryId = Convert.ToInt32(reader.GetValue(6)),
|
||||
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
|
||||
TemplateChain = templateChain
|
||||
});
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
Log.Warning("GetHierarchyAsync returned zero rows");
|
||||
else
|
||||
Log.Information("GetHierarchyAsync returned {Count} objects", results.Count);
|
||||
|
||||
if (_config.Scope == GalaxyScope.LocalPlatform)
|
||||
{
|
||||
var platforms = await GetPlatformsAsync(ct);
|
||||
var platformName = string.IsNullOrWhiteSpace(_config.PlatformName)
|
||||
? Environment.MachineName
|
||||
: _config.PlatformName;
|
||||
var (filtered, gobjectIds) = PlatformScopeFilter.Filter(results, platforms, platformName);
|
||||
_scopeFilteredGobjectIds = gobjectIds;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
_scopeFilteredGobjectIds = null;
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the database query.</param>
|
||||
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
|
||||
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<GalaxyAttributeInfo>();
|
||||
var extended = _config.ExtendedAttributes;
|
||||
var sql = extended ? ExtendedAttributesSql : AttributesSql;
|
||||
|
||||
using var conn = new SqlConnection(_config.ConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader));
|
||||
|
||||
Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count,
|
||||
extended);
|
||||
|
||||
if (_config.Scope == GalaxyScope.LocalPlatform && _scopeFilteredGobjectIds != null)
|
||||
return PlatformScopeFilter.FilterAttributes(results, _scopeFilteredGobjectIds);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the database query.</param>
|
||||
/// <returns>The most recent deploy timestamp, or <see langword="null" /> when none is available.</returns>
|
||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = new SqlConnection(_config.ConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
using var cmd = new SqlCommand(ChangeDetectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
|
||||
return result is DateTime dt ? dt : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a lightweight query to confirm that the repository database is reachable.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the connectivity check.</param>
|
||||
/// <returns><see langword="true" /> when the query succeeds; otherwise, <see langword="false" />.</returns>
|
||||
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(_config.ConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
using var cmd = new SqlCommand(TestConnectionSql, conn)
|
||||
{ CommandTimeout = _config.CommandTimeoutSeconds };
|
||||
await cmd.ExecuteScalarAsync(ct);
|
||||
|
||||
Log.Information("Galaxy repository database connection successful");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Galaxy repository database connection failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries the platform table for deployed platform-to-hostname mappings used by
|
||||
/// <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering.
|
||||
/// </summary>
|
||||
private async Task<List<PlatformInfo>> GetPlatformsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<PlatformInfo>();
|
||||
|
||||
using var conn = new SqlConnection(_config.ConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
using var cmd = new SqlCommand(PlatformLookupSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(new PlatformInfo
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
NodeName = reader.IsDBNull(1) ? "" : reader.GetString(1)
|
||||
});
|
||||
}
|
||||
|
||||
Log.Information("GetPlatformsAsync returned {Count} platform(s)", results.Count);
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a row from the standard attributes query (12 columns).
|
||||
/// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type,
|
||||
/// data_type_name, is_array, array_dimension, mx_attribute_category,
|
||||
/// security_classification, is_historized, is_alarm
|
||||
/// </summary>
|
||||
private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader)
|
||||
{
|
||||
return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
TagName = reader.GetString(1),
|
||||
AttributeName = reader.GetString(2),
|
||||
FullTagReference = reader.GetString(3),
|
||||
MxDataType = Convert.ToInt32(reader.GetValue(4)),
|
||||
DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5),
|
||||
IsArray = Convert.ToBoolean(reader.GetValue(6)),
|
||||
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
|
||||
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
|
||||
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
|
||||
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a row from the extended attributes query (14 columns).
|
||||
/// Columns: gobject_id, tag_name, primitive_name, attribute_name, full_tag_reference,
|
||||
/// mx_data_type, data_type_name, is_array, array_dimension,
|
||||
/// mx_attribute_category, security_classification, is_historized, is_alarm, attribute_source
|
||||
/// </summary>
|
||||
private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader)
|
||||
{
|
||||
return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
TagName = reader.GetString(1),
|
||||
PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2),
|
||||
AttributeName = reader.GetString(3),
|
||||
FullTagReference = reader.GetString(4),
|
||||
MxDataType = Convert.ToInt32(reader.GetValue(5)),
|
||||
DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6),
|
||||
IsArray = Convert.ToBoolean(reader.GetValue(7)),
|
||||
ArrayDimension = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)),
|
||||
SecurityClassification = Convert.ToInt32(reader.GetValue(10)),
|
||||
IsHistorized = Convert.ToInt32(reader.GetValue(11)) == 1,
|
||||
IsAlarm = Convert.ToInt32(reader.GetValue(12)) == 1,
|
||||
AttributeSource = reader.IsDBNull(13) ? "" : reader.GetString(13)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
|
||||
/// </summary>
|
||||
public void RaiseGalaxyChanged()
|
||||
{
|
||||
OnGalaxyChanged?.Invoke();
|
||||
}
|
||||
|
||||
#region SQL Queries (GR-006: const string, no dynamic SQL)
|
||||
|
||||
private const string HierarchySql = @"
|
||||
;WITH template_chain AS (
|
||||
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
||||
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
|
||||
UNION ALL
|
||||
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, 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,
|
||||
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,
|
||||
td.category_id AS category_id,
|
||||
g.hosted_by_gobject_id AS hosted_by_gobject_id,
|
||||
ISNULL(
|
||||
STUFF((
|
||||
SELECT '|' + tc.template_tag_name
|
||||
FROM template_chain tc
|
||||
WHERE tc.instance_gobject_id = g.gobject_id
|
||||
ORDER BY tc.depth
|
||||
FOR XML PATH('')
|
||||
), 1, 1, ''),
|
||||
''
|
||||
) AS template_chain
|
||||
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 deployed_package_chain AS (
|
||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||
UNION ALL
|
||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||
)
|
||||
SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
|
||||
mx_data_type, data_type_name, is_array, array_dimension,
|
||||
mx_attribute_category, security_classification, is_historized, is_alarm
|
||||
FROM (
|
||||
SELECT
|
||||
dpc.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,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
||||
) THEN 1 ELSE 0 END AS is_historized,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
||||
) THEN 1 ELSE 0 END AS is_alarm,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||
ORDER BY dpc.depth
|
||||
) AS rn
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dynamic_attribute da
|
||||
ON da.package_id = dpc.package_id
|
||||
INNER JOIN gobject g
|
||||
ON g.gobject_id = dpc.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 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)
|
||||
) ranked
|
||||
WHERE rn = 1
|
||||
ORDER BY tag_name, attribute_name";
|
||||
|
||||
private const string ExtendedAttributesSql = @"
|
||||
;WITH deployed_package_chain AS (
|
||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||
UNION ALL
|
||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||
),
|
||||
ranked_dynamic AS (
|
||||
SELECT
|
||||
dpc.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,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
||||
) THEN 1 ELSE 0 END AS is_historized,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
||||
) THEN 1 ELSE 0 END AS is_alarm,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||
ORDER BY dpc.depth
|
||||
) AS rn
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dynamic_attribute da
|
||||
ON da.package_id = dpc.package_id
|
||||
INNER JOIN gobject g
|
||||
ON g.gobject_id = dpc.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 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)
|
||||
)
|
||||
SELECT
|
||||
gobject_id,
|
||||
tag_name,
|
||||
primitive_name,
|
||||
attribute_name,
|
||||
full_tag_reference,
|
||||
mx_data_type,
|
||||
data_type_name,
|
||||
is_array,
|
||||
array_dimension,
|
||||
mx_attribute_category,
|
||||
security_classification,
|
||||
is_historized,
|
||||
is_alarm,
|
||||
attribute_source
|
||||
FROM (
|
||||
SELECT
|
||||
g.gobject_id,
|
||||
g.tag_name,
|
||||
pi.primitive_name,
|
||||
ad.attribute_name,
|
||||
CASE WHEN pi.primitive_name = ''
|
||||
THEN g.tag_name + '.' + ad.attribute_name
|
||||
ELSE g.tag_name + '.' + pi.primitive_name + '.' + ad.attribute_name
|
||||
END + CASE WHEN ad.is_array = 1 THEN '[]' ELSE '' END
|
||||
AS full_tag_reference,
|
||||
ad.mx_data_type,
|
||||
dt.description AS data_type_name,
|
||||
ad.is_array,
|
||||
CASE WHEN ad.is_array = 1
|
||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||
SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
|
||||
ELSE NULL
|
||||
END AS array_dimension,
|
||||
ad.mx_attribute_category,
|
||||
ad.security_classification,
|
||||
CAST(0 AS int) AS is_historized,
|
||||
CAST(0 AS int) AS is_alarm,
|
||||
'primitive' AS attribute_source
|
||||
FROM gobject g
|
||||
INNER JOIN instance i
|
||||
ON i.gobject_id = g.gobject_id
|
||||
INNER JOIN template_definition td
|
||||
ON td.template_definition_id = g.template_definition_id
|
||||
AND td.runtime_clsid <> '{00000000-0000-0000-0000-000000000000}'
|
||||
INNER JOIN package p
|
||||
ON p.package_id = g.deployed_package_id
|
||||
INNER JOIN primitive_instance pi
|
||||
ON pi.package_id = p.package_id
|
||||
AND pi.property_bitmask & 0x10 <> 0x10
|
||||
INNER JOIN attribute_definition ad
|
||||
ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||
AND ad.attribute_name NOT LIKE '[_]%'
|
||||
AND ad.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||
LEFT JOIN data_type dt
|
||||
ON dt.mx_data_type = ad.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
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
gobject_id,
|
||||
tag_name,
|
||||
'' AS primitive_name,
|
||||
attribute_name,
|
||||
full_tag_reference,
|
||||
mx_data_type,
|
||||
data_type_name,
|
||||
is_array,
|
||||
array_dimension,
|
||||
mx_attribute_category,
|
||||
security_classification,
|
||||
is_historized,
|
||||
is_alarm,
|
||||
'dynamic' AS attribute_source
|
||||
FROM ranked_dynamic
|
||||
WHERE rn = 1
|
||||
) all_attributes
|
||||
ORDER BY tag_name, primitive_name, attribute_name";
|
||||
|
||||
private const string PlatformLookupSql = @"
|
||||
SELECT p.platform_gobject_id, p.node_name
|
||||
FROM platform p
|
||||
INNER JOIN gobject g ON g.gobject_id = p.platform_gobject_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0";
|
||||
|
||||
private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy";
|
||||
|
||||
private const string TestConnectionSql = "SELECT 1";
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// POCO for dashboard: Galaxy repository status info. (DASH-009)
|
||||
/// </summary>
|
||||
public class GalaxyRepositoryStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy name currently being represented by the bridge.
|
||||
/// </summary>
|
||||
public string GalaxyName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
|
||||
/// </summary>
|
||||
public bool DbConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
|
||||
/// </summary>
|
||||
public DateTime? LastDeployTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
|
||||
/// </summary>
|
||||
public int ObjectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
|
||||
/// </summary>
|
||||
public int AttributeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
|
||||
/// </summary>
|
||||
public DateTime? LastRebuildTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters a Galaxy object hierarchy to retain only objects hosted by a specific platform
|
||||
/// and the structural areas needed to keep the browse tree connected.
|
||||
/// </summary>
|
||||
public static class PlatformScopeFilter
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter));
|
||||
|
||||
private const int CategoryWinPlatform = 1;
|
||||
private const int CategoryAppEngine = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Filters the hierarchy to objects hosted by the platform whose <c>node_name</c> matches
|
||||
/// <paramref name="platformName" />, plus ancestor areas that keep the tree connected.
|
||||
/// </summary>
|
||||
/// <param name="hierarchy">The full Galaxy object hierarchy.</param>
|
||||
/// <param name="platforms">Deployed platform-to-hostname mappings from the <c>platform</c> table.</param>
|
||||
/// <param name="platformName">The target hostname to match (case-insensitive).</param>
|
||||
/// <returns>
|
||||
/// The filtered hierarchy and the set of included gobject_ids (for attribute filtering).
|
||||
/// When no matching platform is found, returns an empty list and empty set.
|
||||
/// </returns>
|
||||
public static (List<GalaxyObjectInfo> Hierarchy, HashSet<int> GobjectIds) Filter(
|
||||
List<GalaxyObjectInfo> hierarchy,
|
||||
List<PlatformInfo> platforms,
|
||||
string platformName)
|
||||
{
|
||||
// Find the platform gobject_id that matches the target hostname.
|
||||
var matchingPlatform = platforms.FirstOrDefault(
|
||||
p => string.Equals(p.NodeName, platformName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingPlatform == null)
|
||||
{
|
||||
Log.Warning(
|
||||
"Scope filter found no deployed platform matching node name '{PlatformName}'; " +
|
||||
"available platforms: [{Available}]",
|
||||
platformName,
|
||||
string.Join(", ", platforms.Select(p => $"{p.NodeName} (gobject_id={p.GobjectId})")));
|
||||
return (new List<GalaxyObjectInfo>(), new HashSet<int>());
|
||||
}
|
||||
|
||||
var platformGobjectId = matchingPlatform.GobjectId;
|
||||
Log.Information(
|
||||
"Scope filter targeting platform '{PlatformName}' (gobject_id={GobjectId})",
|
||||
platformName, platformGobjectId);
|
||||
|
||||
// Build a lookup for the hierarchy by gobject_id.
|
||||
var byId = hierarchy.ToDictionary(o => o.GobjectId);
|
||||
|
||||
// Step 1: Collect all host gobject_ids under this platform.
|
||||
// Walk outward from the platform to find AppEngines (and any deeper hosting objects).
|
||||
var hostIds = new HashSet<int> { platformGobjectId };
|
||||
bool changed;
|
||||
do
|
||||
{
|
||||
changed = false;
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
if (hostIds.Contains(obj.GobjectId))
|
||||
continue;
|
||||
if (obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId)
|
||||
&& (obj.CategoryId == CategoryAppEngine || obj.CategoryId == CategoryWinPlatform))
|
||||
{
|
||||
hostIds.Add(obj.GobjectId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} while (changed);
|
||||
|
||||
// Step 2: Include all non-area objects hosted by any host in the set, plus the hosts themselves.
|
||||
var includedIds = new HashSet<int>(hostIds);
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
if (includedIds.Contains(obj.GobjectId))
|
||||
continue;
|
||||
if (!obj.IsArea && obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId))
|
||||
includedIds.Add(obj.GobjectId);
|
||||
}
|
||||
|
||||
// Step 3: Walk ParentGobjectId chains upward to include ancestor areas so the tree stays connected.
|
||||
var toWalk = new Queue<int>(includedIds);
|
||||
while (toWalk.Count > 0)
|
||||
{
|
||||
var id = toWalk.Dequeue();
|
||||
if (!byId.TryGetValue(id, out var obj))
|
||||
continue;
|
||||
var parentId = obj.ParentGobjectId;
|
||||
if (parentId != 0 && byId.ContainsKey(parentId) && includedIds.Add(parentId))
|
||||
toWalk.Enqueue(parentId);
|
||||
}
|
||||
|
||||
// Step 4: Return the filtered hierarchy preserving original order.
|
||||
var filtered = hierarchy.Where(o => includedIds.Contains(o.GobjectId)).ToList();
|
||||
|
||||
Log.Information(
|
||||
"Scope filter retained {FilteredCount} of {TotalCount} objects for platform '{PlatformName}'",
|
||||
filtered.Count, hierarchy.Count, platformName);
|
||||
|
||||
return (filtered, includedIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters attributes to retain only those belonging to objects in the given set.
|
||||
/// </summary>
|
||||
public static List<GalaxyAttributeInfo> FilterAttributes(
|
||||
List<GalaxyAttributeInfo> attributes,
|
||||
HashSet<int> gobjectIds)
|
||||
{
|
||||
var filtered = attributes.Where(a => gobjectIds.Contains(a.GobjectId)).ToList();
|
||||
Log.Information(
|
||||
"Scope filter retained {FilteredCount} of {TotalCount} attributes",
|
||||
filtered.Count, attributes.Count);
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps OPC UA aggregate NodeIds to the Wonderware Historian AnalogSummary column names
|
||||
/// consumed by the historian plugin. Kept in Host so HistoryReadProcessed can validate
|
||||
/// aggregate support without requiring the plugin to be loaded.
|
||||
/// </summary>
|
||||
public static class HistorianAggregateMap
|
||||
{
|
||||
public static string? MapAggregateToColumn(NodeId aggregateId)
|
||||
{
|
||||
if (aggregateId == ObjectIds.AggregateFunction_Average)
|
||||
return "Average";
|
||||
if (aggregateId == ObjectIds.AggregateFunction_Minimum)
|
||||
return "Minimum";
|
||||
if (aggregateId == ObjectIds.AggregateFunction_Maximum)
|
||||
return "Maximum";
|
||||
if (aggregateId == ObjectIds.AggregateFunction_Count)
|
||||
return "ValueCount";
|
||||
if (aggregateId == ObjectIds.AggregateFunction_Start)
|
||||
return "First";
|
||||
if (aggregateId == ObjectIds.AggregateFunction_End)
|
||||
return "Last";
|
||||
if (aggregateId == ObjectIds.AggregateFunction_StandardDeviationPopulation)
|
||||
return "StdDev";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time state of a single historian cluster node. One entry per configured node is
|
||||
/// surfaced inside <see cref="HistorianHealthSnapshot"/> so the status dashboard can render
|
||||
/// per-node health and operators can see which nodes are in cooldown.
|
||||
/// </summary>
|
||||
public sealed class HistorianClusterNodeState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the configured node hostname exactly as it appears in
|
||||
/// <c>HistorianConfiguration.ServerNames</c>.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the node is currently eligible for new connection
|
||||
/// attempts. <see langword="false"/> means the node is in its post-failure cooldown window
|
||||
/// and the picker is skipping it.
|
||||
/// </summary>
|
||||
public bool IsHealthy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp at which the node's cooldown expires, or
|
||||
/// <see langword="null"/> when the node is not in cooldown.
|
||||
/// </summary>
|
||||
public DateTime? CooldownUntil { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of times this node has transitioned from healthy to failed
|
||||
/// since startup. Does not decrement on recovery.
|
||||
/// </summary>
|
||||
public int FailureCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message from the most recent failure, or <see langword="null"/> when
|
||||
/// the node has never failed.
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the most recent failure, or <see langword="null"/>
|
||||
/// when the node has never failed.
|
||||
/// </summary>
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK-free representation of a Historian event record exposed by the historian plugin.
|
||||
/// Prevents ArchestrA types from leaking into the Host assembly.
|
||||
/// </summary>
|
||||
public sealed class HistorianEventDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Source { get; set; }
|
||||
public DateTime EventTime { get; set; }
|
||||
public DateTime ReceivedTime { get; set; }
|
||||
public string? DisplayText { get; set; }
|
||||
public ushort Severity { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time runtime health of the historian plugin, surfaced to the status dashboard
|
||||
/// and health check service. Fills the gap between the load-time plugin status
|
||||
/// (<see cref="HistorianPluginLoader.LastOutcome"/>) and actual query behavior so operators
|
||||
/// can detect silent query degradation.
|
||||
/// </summary>
|
||||
public sealed class HistorianHealthSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of historian read operations attempted since startup
|
||||
/// across all read paths (raw, aggregate, at-time, events).
|
||||
/// </summary>
|
||||
public long TotalQueries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of read operations that completed without an exception
|
||||
/// being caught by the plugin's error handler. Includes empty result sets as successes —
|
||||
/// the counter reflects "the SDK call returned" not "the SDK call returned data".
|
||||
/// </summary>
|
||||
public long TotalSuccesses { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of read operations that raised an exception. Each failure
|
||||
/// also resets and closes the underlying SDK connection via the existing reconnect path.
|
||||
/// </summary>
|
||||
public long TotalFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of consecutive failures since the last success. Latches until
|
||||
/// a successful query clears it. The health check service uses this as a degradation signal.
|
||||
/// </summary>
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the last successful read, or <see langword="null"/>
|
||||
/// when no query has succeeded since startup.
|
||||
/// </summary>
|
||||
public DateTime? LastSuccessTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the last failure, or <see langword="null"/> when no
|
||||
/// query has failed since startup.
|
||||
/// </summary>
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the exception message from the most recent failure. Cleared on the next
|
||||
/// successful query.
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
|
||||
/// connection for the process (historical values) path.
|
||||
/// </summary>
|
||||
public bool ProcessConnectionOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
|
||||
/// connection for the event (alarm history) path.
|
||||
/// </summary>
|
||||
public bool EventConnectionOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the node the plugin is currently connected to for the process path,
|
||||
/// or <see langword="null"/> when no connection is open.
|
||||
/// </summary>
|
||||
public string? ActiveProcessNode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the node the plugin is currently connected to for the event path,
|
||||
/// or <see langword="null"/> when no event connection is open.
|
||||
/// </summary>
|
||||
public string? ActiveEventNode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of configured historian cluster nodes. A value of 1
|
||||
/// reflects a legacy single-node deployment.
|
||||
/// </summary>
|
||||
public int NodeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of configured nodes that are currently healthy (not in cooldown).
|
||||
/// </summary>
|
||||
public int HealthyNodeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-node cluster state in configuration order.
|
||||
/// </summary>
|
||||
public List<HistorianClusterNodeState> Nodes { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of the most recent historian plugin load attempt.
|
||||
/// </summary>
|
||||
public enum HistorianPluginStatus
|
||||
{
|
||||
/// <summary>Historian.Enabled is false; TryLoad was not called.</summary>
|
||||
Disabled,
|
||||
/// <summary>Plugin DLL was not present in the Historian/ subfolder.</summary>
|
||||
NotFound,
|
||||
/// <summary>Plugin file exists but could not be loaded or instantiated.</summary>
|
||||
LoadFailed,
|
||||
/// <summary>Plugin loaded and an IHistorianDataSource was constructed.</summary>
|
||||
Loaded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured outcome of a <see cref="HistorianPluginLoader.TryLoad"/> or
|
||||
/// <see cref="HistorianPluginLoader.MarkDisabled"/> call, used by the status dashboard.
|
||||
/// </summary>
|
||||
public sealed class HistorianPluginOutcome
|
||||
{
|
||||
public HistorianPluginOutcome(HistorianPluginStatus status, string pluginPath, string? error)
|
||||
{
|
||||
Status = status;
|
||||
PluginPath = pluginPath;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public HistorianPluginStatus Status { get; }
|
||||
public string PluginPath { get; }
|
||||
public string? Error { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the Wonderware historian plugin assembly from the Historian/ subfolder next to
|
||||
/// the host executable. Used so the aahClientManaged SDK is not needed on hosts that run
|
||||
/// with Historian.Enabled=false.
|
||||
/// </summary>
|
||||
public static class HistorianPluginLoader
|
||||
{
|
||||
private const string PluginSubfolder = "Historian";
|
||||
private const string PluginAssemblyName = "ZB.MOM.WW.OtOpcUa.Historian.Aveva";
|
||||
private const string PluginEntryType = "ZB.MOM.WW.OtOpcUa.Historian.Aveva.AvevaHistorianPluginEntry";
|
||||
private const string PluginEntryMethod = "Create";
|
||||
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(HistorianPluginLoader));
|
||||
private static readonly object ResolverGate = new object();
|
||||
private static bool _resolverInstalled;
|
||||
private static string? _resolvedProbeDirectory;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome of the most recent load attempt (or <see cref="HistorianPluginStatus.Disabled"/>
|
||||
/// if the loader has never been invoked). The dashboard reads this to distinguish "disabled",
|
||||
/// "plugin missing", and "plugin crashed".
|
||||
/// </summary>
|
||||
public static HistorianPluginOutcome LastOutcome { get; private set; }
|
||||
= new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
|
||||
|
||||
/// <summary>
|
||||
/// Records that the historian plugin is disabled by configuration. Called by
|
||||
/// <c>OpcUaService</c> when <c>Historian.Enabled=false</c> so the status dashboard can
|
||||
/// report the exact reason history is unavailable.
|
||||
/// </summary>
|
||||
public static void MarkDisabled()
|
||||
{
|
||||
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to load the historian plugin and construct an <see cref="IHistorianDataSource"/>.
|
||||
/// Returns null on any failure so the server can continue with history unsupported. The
|
||||
/// specific reason is published on <see cref="LastOutcome"/>.
|
||||
/// </summary>
|
||||
public static IHistorianDataSource? TryLoad(HistorianConfiguration config)
|
||||
{
|
||||
var pluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, PluginSubfolder);
|
||||
var pluginPath = Path.Combine(pluginDirectory, PluginAssemblyName + ".dll");
|
||||
|
||||
if (!File.Exists(pluginPath))
|
||||
{
|
||||
Log.Warning(
|
||||
"Historian plugin not found at {PluginPath} — history read operations will return BadHistoryOperationUnsupported",
|
||||
pluginPath);
|
||||
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.NotFound, pluginPath, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
EnsureAssemblyResolverInstalled(pluginDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(pluginPath);
|
||||
var entryType = assembly.GetType(PluginEntryType, throwOnError: false);
|
||||
if (entryType == null)
|
||||
{
|
||||
Log.Warning("Historian plugin {PluginPath} does not expose {EntryType}", pluginPath, PluginEntryType);
|
||||
LastOutcome = new HistorianPluginOutcome(
|
||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
||||
$"Plugin assembly does not expose entry type {PluginEntryType}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var create = entryType.GetMethod(PluginEntryMethod, BindingFlags.Public | BindingFlags.Static);
|
||||
if (create == null)
|
||||
{
|
||||
Log.Warning("Historian plugin entry type {EntryType} missing static {Method}", PluginEntryType, PluginEntryMethod);
|
||||
LastOutcome = new HistorianPluginOutcome(
|
||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
||||
$"Plugin entry type {PluginEntryType} is missing a public static {PluginEntryMethod} method");
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = create.Invoke(null, new object[] { config });
|
||||
if (result is IHistorianDataSource dataSource)
|
||||
{
|
||||
Log.Information("Historian plugin loaded from {PluginPath}", pluginPath);
|
||||
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Loaded, pluginPath, null);
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
Log.Warning("Historian plugin {PluginPath} returned an object that does not implement IHistorianDataSource", pluginPath);
|
||||
LastOutcome = new HistorianPluginOutcome(
|
||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
||||
"Plugin entry method returned an object that does not implement IHistorianDataSource");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to load historian plugin from {PluginPath} — history disabled", pluginPath);
|
||||
LastOutcome = new HistorianPluginOutcome(
|
||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
||||
ex.GetBaseException().Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureAssemblyResolverInstalled(string pluginDirectory)
|
||||
{
|
||||
lock (ResolverGate)
|
||||
{
|
||||
_resolvedProbeDirectory = pluginDirectory;
|
||||
if (_resolverInstalled)
|
||||
return;
|
||||
|
||||
AppDomain.CurrentDomain.AssemblyResolve += ResolveFromPluginDirectory;
|
||||
_resolverInstalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static Assembly? ResolveFromPluginDirectory(object? sender, ResolveEventArgs args)
|
||||
{
|
||||
var probeDirectory = _resolvedProbeDirectory;
|
||||
if (string.IsNullOrEmpty(probeDirectory))
|
||||
return null;
|
||||
|
||||
var requested = new AssemblyName(args.Name);
|
||||
var candidate = Path.Combine(probeDirectory!, requested.Name + ".dll");
|
||||
if (!File.Exists(candidate))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return Assembly.LoadFrom(candidate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Historian plugin resolver failed to load {Candidate}", candidate);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages continuation points for OPC UA HistoryRead requests that return
|
||||
/// more data than the per-request limit allows.
|
||||
/// </summary>
|
||||
internal sealed class HistoryContinuationPointManager
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<HistoryContinuationPointManager>();
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, StoredContinuation> _store = new();
|
||||
private readonly TimeSpan _timeout;
|
||||
|
||||
public HistoryContinuationPointManager() : this(TimeSpan.FromMinutes(5)) { }
|
||||
|
||||
internal HistoryContinuationPointManager(TimeSpan timeout)
|
||||
{
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores remaining data values and returns a continuation point identifier.
|
||||
/// </summary>
|
||||
public byte[] Store(List<DataValue> remaining)
|
||||
{
|
||||
PurgeExpired();
|
||||
var id = Guid.NewGuid();
|
||||
_store[id] = new StoredContinuation(remaining, DateTime.UtcNow);
|
||||
Log.Debug("Stored history continuation point {Id} with {Count} remaining values", id, remaining.Count);
|
||||
return id.ToByteArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves and removes the remaining data values for a continuation point.
|
||||
/// Returns null if the continuation point is invalid or expired.
|
||||
/// </summary>
|
||||
public List<DataValue>? Retrieve(byte[] continuationPoint)
|
||||
{
|
||||
PurgeExpired();
|
||||
if (continuationPoint == null || continuationPoint.Length != 16)
|
||||
return null;
|
||||
|
||||
var id = new Guid(continuationPoint);
|
||||
if (!_store.TryRemove(id, out var stored))
|
||||
return null;
|
||||
|
||||
if (DateTime.UtcNow - stored.CreatedAt > _timeout)
|
||||
{
|
||||
Log.Debug("History continuation point {Id} expired", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored.Values;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases a continuation point without retrieving its data.
|
||||
/// </summary>
|
||||
public void Release(byte[] continuationPoint)
|
||||
{
|
||||
PurgeExpired();
|
||||
if (continuationPoint == null || continuationPoint.Length != 16)
|
||||
return;
|
||||
|
||||
var id = new Guid(continuationPoint);
|
||||
_store.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
private void PurgeExpired()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow - _timeout;
|
||||
foreach (var kvp in _store)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < cutoff)
|
||||
_store.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StoredContinuation
|
||||
{
|
||||
public StoredContinuation(List<DataValue> values, DateTime createdAt)
|
||||
{
|
||||
Values = values;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
public List<DataValue> Values { get; }
|
||||
public DateTime CreatedAt { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC UA-typed surface for the historian plugin. Host consumers depend only on this
|
||||
/// interface so the Wonderware Historian SDK assemblies are not required unless the
|
||||
/// plugin is loaded at runtime.
|
||||
/// </summary>
|
||||
public interface IHistorianDataSource : IDisposable
|
||||
{
|
||||
Task<List<DataValue>> ReadRawAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<List<DataValue>> ReadAggregateAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime,
|
||||
double intervalMs, string aggregateColumn,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<List<DataValue>> ReadAtTimeAsync(
|
||||
string tagName, DateTime[] timestamps,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<List<HistorianEventDto>> ReadEventsAsync(
|
||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a runtime snapshot of query success/failure counters and connection state.
|
||||
/// Consumed by the status dashboard and health check service so operators can detect
|
||||
/// silent query degradation that the load-time plugin status can't catch.
|
||||
/// </summary>
|
||||
HistorianHealthSnapshot GetHealthSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
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.OtOpcUa.Host.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation" />. (MXA-008)
|
||||
/// </summary>
|
||||
public interface ITimingScope : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks whether the timed bridge operation completed successfully.
|
||||
/// </summary>
|
||||
/// <param name="success">A value indicating whether the measured operation succeeded.</param>
|
||||
void SetSuccess(bool success);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics snapshot for a single operation type.
|
||||
/// </summary>
|
||||
public class MetricsStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of recorded executions for the operation.
|
||||
/// </summary>
|
||||
public long TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of recorded executions that completed successfully.
|
||||
/// </summary>
|
||||
public long SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ratio of successful executions to total executions.
|
||||
/// </summary>
|
||||
public double SuccessRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
|
||||
/// </summary>
|
||||
public double AverageMilliseconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fastest recorded execution time in milliseconds.
|
||||
/// </summary>
|
||||
public double MinMilliseconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the slowest recorded execution time in milliseconds.
|
||||
/// </summary>
|
||||
public double MaxMilliseconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the 95th percentile execution time in milliseconds.
|
||||
/// </summary>
|
||||
public double Percentile95Milliseconds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008)
|
||||
/// </summary>
|
||||
public class OperationMetrics
|
||||
{
|
||||
private readonly List<double> _durations = new();
|
||||
private readonly object _lock = new();
|
||||
private double _maxMilliseconds;
|
||||
private double _minMilliseconds = double.MaxValue;
|
||||
private long _successCount;
|
||||
private long _totalCount;
|
||||
private double _totalMilliseconds;
|
||||
|
||||
/// <summary>
|
||||
/// Records the outcome and duration of a single bridge operation invocation.
|
||||
/// </summary>
|
||||
/// <param name="duration">The elapsed time for the operation.</param>
|
||||
/// <param name="success">A value indicating whether the operation completed successfully.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a snapshot of the current statistics for this operation type.
|
||||
/// </summary>
|
||||
/// <returns>A statistics snapshot suitable for logs, status reporting, and tests.</returns>
|
||||
public MetricsStatistics GetStatistics()
|
||||
{
|
||||
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(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Timer _reportingTimer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new metrics collector and starts periodic performance reporting.
|
||||
/// </summary>
|
||||
public PerformanceMetrics()
|
||||
{
|
||||
_reportingTimer = new Timer(ReportMetrics, null,
|
||||
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops periodic reporting and emits a final metrics snapshot.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_reportingTimer.Dispose();
|
||||
ReportMetrics(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed bridge operation under the specified metrics bucket.
|
||||
/// </summary>
|
||||
/// <param name="operationName">The logical operation name, such as read, write, or subscribe.</param>
|
||||
/// <param name="duration">The elapsed time for the operation.</param>
|
||||
/// <param name="success">A value indicating whether the operation completed successfully.</param>
|
||||
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
|
||||
{
|
||||
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
|
||||
metrics.Record(duration, success);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
|
||||
/// </summary>
|
||||
/// <param name="operationName">The logical operation name to record.</param>
|
||||
/// <returns>A timing scope that reports elapsed time back into this collector.</returns>
|
||||
public ITimingScope BeginOperation(string operationName)
|
||||
{
|
||||
return new TimingScope(this, operationName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the raw metrics bucket for a named operation.
|
||||
/// </summary>
|
||||
/// <param name="operationName">The logical operation name to look up.</param>
|
||||
/// <returns>The metrics bucket when present; otherwise, <see langword="null" />.</returns>
|
||||
public OperationMetrics? GetMetrics(string operationName)
|
||||
{
|
||||
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces a statistics snapshot for all recorded bridge operations.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary keyed by operation name containing current metrics statistics.</returns>
|
||||
public Dictionary<string, MetricsStatistics> GetStatistics()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timing scope that records one operation result into the owning metrics collector.
|
||||
/// </summary>
|
||||
private class TimingScope : ITimingScope
|
||||
{
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly string _operationName;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private bool _disposed;
|
||||
private bool _success = true;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a timing scope for a named bridge operation.
|
||||
/// </summary>
|
||||
/// <param name="metrics">The metrics collector that should receive the result.</param>
|
||||
/// <param name="operationName">The logical operation name being timed.</param>
|
||||
public TimingScope(PerformanceMetrics metrics, string operationName)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_operationName = operationName;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks whether the timed operation should be recorded as successful.
|
||||
/// </summary>
|
||||
/// <param name="success">A value indicating whether the operation succeeded.</param>
|
||||
public void SetSuccess(bool success)
|
||||
{
|
||||
_success = success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops timing and records the operation result once.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_stopwatch.Stop();
|
||||
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Advises <c><ObjectName>.ScanState</c> on every deployed <c>$WinPlatform</c> and
|
||||
/// <c>$AppEngine</c>, tracks their runtime state (Unknown / Running / Stopped), and notifies
|
||||
/// the owning node manager on Running↔Stopped transitions so it can proactively flip every
|
||||
/// OPC UA variable hosted by that object to <c>BadOutOfService</c> (and clear on recovery).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State machine semantics are documented in <c>runtimestatus.md</c>. Key facts:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ScanState</c> is delivered on-change only — no periodic heartbeat. A stably
|
||||
/// Running host may go hours without a callback.</item>
|
||||
/// <item>Running → Stopped is driven by explicit error callbacks or <c>ScanState = false</c>,
|
||||
/// NEVER by starvation. The only starvation check applies to the initial Unknown state.</item>
|
||||
/// <item>When the MxAccess transport is disconnected, <see cref="GetSnapshot"/> returns every
|
||||
/// entry with <see cref="GalaxyRuntimeState.Unknown"/> regardless of the underlying state,
|
||||
/// because we can't observe anything through a dead transport.</item>
|
||||
/// <item>The stop/start callbacks fire synchronously from whichever thread delivered the
|
||||
/// probe update. The manager releases its own lock before invoking them to avoid
|
||||
/// lock-inversion deadlocks with the node manager's <c>Lock</c>.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyRuntimeProbeManager : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRuntimeProbeManager>();
|
||||
|
||||
private const int CategoryWinPlatform = 1;
|
||||
private const int CategoryAppEngine = 3;
|
||||
private const string KindWinPlatform = "$WinPlatform";
|
||||
private const string KindAppEngine = "$AppEngine";
|
||||
private const string ProbeAttribute = ".ScanState";
|
||||
|
||||
private readonly IMxAccessClient _client;
|
||||
private readonly TimeSpan _unknownTimeout;
|
||||
private readonly Action<int>? _onHostStopped;
|
||||
private readonly Action<int>? _onHostRunning;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
// Key: probe tag reference (e.g. "DevAppEngine.ScanState").
|
||||
// Value: the current runtime status for that host, kept in sync on every probe callback
|
||||
// and queried via GetSnapshot for dashboard rendering.
|
||||
private readonly Dictionary<string, GalaxyRuntimeStatus> _byProbe =
|
||||
new Dictionary<string, GalaxyRuntimeStatus>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Reverse index: gobject_id -> probe tag, so Sync() can diff new/removed hosts efficiently.
|
||||
private readonly Dictionary<int, string> _probeByGobjectId = new Dictionary<int, string>();
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new probe manager. <paramref name="onHostStopped"/> and
|
||||
/// <paramref name="onHostRunning"/> are invoked synchronously on Running↔Stopped
|
||||
/// transitions so the owning node manager can invalidate / restore the hosted subtree.
|
||||
/// </summary>
|
||||
public GalaxyRuntimeProbeManager(
|
||||
IMxAccessClient client,
|
||||
int unknownTimeoutSeconds,
|
||||
Action<int>? onHostStopped = null,
|
||||
Action<int>? onHostRunning = null)
|
||||
: this(client, unknownTimeoutSeconds, onHostStopped, onHostRunning, () => DateTime.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
internal GalaxyRuntimeProbeManager(
|
||||
IMxAccessClient client,
|
||||
int unknownTimeoutSeconds,
|
||||
Action<int>? onHostStopped,
|
||||
Action<int>? onHostRunning,
|
||||
Func<DateTime> clock)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_unknownTimeout = TimeSpan.FromSeconds(Math.Max(1, unknownTimeoutSeconds));
|
||||
_onHostStopped = onHostStopped;
|
||||
_onHostRunning = onHostRunning;
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active probe subscriptions. Surfaced on the dashboard Subscriptions
|
||||
/// panel so operators can see bridge-owned probe count separately from the total.
|
||||
/// </summary>
|
||||
public int ActiveProbeCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
return _byProbe.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the galaxy runtime host identified by
|
||||
/// <paramref name="gobjectId"/> is currently in the <see cref="GalaxyRuntimeState.Stopped"/>
|
||||
/// state. Used by the node manager's Read path to short-circuit on-demand reads of tags
|
||||
/// hosted by a known-stopped runtime object, preventing MxAccess from serving stale
|
||||
/// cached values as Good. Unlike <see cref="GetSnapshot"/> this check uses the
|
||||
/// underlying state directly — transport-disconnected hosts will NOT report Stopped here
|
||||
/// (they report their last-known state), because connection-loss is handled by the
|
||||
/// normal MxAccess error paths and we don't want this method to double-flag.
|
||||
/// </summary>
|
||||
public bool IsHostStopped(int gobjectId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
|
||||
&& _byProbe.TryGetValue(probe, out var status))
|
||||
{
|
||||
return status.State == GalaxyRuntimeState.Stopped;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a point-in-time clone of the runtime status for the host identified by
|
||||
/// <paramref name="gobjectId"/>, or <see langword="null"/> when no probe is registered
|
||||
/// for that object. Used by the node manager to populate the synthetic <c>$RuntimeState</c>
|
||||
/// child variables on each host object. Uses the underlying state directly (not the
|
||||
/// transport-gated rewrite), matching <see cref="IsHostStopped"/>.
|
||||
/// </summary>
|
||||
public GalaxyRuntimeStatus? GetHostStatus(int gobjectId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
|
||||
&& _byProbe.TryGetValue(probe, out var status))
|
||||
{
|
||||
return Clone(status, forceUnknown: false);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diffs the supplied hierarchy against the active probe set, advising new hosts and
|
||||
/// unadvising removed ones. The hierarchy is filtered to runtime host categories
|
||||
/// ($WinPlatform, $AppEngine) — non-host rows are ignored. Idempotent: a second call
|
||||
/// with the same hierarchy performs no Advise / Unadvise work.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sync is synchronous on MxAccess: <see cref="IMxAccessClient.SubscribeAsync"/> is
|
||||
/// awaited for each new host, so for a galaxy with N runtime hosts the call blocks for
|
||||
/// ~N round-trips. This is acceptable because it only runs during address-space build
|
||||
/// and rebuild, not on the hot path.
|
||||
/// </remarks>
|
||||
public async Task SyncAsync(IReadOnlyList<GalaxyObjectInfo> hierarchy)
|
||||
{
|
||||
if (_disposed || hierarchy == null)
|
||||
return;
|
||||
|
||||
// Filter to runtime hosts and project to the expected probe tag name.
|
||||
var desired = new Dictionary<int, (string Probe, string Kind, GalaxyObjectInfo Obj)>();
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
if (obj.CategoryId != CategoryWinPlatform && obj.CategoryId != CategoryAppEngine)
|
||||
continue;
|
||||
if (string.IsNullOrWhiteSpace(obj.TagName))
|
||||
continue;
|
||||
var probe = obj.TagName + ProbeAttribute;
|
||||
var kind = obj.CategoryId == CategoryWinPlatform ? KindWinPlatform : KindAppEngine;
|
||||
desired[obj.GobjectId] = (probe, kind, obj);
|
||||
}
|
||||
|
||||
// Compute diffs under lock, release lock before issuing SDK calls (which can block).
|
||||
// toSubscribe carries the gobject id alongside the probe name so the rollback path on
|
||||
// subscribe failure can unwind both dictionaries without a reverse lookup.
|
||||
List<(int GobjectId, string Probe)> toSubscribe;
|
||||
List<string> toUnsubscribe;
|
||||
lock (_lock)
|
||||
{
|
||||
toSubscribe = new List<(int, string)>();
|
||||
toUnsubscribe = new List<string>();
|
||||
|
||||
foreach (var kvp in desired)
|
||||
{
|
||||
if (_probeByGobjectId.TryGetValue(kvp.Key, out var existingProbe))
|
||||
{
|
||||
// Already tracked: ensure the status entry is aligned (tag rename path is
|
||||
// intentionally not supported — if the probe changed, treat it as remove+add).
|
||||
if (!string.Equals(existingProbe, kvp.Value.Probe, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
toUnsubscribe.Add(existingProbe);
|
||||
_byProbe.Remove(existingProbe);
|
||||
_probeByGobjectId.Remove(kvp.Key);
|
||||
|
||||
toSubscribe.Add((kvp.Key, kvp.Value.Probe));
|
||||
_byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
|
||||
_probeByGobjectId[kvp.Key] = kvp.Value.Probe;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
toSubscribe.Add((kvp.Key, kvp.Value.Probe));
|
||||
_byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
|
||||
_probeByGobjectId[kvp.Key] = kvp.Value.Probe;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove hosts that are no longer in the desired set.
|
||||
var toRemove = _probeByGobjectId.Keys.Where(id => !desired.ContainsKey(id)).ToList();
|
||||
foreach (var id in toRemove)
|
||||
{
|
||||
var probe = _probeByGobjectId[id];
|
||||
toUnsubscribe.Add(probe);
|
||||
_byProbe.Remove(probe);
|
||||
_probeByGobjectId.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the diff outside the lock.
|
||||
foreach (var (gobjectId, probe) in toSubscribe)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _client.SubscribeAsync(probe, OnProbeValueChanged);
|
||||
Log.Information("Galaxy runtime probe advised: {Probe}", probe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to advise galaxy runtime probe {Probe}", probe);
|
||||
|
||||
// Roll back the pending entry so Tick() can't later transition a never-advised
|
||||
// probe from Unknown to Stopped and fan out a false-negative host-down signal.
|
||||
// A concurrent SyncAsync may have re-added the same gobject under a new probe
|
||||
// name, so compare against the captured probe string before removing.
|
||||
lock (_lock)
|
||||
{
|
||||
if (_probeByGobjectId.TryGetValue(gobjectId, out var current)
|
||||
&& string.Equals(current, probe, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_probeByGobjectId.Remove(gobjectId);
|
||||
}
|
||||
_byProbe.Remove(probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var probe in toUnsubscribe)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _client.UnsubscribeAsync(probe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during sync", probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes an <c>OnTagValueChanged</c> callback to the probe state machine. Returns
|
||||
/// <see langword="true"/> when <paramref name="tagRef"/> matches a bridge-owned probe
|
||||
/// (in which case the owning node manager should skip its normal variable-update path).
|
||||
/// </summary>
|
||||
public bool HandleProbeUpdate(string tagRef, Vtq vtq)
|
||||
{
|
||||
if (_disposed || string.IsNullOrEmpty(tagRef))
|
||||
return false;
|
||||
|
||||
GalaxyRuntimeStatus? status;
|
||||
int fromToGobjectId = 0;
|
||||
GalaxyRuntimeState? transitionTo = null;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_byProbe.TryGetValue(tagRef, out status))
|
||||
return false; // not a probe — let the caller handle it normally
|
||||
|
||||
var now = _clock();
|
||||
var isRunning = vtq.Quality.IsGood() && vtq.Value is bool b && b;
|
||||
status.LastStateCallbackTime = now;
|
||||
status.LastScanState = vtq.Value as bool?;
|
||||
|
||||
if (isRunning)
|
||||
{
|
||||
status.GoodUpdateCount++;
|
||||
status.LastError = null;
|
||||
if (status.State != GalaxyRuntimeState.Running)
|
||||
{
|
||||
// Only fire the host-running callback on a true Stopped → Running
|
||||
// recovery. Unknown → Running happens once at startup for every host
|
||||
// and is not a recovery — firing ClearHostVariablesBadQuality there
|
||||
// would wipe Bad status set by the concurrently-stopping other host
|
||||
// on variables that span both lists.
|
||||
var wasStopped = status.State == GalaxyRuntimeState.Stopped;
|
||||
status.State = GalaxyRuntimeState.Running;
|
||||
status.LastStateChangeTime = now;
|
||||
if (wasStopped)
|
||||
{
|
||||
transitionTo = GalaxyRuntimeState.Running;
|
||||
fromToGobjectId = status.GobjectId;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
status.FailureCount++;
|
||||
status.LastError = BuildErrorDetail(vtq);
|
||||
if (status.State != GalaxyRuntimeState.Stopped)
|
||||
{
|
||||
status.State = GalaxyRuntimeState.Stopped;
|
||||
status.LastStateChangeTime = now;
|
||||
transitionTo = GalaxyRuntimeState.Stopped;
|
||||
fromToGobjectId = status.GobjectId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke transition callbacks outside the lock to avoid inverting the node manager's
|
||||
// lock order when it subsequently takes its own Lock to flip hosted variables.
|
||||
if (transitionTo == GalaxyRuntimeState.Stopped)
|
||||
{
|
||||
Log.Information("Galaxy runtime {Probe} transitioned Running → Stopped ({Err})",
|
||||
tagRef, status?.LastError ?? "(no detail)");
|
||||
try { _onHostStopped?.Invoke(fromToGobjectId); }
|
||||
catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw for {Probe}", tagRef); }
|
||||
}
|
||||
else if (transitionTo == GalaxyRuntimeState.Running)
|
||||
{
|
||||
Log.Information("Galaxy runtime {Probe} transitioned → Running", tagRef);
|
||||
try { _onHostRunning?.Invoke(fromToGobjectId); }
|
||||
catch (Exception ex) { Log.Warning(ex, "onHostRunning callback threw for {Probe}", tagRef); }
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodic tick — flips Unknown entries to Stopped once their registration has been
|
||||
/// outstanding for longer than the configured timeout without ever receiving a first
|
||||
/// callback. Does nothing to Running or Stopped entries.
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var transitions = new List<int>();
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
foreach (var entry in _byProbe.Values)
|
||||
{
|
||||
if (entry.State != GalaxyRuntimeState.Unknown)
|
||||
continue;
|
||||
|
||||
// LastStateChangeTime is set at creation to "now" so the timeout is measured
|
||||
// from when the probe was advised.
|
||||
if (entry.LastStateChangeTime.HasValue
|
||||
&& now - entry.LastStateChangeTime.Value > _unknownTimeout)
|
||||
{
|
||||
entry.State = GalaxyRuntimeState.Stopped;
|
||||
entry.LastStateChangeTime = now;
|
||||
entry.FailureCount++;
|
||||
entry.LastError = "Probe never received an initial callback within the unknown-resolution timeout";
|
||||
transitions.Add(entry.GobjectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var gobjectId in transitions)
|
||||
{
|
||||
Log.Warning("Galaxy runtime gobject {GobjectId} timed out in Unknown state → Stopped", gobjectId);
|
||||
try { _onHostStopped?.Invoke(gobjectId); }
|
||||
catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw during tick for {GobjectId}", gobjectId); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a read-only snapshot of every tracked host. When the MxAccess transport is
|
||||
/// disconnected, every entry is rewritten to Unknown on the way out so operators aren't
|
||||
/// misled by cached per-host state — the Connection panel is the primary signal in that
|
||||
/// case. The underlying <c>_byProbe</c> map is not modified.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GalaxyRuntimeStatus> GetSnapshot()
|
||||
{
|
||||
var transportDown = _client.State != ConnectionState.Connected;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var result = new List<GalaxyRuntimeStatus>(_byProbe.Count);
|
||||
foreach (var entry in _byProbe.Values)
|
||||
result.Add(Clone(entry, forceUnknown: transportDown));
|
||||
// Stable ordering by name so dashboard rows don't jitter between refreshes.
|
||||
result.Sort((a, b) => string.CompareOrdinal(a.ObjectName, b.ObjectName));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
List<string> probes;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
probes = _byProbe.Keys.ToList();
|
||||
_byProbe.Clear();
|
||||
_probeByGobjectId.Clear();
|
||||
}
|
||||
|
||||
foreach (var probe in probes)
|
||||
{
|
||||
try
|
||||
{
|
||||
_client.UnsubscribeAsync(probe).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during Dispose", probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProbeValueChanged(string tagRef, Vtq vtq)
|
||||
{
|
||||
HandleProbeUpdate(tagRef, vtq);
|
||||
}
|
||||
|
||||
private GalaxyRuntimeStatus MakeInitialStatus(GalaxyObjectInfo obj, string kind)
|
||||
{
|
||||
return new GalaxyRuntimeStatus
|
||||
{
|
||||
ObjectName = obj.TagName,
|
||||
GobjectId = obj.GobjectId,
|
||||
Kind = kind,
|
||||
State = GalaxyRuntimeState.Unknown,
|
||||
LastStateChangeTime = _clock()
|
||||
};
|
||||
}
|
||||
|
||||
private static GalaxyRuntimeStatus Clone(GalaxyRuntimeStatus src, bool forceUnknown)
|
||||
{
|
||||
return new GalaxyRuntimeStatus
|
||||
{
|
||||
ObjectName = src.ObjectName,
|
||||
GobjectId = src.GobjectId,
|
||||
Kind = src.Kind,
|
||||
State = forceUnknown ? GalaxyRuntimeState.Unknown : src.State,
|
||||
LastStateCallbackTime = src.LastStateCallbackTime,
|
||||
LastStateChangeTime = src.LastStateChangeTime,
|
||||
LastScanState = src.LastScanState,
|
||||
LastError = forceUnknown ? null : src.LastError,
|
||||
GoodUpdateCount = src.GoodUpdateCount,
|
||||
FailureCount = src.FailureCount
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildErrorDetail(Vtq vtq)
|
||||
{
|
||||
if (vtq.Quality.IsBad())
|
||||
return $"bad quality ({vtq.Quality})";
|
||||
if (vtq.Quality.IsUncertain())
|
||||
return $"uncertain quality ({vtq.Quality})";
|
||||
if (vtq.Value is bool b && !b)
|
||||
return "ScanState = false (OffScan)";
|
||||
return $"unexpected value: {vtq.Value ?? "(null)"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token that cancels the connection attempt.</param>
|
||||
public async Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_state == ConnectionState.Connected) return;
|
||||
|
||||
SetState(ConnectionState.Connecting);
|
||||
try
|
||||
{
|
||||
_connectionHandle = await _staThread.RunAsync(() =>
|
||||
{
|
||||
AttachProxyEvents();
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(DetachProxyEvents);
|
||||
}
|
||||
catch (Exception cleanupEx)
|
||||
{
|
||||
Log.Warning(cleanupEx, "Failed to detach proxy events after connection failure");
|
||||
}
|
||||
|
||||
Log.Error(ex, "MxAccess connection failed");
|
||||
SetState(ConnectionState.Error, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
|
||||
/// </summary>
|
||||
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
|
||||
DetachProxyEvents();
|
||||
|
||||
// Unregister
|
||||
try
|
||||
{
|
||||
_proxy.Unregister(_connectionHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during Unregister");
|
||||
}
|
||||
});
|
||||
|
||||
_handleToAddress.Clear();
|
||||
_addressToHandle.Clear();
|
||||
_pendingReadsByAddress.Clear();
|
||||
_pendingWrites.Clear();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during disconnect");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetState(ConnectionState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void AttachProxyEvents()
|
||||
{
|
||||
if (_proxyEventsAttached) return;
|
||||
_proxy.OnDataChange += HandleOnDataChange;
|
||||
_proxy.OnWriteComplete += HandleOnWriteComplete;
|
||||
_proxyEventsAttached = true;
|
||||
}
|
||||
|
||||
private void DetachProxyEvents()
|
||||
{
|
||||
if (!_proxyEventsAttached) return;
|
||||
_proxy.OnDataChange -= HandleOnDataChange;
|
||||
_proxy.OnWriteComplete -= HandleOnWriteComplete;
|
||||
_proxyEventsAttached = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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);
|
||||
|
||||
if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads))
|
||||
foreach (var pendingRead in pendingReads.Values)
|
||||
pendingRead.TrySetResult(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))
|
||||
{
|
||||
var 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
private Task? _monitorTask;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
|
||||
/// </summary>
|
||||
public void StartMonitor()
|
||||
{
|
||||
if (_monitorCts != null)
|
||||
StopMonitor();
|
||||
|
||||
_monitorCts = new CancellationTokenSource();
|
||||
_monitorTask = Task.Run(() => MonitorLoopAsync(_monitorCts.Token));
|
||||
Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the background monitor loop.
|
||||
/// </summary>
|
||||
public void StopMonitor()
|
||||
{
|
||||
_monitorCts?.Cancel();
|
||||
try { _monitorTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
|
||||
_monitorTask = null;
|
||||
}
|
||||
|
||||
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 || _state == ConnectionState.Error) &&
|
||||
_config.AutoReconnect)
|
||||
{
|
||||
Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to read.</param>
|
||||
/// <param name="ct">A token that cancels the read.</param>
|
||||
/// <returns>The resulting VTQ value or a bad-quality fallback on timeout or failure.</returns>
|
||||
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>();
|
||||
|
||||
var itemHandle = await _staThread.RunAsync(() =>
|
||||
{
|
||||
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
|
||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
||||
return h;
|
||||
});
|
||||
|
||||
var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
|
||||
_ => new ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
|
||||
pendingReads[itemHandle] = tcs;
|
||||
_handleToAddress[itemHandle] = fullTagReference;
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds));
|
||||
cts.Token.Register(() => tcs.TrySetResult(Vtq.Bad(Quality.BadCommFailure)));
|
||||
|
||||
var result = await tcs.Task;
|
||||
if (result.Quality != Quality.Good)
|
||||
scope.SetSuccess(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
return Vtq.Bad(Quality.BadCommFailure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_pendingReadsByAddress.TryGetValue(fullTagReference, out var reads))
|
||||
{
|
||||
reads.TryRemove(itemHandle, out _);
|
||||
if (reads.IsEmpty)
|
||||
_pendingReadsByAddress.TryRemove(fullTagReference, out _);
|
||||
}
|
||||
|
||||
_handleToAddress.TryRemove(itemHandle, 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to write.</param>
|
||||
/// <param name="value">The value to send to the runtime.</param>
|
||||
/// <param name="ct">A token that cancels the write.</param>
|
||||
/// <returns><see langword="true" /> when the runtime acknowledges success; otherwise, <see langword="false" />.</returns>
|
||||
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;
|
||||
|
||||
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(() =>
|
||||
{
|
||||
Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference,
|
||||
_config.WriteTimeoutSeconds);
|
||||
tcs.TrySetResult(false);
|
||||
});
|
||||
|
||||
var success = await tcs.Task;
|
||||
if (!success)
|
||||
scope.SetSuccess(false);
|
||||
|
||||
return success;
|
||||
}
|
||||
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 _);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to monitor.</param>
|
||||
/// <param name="callback">The callback that should receive runtime value changes.</param>
|
||||
public async Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
||||
{
|
||||
_storedSubscriptions[fullTagReference] = callback;
|
||||
if (_state != ConnectionState.Connected) return;
|
||||
if (_addressToHandle.ContainsKey(fullTagReference)) return;
|
||||
|
||||
await SubscribeInternalAsync(fullTagReference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to stop monitoring.</param>
|
||||
public async Task UnsubscribeAsync(string fullTagReference)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
if (_addressToHandle.ContainsKey(address))
|
||||
return;
|
||||
|
||||
using var scope = _metrics.BeginOperation("Subscribe");
|
||||
try
|
||||
{
|
||||
var itemHandle = await _staThread.RunAsync(() =>
|
||||
{
|
||||
var h = _proxy.AddItem(_connectionHandle, address);
|
||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
||||
return h;
|
||||
});
|
||||
|
||||
var registeredHandle = _addressToHandle.GetOrAdd(address, itemHandle);
|
||||
if (registeredHandle != itemHandle)
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_handleToAddress[itemHandle] = address;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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 ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly MxAccessConfiguration _config;
|
||||
|
||||
// Handle mappings
|
||||
private readonly ConcurrentDictionary<int, string> _handleToAddress = new();
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly SemaphoreSlim _operationSemaphore;
|
||||
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>
|
||||
_pendingReadsByAddress
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Pending writes
|
||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites = new();
|
||||
|
||||
private readonly IMxProxy _proxy;
|
||||
|
||||
private readonly StaComThread _staThread;
|
||||
|
||||
// Subscription storage
|
||||
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _storedSubscriptions
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private int _connectionHandle;
|
||||
private DateTime _lastProbeValueTime = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _monitorCts;
|
||||
|
||||
// Probe
|
||||
private string? _probeTag;
|
||||
private bool _proxyEventsAttached;
|
||||
private int _reconnectCount;
|
||||
private volatile ConnectionState _state = ConnectionState.Disconnected;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
|
||||
/// </summary>
|
||||
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
|
||||
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
|
||||
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
|
||||
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
|
||||
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config,
|
||||
PerformanceMetrics metrics)
|
||||
{
|
||||
_staThread = staThread;
|
||||
_proxy = proxy;
|
||||
_config = config;
|
||||
_metrics = metrics;
|
||||
_operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current runtime connection state for the MXAccess client.
|
||||
/// </summary>
|
||||
public ConnectionState State => _state;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active tag subscriptions currently maintained against the runtime.
|
||||
/// </summary>
|
||||
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of reconnect attempts performed since the client was created.
|
||||
/// </summary>
|
||||
public int ReconnectCount => _reconnectCount;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the MXAccess connection state changes.
|
||||
/// </summary>
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a subscribed runtime tag publishes a new value.
|
||||
/// </summary>
|
||||
public event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_monitorCts?.Cancel();
|
||||
DisconnectAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during MxAccessClient dispose");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationSemaphore.Dispose();
|
||||
_monitorCts?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetState(ConnectionState newState, string message = "")
|
||||
{
|
||||
var previous = _state;
|
||||
if (previous == newState) return;
|
||||
_state = newState;
|
||||
Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message);
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ArchestrA.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
|
||||
/// </summary>
|
||||
public event MxDataChangeHandler? OnDataChange;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the COM proxy confirms completion of a write request.
|
||||
/// </summary>
|
||||
public event MxWriteCompleteHandler? OnWriteComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Creates and registers the COM proxy session that backs live MXAccess operations.
|
||||
/// </summary>
|
||||
/// <param name="clientName">The client name reported to the Wonderware runtime.</param>
|
||||
/// <returns>The runtime connection handle assigned by the COM server.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters the COM proxy session and releases the underlying COM object.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)" />.</param>
|
||||
public void Unregister(int handle)
|
||||
{
|
||||
if (_lmxProxy != null)
|
||||
try
|
||||
{
|
||||
_lmxProxy.OnDataChange -= ProxyOnDataChange;
|
||||
_lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
|
||||
_lmxProxy.Unregister(handle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(_lmxProxy);
|
||||
_lmxProxy = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
|
||||
/// <returns>The item handle assigned by the COM proxy.</returns>
|
||||
public int AddItem(int handle, string address)
|
||||
{
|
||||
return _lmxProxy!.AddItem(handle, address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item handle from the active COM proxy session.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle to remove.</param>
|
||||
public void RemoveItem(int handle, int itemHandle)
|
||||
{
|
||||
_lmxProxy!.RemoveItem(handle, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables supervisory callbacks for the specified runtime item.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle to monitor.</param>
|
||||
public void AdviseSupervisory(int handle, int itemHandle)
|
||||
{
|
||||
_lmxProxy!.AdviseSupervisory(handle, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables supervisory callbacks for the specified runtime item.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle to stop monitoring.</param>
|
||||
public void UnAdviseSupervisory(int handle, int itemHandle)
|
||||
{
|
||||
_lmxProxy!.UnAdvise(handle, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value to the specified runtime item through the COM proxy.
|
||||
/// </summary>
|
||||
/// <param name="handle">The runtime connection handle.</param>
|
||||
/// <param name="itemHandle">The item handle to write.</param>
|
||||
/// <param name="value">The value to send to the runtime.</param>
|
||||
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
|
||||
public void Write(int handle, int itemHandle, object value, int securityClassification)
|
||||
{
|
||||
_lmxProxy!.Write(handle, itemHandle, value, securityClassification);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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 TaskCompletionSource<bool> _ready = new();
|
||||
|
||||
private readonly Thread _thread;
|
||||
private readonly ConcurrentQueue<WorkItem> _workItems = new();
|
||||
private long _appMessages;
|
||||
private long _dispatchedMessages;
|
||||
private bool _disposed;
|
||||
private DateTime _lastLogTime;
|
||||
private volatile uint _nativeThreadId;
|
||||
private volatile bool _pumpExited;
|
||||
|
||||
private long _totalMessages;
|
||||
private long _workItemsExecuted;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
|
||||
/// </summary>
|
||||
public StaComThread()
|
||||
{
|
||||
_thread = new Thread(ThreadEntry)
|
||||
{
|
||||
Name = "MxAccess-STA",
|
||||
IsBackground = true
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the STA thread is running and able to accept work.
|
||||
/// </summary>
|
||||
public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited;
|
||||
|
||||
/// <summary>
|
||||
/// Stops the STA thread and releases the message-pump resources used for COM interop.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (_nativeThreadId != 0 && !_pumpExited)
|
||||
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");
|
||||
}
|
||||
|
||||
DrainAndFaultQueue();
|
||||
Log.Information("STA COM thread stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the STA thread and waits until its message pump is ready for COM work.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_thread.Start();
|
||||
_ready.Task.GetAwaiter().GetResult();
|
||||
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an action to execute on the STA thread.
|
||||
/// </summary>
|
||||
/// <param name="action">The work item to execute on the STA thread.</param>
|
||||
/// <returns>A task that completes when the action has finished executing.</returns>
|
||||
public Task RunAsync(Action action)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_workItems.Enqueue(new WorkItem
|
||||
{
|
||||
Execute = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
},
|
||||
Fault = ex => tcs.TrySetException(ex)
|
||||
});
|
||||
|
||||
if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
|
||||
{
|
||||
_pumpExited = true;
|
||||
DrainAndFaultQueue();
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a function to execute on the STA thread and returns its result.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The result type produced by the function.</typeparam>
|
||||
/// <param name="func">The work item to execute on the STA thread.</param>
|
||||
/// <returns>A task that completes with the function result.</returns>
|
||||
public Task<T> RunAsync<T>(Func<T> func)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
|
||||
|
||||
var tcs = new TaskCompletionSource<T>();
|
||||
_workItems.Enqueue(new WorkItem
|
||||
{
|
||||
Execute = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
tcs.TrySetResult(func());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
},
|
||||
Fault = ex => tcs.TrySetException(ex)
|
||||
});
|
||||
|
||||
if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
|
||||
{
|
||||
_pumpExited = true;
|
||||
DrainAndFaultQueue();
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pumpExited = true;
|
||||
DrainAndFaultQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainQueue()
|
||||
{
|
||||
while (_workItems.TryDequeue(out var workItem))
|
||||
{
|
||||
_workItemsExecuted++;
|
||||
try
|
||||
{
|
||||
workItem.Execute();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Unhandled exception in STA work item");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainAndFaultQueue()
|
||||
{
|
||||
var faultException = new InvalidOperationException("STA COM thread pump has exited");
|
||||
while (_workItems.TryDequeue(out var workItem))
|
||||
{
|
||||
try
|
||||
{
|
||||
workItem.Fault(faultException);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Faulting a TCS should not throw, but guard against it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private sealed class WorkItem
|
||||
{
|
||||
public Action Execute { get; set; }
|
||||
public Action<Exception> Fault { get; set; }
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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>
|
||||
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes
|
||||
/// nodes.
|
||||
/// </summary>
|
||||
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
|
||||
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
|
||||
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
|
||||
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
var model = new AddressSpaceModel();
|
||||
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
||||
|
||||
var attrsByObject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Build parent→children map
|
||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Find root objects (parent not in hierarchy)
|
||||
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
|
||||
|
||||
foreach (var obj in hierarchy)
|
||||
{
|
||||
var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
|
||||
|
||||
if (!knownIds.Contains(obj.ParentGobjectId))
|
||||
model.RootNodes.Add(nodeInfo);
|
||||
}
|
||||
|
||||
Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
|
||||
model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
|
||||
Dictionary<int, List<GalaxyAttributeInfo>> attrsByObject,
|
||||
Dictionary<int, List<GalaxyObjectInfo>> childrenByParent,
|
||||
AddressSpaceModel model)
|
||||
{
|
||||
var node = new NodeInfo
|
||||
{
|
||||
GobjectId = obj.GobjectId,
|
||||
TagName = obj.TagName,
|
||||
BrowseName = obj.BrowseName,
|
||||
ParentGobjectId = obj.ParentGobjectId,
|
||||
IsArea = obj.IsArea
|
||||
};
|
||||
|
||||
if (!obj.IsArea)
|
||||
model.ObjectCount++;
|
||||
|
||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
|
||||
foreach (var attr in attrs)
|
||||
{
|
||||
node.Attributes.Add(new AttributeNodeInfo
|
||||
{
|
||||
AttributeName = attr.AttributeName,
|
||||
FullTagReference = attr.FullTagReference,
|
||||
MxDataType = attr.MxDataType,
|
||||
IsArray = attr.IsArray,
|
||||
ArrayDimension = attr.ArrayDimension,
|
||||
PrimitiveName = attr.PrimitiveName ?? "",
|
||||
SecurityClassification = attr.SecurityClassification,
|
||||
IsHistorized = attr.IsHistorized,
|
||||
IsAlarm = attr.IsAlarm
|
||||
});
|
||||
|
||||
model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
|
||||
model.VariableCount++;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
|
||||
{
|
||||
if (!attr.IsArray)
|
||||
return attr.FullTagReference;
|
||||
|
||||
return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
|
||||
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
|
||||
: attr.FullTagReference;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Node info for the address space tree.
|
||||
/// </summary>
|
||||
public class NodeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy object identifier represented by this address-space node.
|
||||
/// </summary>
|
||||
public int GobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
|
||||
/// </summary>
|
||||
public string TagName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
|
||||
/// </summary>
|
||||
public string BrowseName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
|
||||
/// </summary>
|
||||
public int ParentGobjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
|
||||
/// </summary>
|
||||
public bool IsArea { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the attribute nodes published beneath this object.
|
||||
/// </summary>
|
||||
public List<AttributeNodeInfo> Attributes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
|
||||
/// </summary>
|
||||
public List<NodeInfo> Children { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight description of an attribute node that will become an OPC UA variable.
|
||||
/// </summary>
|
||||
public class AttributeNodeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy attribute name published under the object.
|
||||
/// </summary>
|
||||
public string AttributeName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
|
||||
/// </summary>
|
||||
public string FullTagReference { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
|
||||
/// </summary>
|
||||
public int MxDataType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is modeled as an array.
|
||||
/// </summary>
|
||||
public bool IsArray { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the declared array length when the attribute is a fixed-size array.
|
||||
/// </summary>
|
||||
public int? ArrayDimension { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the primitive name that groups the attribute under a sub-object node.
|
||||
/// Empty for root-level attributes.
|
||||
/// </summary>
|
||||
public string PrimitiveName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
|
||||
/// </summary>
|
||||
public int SecurityClassification { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is historized.
|
||||
/// </summary>
|
||||
public bool IsHistorized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the attribute is an alarm.
|
||||
/// </summary>
|
||||
public bool IsAlarm { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building the address space model.
|
||||
/// </summary>
|
||||
public class AddressSpaceModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
|
||||
/// </summary>
|
||||
public List<NodeInfo> RootNodes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> NodeIdToTagReference { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of non-area Galaxy objects included in the model.
|
||||
/// </summary>
|
||||
public int ObjectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of variable nodes created from Galaxy attributes.
|
||||
/// </summary>
|
||||
public int VariableCount { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
|
||||
/// </summary>
|
||||
public static class AddressSpaceDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
|
||||
/// </summary>
|
||||
/// <param name="oldHierarchy">The previously published Galaxy object hierarchy snapshot.</param>
|
||||
/// <param name="oldAttributes">The previously published Galaxy attribute snapshot keyed to the old hierarchy.</param>
|
||||
/// <param name="newHierarchy">The latest Galaxy object hierarchy snapshot pulled from the repository.</param>
|
||||
/// <param name="newAttributes">The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace.</param>
|
||||
public static HashSet<int> FindChangedGobjectIds(
|
||||
List<GalaxyObjectInfo> oldHierarchy, List<GalaxyAttributeInfo> oldAttributes,
|
||||
List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
|
||||
{
|
||||
var changed = new HashSet<int>();
|
||||
|
||||
var oldObjects = oldHierarchy.ToDictionary(h => h.GobjectId);
|
||||
var newObjects = newHierarchy.ToDictionary(h => h.GobjectId);
|
||||
|
||||
// Added objects
|
||||
foreach (var id in newObjects.Keys)
|
||||
if (!oldObjects.ContainsKey(id))
|
||||
changed.Add(id);
|
||||
|
||||
// Removed objects
|
||||
foreach (var id in oldObjects.Keys)
|
||||
if (!newObjects.ContainsKey(id))
|
||||
changed.Add(id);
|
||||
|
||||
// Modified objects
|
||||
foreach (var kvp in newObjects)
|
||||
if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
|
||||
changed.Add(kvp.Key);
|
||||
|
||||
// Attribute changes — group by gobject_id and compare
|
||||
var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
var newAttrsByObj = newAttributes.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// All gobject_ids that have attributes in either old or new
|
||||
var allAttrGobjectIds = new HashSet<int>(oldAttrsByObj.Keys);
|
||||
allAttrGobjectIds.UnionWith(newAttrsByObj.Keys);
|
||||
|
||||
foreach (var id in allAttrGobjectIds)
|
||||
{
|
||||
if (changed.Contains(id))
|
||||
continue;
|
||||
|
||||
oldAttrsByObj.TryGetValue(id, out var oldAttrs);
|
||||
newAttrsByObj.TryGetValue(id, out var newAttrs);
|
||||
|
||||
if (!AttributeSetsEqual(oldAttrs, newAttrs))
|
||||
changed.Add(id);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
|
||||
/// </summary>
|
||||
/// <param name="changed">The root Galaxy objects that were detected as changed between snapshots.</param>
|
||||
/// <param name="hierarchy">The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.</param>
|
||||
public static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)
|
||||
{
|
||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());
|
||||
|
||||
var expanded = new HashSet<int>(changed);
|
||||
var queue = new Queue<int>(changed);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
if (childrenByParent.TryGetValue(id, out var children))
|
||||
foreach (var childId in children)
|
||||
if (expanded.Add(childId))
|
||||
queue.Enqueue(childId);
|
||||
}
|
||||
|
||||
return expanded;
|
||||
}
|
||||
|
||||
private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b)
|
||||
{
|
||||
return a.TagName == b.TagName
|
||||
&& a.BrowseName == b.BrowseName
|
||||
&& a.ContainedName == b.ContainedName
|
||||
&& a.ParentGobjectId == b.ParentGobjectId
|
||||
&& a.IsArea == b.IsArea;
|
||||
}
|
||||
|
||||
private static bool AttributeSetsEqual(List<GalaxyAttributeInfo>? a, List<GalaxyAttributeInfo>? b)
|
||||
{
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.Count != b.Count) return false;
|
||||
|
||||
// Sort by a stable key and compare pairwise
|
||||
var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
||||
var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
||||
|
||||
for (var i = 0; i < sortedA.Count; i++)
|
||||
if (!AttributesEqual(sortedA[i], sortedB[i]))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b)
|
||||
{
|
||||
return a.AttributeName == b.AttributeName
|
||||
&& a.FullTagReference == b.FullTagReference
|
||||
&& a.MxDataType == b.MxDataType
|
||||
&& a.IsArray == b.IsArray
|
||||
&& a.ArrayDimension == b.ArrayDimension
|
||||
&& a.PrimitiveName == b.PrimitiveName
|
||||
&& a.SecurityClassification == b.SecurityClassification
|
||||
&& a.IsHistorized == b.IsHistorized
|
||||
&& a.IsAlarm == b.IsAlarm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using System;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a bridge VTQ snapshot into an OPC UA data value.
|
||||
/// </summary>
|
||||
/// <param name="vtq">The VTQ snapshot to convert.</param>
|
||||
/// <returns>An OPC UA data value suitable for reads and subscriptions.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA data value back into a bridge VTQ snapshot.
|
||||
/// </summary>
|
||||
/// <param name="dataValue">The OPC UA data value to convert.</param>
|
||||
/// <returns>A VTQ snapshot containing the converted value, timestamp, and derived quality.</returns>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,528 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom OPC UA server that creates the LmxNodeManager, handles user authentication,
|
||||
/// and exposes redundancy state through the standard server object. (OPC-001, OPC-012)
|
||||
/// </summary>
|
||||
public class LmxOpcUaServer : StandardServer
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
|
||||
private readonly bool _alarmTrackingEnabled;
|
||||
private readonly AlarmObjectFilter? _alarmObjectFilter;
|
||||
private readonly string? _applicationUri;
|
||||
private readonly AuthenticationConfiguration _authConfig;
|
||||
private readonly IUserAuthenticationProvider? _authProvider;
|
||||
|
||||
private readonly string _galaxyName;
|
||||
private readonly IHistorianDataSource? _historianDataSource;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly RedundancyConfiguration _redundancyConfig;
|
||||
private readonly ServiceLevelCalculator _serviceLevelCalculator = new();
|
||||
private NodeId? _alarmAckRoleId;
|
||||
|
||||
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
|
||||
private NodeId? _readOnlyRoleId;
|
||||
private NodeId? _writeConfigureRoleId;
|
||||
private NodeId? _writeOperateRoleId;
|
||||
private NodeId? _writeTuneRoleId;
|
||||
|
||||
private readonly bool _runtimeStatusProbesEnabled;
|
||||
private readonly int _runtimeStatusUnknownTimeoutSeconds;
|
||||
private readonly int _mxAccessRequestTimeoutSeconds;
|
||||
private readonly int _historianRequestTimeoutSeconds;
|
||||
|
||||
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
||||
IHistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
|
||||
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
|
||||
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
|
||||
AlarmObjectFilter? alarmObjectFilter = null,
|
||||
bool runtimeStatusProbesEnabled = false,
|
||||
int runtimeStatusUnknownTimeoutSeconds = 15,
|
||||
int mxAccessRequestTimeoutSeconds = 30,
|
||||
int historianRequestTimeoutSeconds = 60)
|
||||
{
|
||||
_galaxyName = galaxyName;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
_historianDataSource = historianDataSource;
|
||||
_alarmTrackingEnabled = alarmTrackingEnabled;
|
||||
_alarmObjectFilter = alarmObjectFilter;
|
||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
||||
_authProvider = authProvider;
|
||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
||||
_applicationUri = applicationUri;
|
||||
_runtimeStatusProbesEnabled = runtimeStatusProbesEnabled;
|
||||
_runtimeStatusUnknownTimeoutSeconds = runtimeStatusUnknownTimeoutSeconds;
|
||||
_mxAccessRequestTimeoutSeconds = mxAccessRequestTimeoutSeconds;
|
||||
_historianRequestTimeoutSeconds = historianRequestTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
|
||||
/// </summary>
|
||||
public LmxNodeManager? NodeManager { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active OPC UA sessions currently connected to the server.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server,
|
||||
ApplicationConfiguration configuration)
|
||||
{
|
||||
// Resolve custom role NodeIds from the roles namespace
|
||||
ResolveRoleNodeIds(server);
|
||||
|
||||
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
|
||||
NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
|
||||
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
|
||||
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId,
|
||||
_alarmObjectFilter,
|
||||
_runtimeStatusProbesEnabled, _runtimeStatusUnknownTimeoutSeconds,
|
||||
_mxAccessRequestTimeoutSeconds, _historianRequestTimeoutSeconds);
|
||||
|
||||
var nodeManagers = new List<INodeManager> { NodeManager };
|
||||
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
|
||||
}
|
||||
|
||||
private void ResolveRoleNodeIds(IServerInternal server)
|
||||
{
|
||||
var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri);
|
||||
_readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex);
|
||||
_writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex);
|
||||
_writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex);
|
||||
_writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex);
|
||||
_alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex);
|
||||
Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnServerStarted(IServerInternal server)
|
||||
{
|
||||
base.OnServerStarted(server);
|
||||
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
||||
|
||||
ConfigureRedundancy(server);
|
||||
ConfigureHistoryCapabilities(server);
|
||||
ConfigureServerCapabilities(server);
|
||||
}
|
||||
|
||||
private void ConfigureRedundancy(IServerInternal server)
|
||||
{
|
||||
var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled);
|
||||
|
||||
try
|
||||
{
|
||||
// Set RedundancySupport via the diagnostics node manager
|
||||
var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport;
|
||||
var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
||||
redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
|
||||
if (redundancySupportNode != null)
|
||||
{
|
||||
redundancySupportNode.Value = (int)mode;
|
||||
redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
||||
Log.Information("Set RedundancySupport to {Mode}", mode);
|
||||
}
|
||||
|
||||
// Set ServerUriArray for non-transparent redundancy
|
||||
if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0)
|
||||
{
|
||||
var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray;
|
||||
var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
||||
serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
|
||||
if (serverUriArrayNode != null)
|
||||
{
|
||||
serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray();
|
||||
serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
||||
Log.Information("Set ServerUriArray to [{Uris}]",
|
||||
string.Join(", ", _redundancyConfig.ServerUris));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning(
|
||||
"ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type");
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial ServiceLevel
|
||||
var initialLevel = CalculateCurrentServiceLevel(true, true);
|
||||
SetServiceLevelValue(server, initialLevel);
|
||||
Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Failed to configure redundancy nodes — redundancy state may not be visible to clients");
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureHistoryCapabilities(IServerInternal server)
|
||||
{
|
||||
if (_historianDataSource == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var dnm = server.DiagnosticsNodeManager;
|
||||
var ctx = server.DefaultSystemContext;
|
||||
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability, true);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability,
|
||||
_alarmTrackingEnabled);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_MaxReturnDataValues,
|
||||
(uint)(_historianDataSource != null ? 10000 : 0));
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_MaxReturnEventValues, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_InsertDataCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_ReplaceDataCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_UpdateDataCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_DeleteRawCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_InsertEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_ReplaceEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_UpdateEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_DeleteEventCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_InsertAnnotationCapability, false);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.HistoryServerCapabilities_ServerTimestampSupported, true);
|
||||
|
||||
// Add aggregate function references under the AggregateFunctions folder
|
||||
var aggFolderNode = dnm?.FindPredefinedNode(
|
||||
ObjectIds.HistoryServerCapabilities_AggregateFunctions,
|
||||
typeof(FolderState)) as FolderState;
|
||||
|
||||
if (aggFolderNode != null)
|
||||
{
|
||||
var aggregateIds = new[]
|
||||
{
|
||||
ObjectIds.AggregateFunction_Average,
|
||||
ObjectIds.AggregateFunction_Minimum,
|
||||
ObjectIds.AggregateFunction_Maximum,
|
||||
ObjectIds.AggregateFunction_Count,
|
||||
ObjectIds.AggregateFunction_Start,
|
||||
ObjectIds.AggregateFunction_End,
|
||||
ObjectIds.AggregateFunction_StandardDeviationPopulation
|
||||
};
|
||||
|
||||
foreach (var aggId in aggregateIds)
|
||||
{
|
||||
var aggNode = dnm?.FindPredefinedNode(aggId, typeof(BaseObjectState)) as BaseObjectState;
|
||||
if (aggNode != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
aggFolderNode.AddReference(ReferenceTypeIds.Organizes, false, aggNode.NodeId);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Reference already exists — skip
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
aggNode.AddReference(ReferenceTypeIds.Organizes, true, aggFolderNode.NodeId);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Reference already exists — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("HistoryServerCapabilities configured with {Count} aggregate functions",
|
||||
aggregateIds.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("AggregateFunctions folder not found in predefined nodes");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Failed to configure HistoryServerCapabilities — history discovery may not work for clients");
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureServerCapabilities(IServerInternal server)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dnm = server.DiagnosticsNodeManager;
|
||||
var ctx = server.DefaultSystemContext;
|
||||
|
||||
// Server profiles
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_ServerProfileArray,
|
||||
new[] { "http://opcfoundation.org/UA-Profile/Server/StandardUA2017" });
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_LocaleIdArray,
|
||||
new[] { "en" });
|
||||
|
||||
// Limits
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MinSupportedSampleRate, 100.0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxBrowseContinuationPoints, (ushort)100);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxQueryContinuationPoints, (ushort)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxHistoryContinuationPoints, (ushort)100);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxArrayLength, (uint)65535);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxStringLength, (uint)(4 * 1024 * 1024));
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_MaxByteStringLength, (uint)(4 * 1024 * 1024));
|
||||
|
||||
// OperationLimits
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds,
|
||||
(uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents, (uint)1000);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData, (uint)0);
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents, (uint)0);
|
||||
|
||||
// Diagnostics
|
||||
SetPredefinedVariable(dnm, ctx,
|
||||
VariableIds.Server_ServerDiagnostics_EnabledFlag, true);
|
||||
|
||||
Log.Information(
|
||||
"ServerCapabilities configured (OperationLimits, diagnostics enabled)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Failed to configure ServerCapabilities — capability discovery may not work for clients");
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetPredefinedVariable(DiagnosticsNodeManager? dnm, ServerSystemContext ctx,
|
||||
NodeId variableId, object value)
|
||||
{
|
||||
var node = dnm?.FindPredefinedNode(variableId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
if (node != null)
|
||||
{
|
||||
node.Value = value;
|
||||
node.ClearChangeMasks(ctx, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the server's ServiceLevel based on current runtime health.
|
||||
/// Called by the service layer when MXAccess or DB health changes.
|
||||
/// </summary>
|
||||
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
|
||||
try
|
||||
{
|
||||
if (ServerInternal != null) SetServiceLevelValue(ServerInternal, level);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Failed to update ServiceLevel node");
|
||||
}
|
||||
}
|
||||
|
||||
private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
if (!_redundancyConfig.Enabled)
|
||||
return 255; // SDK default when redundancy is not configured
|
||||
|
||||
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
|
||||
var baseLevel = isPrimary
|
||||
? _redundancyConfig.ServiceLevelBase
|
||||
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
|
||||
|
||||
return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected);
|
||||
}
|
||||
|
||||
private static void SetServiceLevelValue(IServerInternal server, byte level)
|
||||
{
|
||||
var serviceLevelNodeId = VariableIds.Server_ServiceLevel;
|
||||
var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
||||
serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
|
||||
if (serviceLevelNode != null)
|
||||
{
|
||||
serviceLevelNode.Value = level;
|
||||
serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
|
||||
{
|
||||
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
||||
{
|
||||
if (!_authConfig.AllowAnonymous)
|
||||
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected,
|
||||
"Anonymous access is disabled");
|
||||
|
||||
args.Identity = new RoleBasedIdentity(
|
||||
new UserIdentity(anonymousToken),
|
||||
new List<Role> { Role.Anonymous });
|
||||
Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewIdentity is UserNameIdentityToken userNameToken)
|
||||
{
|
||||
var password = userNameToken.DecryptedPassword ?? "";
|
||||
|
||||
if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password))
|
||||
{
|
||||
Log.Warning("AUDIT: Authentication FAILED for user {Username} from session {SessionId}",
|
||||
userNameToken.UserName, session?.Id);
|
||||
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
|
||||
}
|
||||
|
||||
var roles = new List<Role> { Role.AuthenticatedUser };
|
||||
|
||||
if (_authProvider is IRoleProvider roleProvider)
|
||||
{
|
||||
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
|
||||
|
||||
foreach (var appRole in appRoles)
|
||||
switch (appRole)
|
||||
{
|
||||
case AppRoles.ReadOnly:
|
||||
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
||||
break;
|
||||
case AppRoles.WriteOperate:
|
||||
if (_writeOperateRoleId != null)
|
||||
roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
|
||||
break;
|
||||
case AppRoles.WriteTune:
|
||||
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
|
||||
break;
|
||||
case AppRoles.WriteConfigure:
|
||||
if (_writeConfigureRoleId != null)
|
||||
roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
|
||||
break;
|
||||
case AppRoles.AlarmAck:
|
||||
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
|
||||
break;
|
||||
}
|
||||
|
||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} with roles [{Roles}] session {SessionId}",
|
||||
userNameToken.UserName, string.Join(", ", appRoles), session?.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} session {SessionId}",
|
||||
userNameToken.UserName, session?.Id);
|
||||
}
|
||||
|
||||
args.Identity = new RoleBasedIdentity(
|
||||
new UserIdentity(userNameToken), roles);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewIdentity is X509IdentityToken x509Token)
|
||||
{
|
||||
var cert = x509Token.Certificate;
|
||||
var subject = cert?.Subject ?? "Unknown";
|
||||
|
||||
// Extract CN from certificate subject for display
|
||||
var cn = subject;
|
||||
var cnStart = subject.IndexOf("CN=", StringComparison.OrdinalIgnoreCase);
|
||||
if (cnStart >= 0)
|
||||
{
|
||||
cn = subject.Substring(cnStart + 3);
|
||||
var commaIdx = cn.IndexOf(',');
|
||||
if (commaIdx >= 0)
|
||||
cn = cn.Substring(0, commaIdx);
|
||||
}
|
||||
|
||||
var roles = new List<Role> { Role.AuthenticatedUser };
|
||||
|
||||
// X.509 authenticated users get ReadOnly role by default
|
||||
if (_readOnlyRoleId != null)
|
||||
roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
||||
|
||||
args.Identity = new RoleBasedIdentity(
|
||||
new UserIdentity(x509Token), roles);
|
||||
Log.Information("X509 certificate authenticated: CN={CN}, Subject={Subject}, Thumbprint={Thumbprint}",
|
||||
cn, subject, cert?.Thumbprint);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = DateTime.UtcNow
|
||||
};
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005)
|
||||
/// </summary>
|
||||
public static class OpcUaQualityMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts bridge quality values into OPC UA status codes.
|
||||
/// </summary>
|
||||
/// <param name="quality">The bridge quality value.</param>
|
||||
/// <returns>The OPC UA status code to publish.</returns>
|
||||
public static StatusCode ToStatusCode(Quality quality)
|
||||
{
|
||||
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA status code back into a bridge quality category.
|
||||
/// </summary>
|
||||
/// <param name="statusCode">The OPC UA status code to interpret.</param>
|
||||
/// <returns>The bridge quality category represented by the status code.</returns>
|
||||
public static Quality FromStatusCode(StatusCode statusCode)
|
||||
{
|
||||
if (StatusCode.IsGood(statusCode)) return Quality.Good;
|
||||
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
|
||||
return Quality.Bad;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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 AlarmObjectFilter? _alarmObjectFilter;
|
||||
private readonly AuthenticationConfiguration _authConfig;
|
||||
private readonly IUserAuthenticationProvider? _authProvider;
|
||||
|
||||
private readonly OpcUaConfiguration _config;
|
||||
private readonly IHistorianDataSource? _historianDataSource;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly IMxAccessClient _mxAccessClient;
|
||||
private readonly RedundancyConfiguration _redundancyConfig;
|
||||
private readonly SecurityProfileConfiguration _securityConfig;
|
||||
private ApplicationInstance? _application;
|
||||
private LmxOpcUaServer? _server;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
|
||||
/// </summary>
|
||||
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
|
||||
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
|
||||
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
|
||||
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
|
||||
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
||||
IHistorianDataSource? historianDataSource = null,
|
||||
AuthenticationConfiguration? authConfig = null,
|
||||
IUserAuthenticationProvider? authProvider = null,
|
||||
SecurityProfileConfiguration? securityConfig = null,
|
||||
RedundancyConfiguration? redundancyConfig = null,
|
||||
AlarmObjectFilter? alarmObjectFilter = null,
|
||||
MxAccessConfiguration? mxAccessConfig = null,
|
||||
HistorianConfiguration? historianConfig = null)
|
||||
{
|
||||
_config = config;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
_historianDataSource = historianDataSource;
|
||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
||||
_authProvider = authProvider;
|
||||
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
|
||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
||||
_alarmObjectFilter = alarmObjectFilter;
|
||||
_mxAccessConfig = mxAccessConfig ?? new MxAccessConfiguration();
|
||||
_historianConfig = historianConfig ?? new HistorianConfiguration();
|
||||
}
|
||||
|
||||
private readonly MxAccessConfiguration _mxAccessConfig;
|
||||
private readonly HistorianConfiguration _historianConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active node manager that holds the published Galaxy namespace.
|
||||
/// </summary>
|
||||
public LmxNodeManager? NodeManager => _server?.NodeManager;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of currently connected OPC UA client sessions.
|
||||
/// </summary>
|
||||
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
|
||||
/// </summary>
|
||||
public bool IsRunning => _server != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of opc.tcp base addresses the server is currently listening on.
|
||||
/// Returns an empty list when the server has not started.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BaseAddresses
|
||||
{
|
||||
get
|
||||
{
|
||||
var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses;
|
||||
return addrs != null ? addrs.ToList() : Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri).
|
||||
/// Returns an empty list when the server has not started.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ServerSecurityPolicy> SecurityPolicies
|
||||
{
|
||||
get
|
||||
{
|
||||
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies;
|
||||
return policies != null ? policies.ToList() : Array.Empty<ServerSecurityPolicy>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate).
|
||||
/// Returns an empty list when the server has not started.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> UserTokenPolicies
|
||||
{
|
||||
get
|
||||
{
|
||||
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies;
|
||||
return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the host and releases server resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the OPC UA ServiceLevel based on current runtime health.
|
||||
/// </summary>
|
||||
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured
|
||||
/// endpoint.
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
|
||||
var applicationUri = _config.ApplicationUri ?? namespaceUri;
|
||||
|
||||
// Resolve configured security profiles
|
||||
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
|
||||
foreach (var sp in securityPolicies)
|
||||
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
|
||||
|
||||
// Build PKI paths
|
||||
var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OPC Foundation", "pki");
|
||||
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
|
||||
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
|
||||
MaxSessionCount = _config.MaxSessions,
|
||||
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
|
||||
MinSessionTimeout = 10000,
|
||||
UserTokenPolicies = BuildUserTokenPolicies()
|
||||
};
|
||||
foreach (var policy in securityPolicies)
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
|
||||
var secConfig = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "own"),
|
||||
SubjectName = certSubject
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "issuer")
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "trusted")
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "rejected")
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
|
||||
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
|
||||
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
|
||||
};
|
||||
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationUri = applicationUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ProductUri = namespaceUri,
|
||||
ServerConfiguration = serverConfig,
|
||||
SecurityConfiguration = secConfig,
|
||||
|
||||
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);
|
||||
|
||||
// Hook certificate validation logging
|
||||
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
|
||||
|
||||
_application = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ApplicationConfiguration = appConfig
|
||||
};
|
||||
|
||||
// Check/create application certificate
|
||||
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
||||
var certLifetimeMonths = (ushort)_securityConfig.CertificateLifetimeMonths;
|
||||
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
||||
if (!certOk)
|
||||
{
|
||||
Log.Warning("Application certificate check failed, attempting to create...");
|
||||
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
||||
}
|
||||
|
||||
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
|
||||
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri,
|
||||
_alarmObjectFilter,
|
||||
_mxAccessConfig.RuntimeStatusProbesEnabled,
|
||||
_mxAccessConfig.RuntimeStatusUnknownTimeoutSeconds,
|
||||
_mxAccessConfig.RequestTimeoutSeconds,
|
||||
_historianConfig.RequestTimeoutSeconds);
|
||||
await _application.Start(_server);
|
||||
|
||||
Log.Information(
|
||||
"OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
|
||||
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
|
||||
}
|
||||
|
||||
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
|
||||
{
|
||||
var cert = e.Certificate;
|
||||
var subject = cert?.Subject ?? "Unknown";
|
||||
var thumbprint = cert?.Thumbprint ?? "N/A";
|
||||
|
||||
if (_securityConfig.AutoAcceptClientCertificates)
|
||||
{
|
||||
e.Accept = true;
|
||||
Log.Warning(
|
||||
"Client certificate auto-accepted: Subject={Subject}, Thumbprint={Thumbprint}, ValidTo={ValidTo}",
|
||||
subject, thumbprint, cert?.NotAfter.ToString("yyyy-MM-dd"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning(
|
||||
"Client certificate validation: Error={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}",
|
||||
e.Error?.StatusCode, subject, thumbprint, e.Accept);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the OPC UA application instance and releases its in-memory server objects.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private UserTokenPolicyCollection BuildUserTokenPolicies()
|
||||
{
|
||||
var policies = new UserTokenPolicyCollection();
|
||||
if (_authConfig.AllowAnonymous)
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
||||
if (_authConfig.Ldap.Enabled || _authProvider != null)
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
|
||||
|
||||
// X.509 certificate authentication is always available when security is configured
|
||||
if (_securityConfig.Profiles.Any(p =>
|
||||
!p.Equals("None", StringComparison.OrdinalIgnoreCase)))
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.Certificate));
|
||||
|
||||
if (policies.Count == 0)
|
||||
{
|
||||
Log.Warning("No authentication methods configured — adding Anonymous as fallback");
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a configured redundancy mode string to the OPC UA <see cref="RedundancySupport" /> enum.
|
||||
/// </summary>
|
||||
public static class RedundancyModeResolver
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(RedundancyModeResolver));
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured mode string to a <see cref="RedundancySupport" /> value.
|
||||
/// Returns <see cref="RedundancySupport.None" /> when redundancy is disabled or the mode is unrecognized.
|
||||
/// </summary>
|
||||
/// <param name="mode">The mode string from configuration (e.g., "Warm", "Hot").</param>
|
||||
/// <param name="enabled">Whether redundancy is enabled.</param>
|
||||
/// <returns>The resolved redundancy support mode.</returns>
|
||||
public static RedundancySupport Resolve(string mode, bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
return RedundancySupport.None;
|
||||
|
||||
var resolved = (mode ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"warm" => RedundancySupport.Warm,
|
||||
"hot" => RedundancySupport.Hot,
|
||||
_ => RedundancySupport.None
|
||||
};
|
||||
|
||||
if (resolved == RedundancySupport.None)
|
||||
Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot",
|
||||
mode);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps configured security profile names to OPC UA <see cref="ServerSecurityPolicy" /> instances.
|
||||
/// </summary>
|
||||
public static class SecurityProfileResolver
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
|
||||
|
||||
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["None"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None
|
||||
},
|
||||
["Basic256Sha256-Sign"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
||||
},
|
||||
["Basic256Sha256-SignAndEncrypt"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
||||
},
|
||||
["Aes128_Sha256_RsaOaep-Sign"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
|
||||
},
|
||||
["Aes128_Sha256_RsaOaep-SignAndEncrypt"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
|
||||
},
|
||||
["Aes256_Sha256_RsaPss-Sign"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
|
||||
},
|
||||
["Aes256_Sha256_RsaPss-SignAndEncrypt"] = new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of valid profile names for validation and documentation.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy" /> entries.
|
||||
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
|
||||
/// </summary>
|
||||
/// <param name="profileNames">The profile names from configuration.</param>
|
||||
/// <returns>A deduplicated list of server security policies.</returns>
|
||||
public static List<ServerSecurityPolicy> Resolve(IReadOnlyCollection<string> profileNames)
|
||||
{
|
||||
var resolved = new List<ServerSecurityPolicy>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in profileNames ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
|
||||
var trimmed = name.Trim();
|
||||
|
||||
if (!seen.Add(trimmed))
|
||||
{
|
||||
Log.Debug("Skipping duplicate security profile: {Profile}", trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (KnownProfiles.TryGetValue(trimmed, out var policy))
|
||||
resolved.Add(policy);
|
||||
else
|
||||
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
|
||||
trimmed, string.Join(", ", KnownProfiles.Keys));
|
||||
}
|
||||
|
||||
if (resolved.Count == 0)
|
||||
{
|
||||
Log.Warning("No valid security profiles configured — falling back to None");
|
||||
resolved.Add(KnownProfiles["None"]);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs.
|
||||
/// </summary>
|
||||
public sealed class ServiceLevelCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the current ServiceLevel from a role-adjusted baseline and health state.
|
||||
/// </summary>
|
||||
/// <param name="baseLevel">The role-adjusted baseline (e.g., 200 for primary, 150 for secondary).</param>
|
||||
/// <param name="mxAccessConnected">Whether the MXAccess runtime connection is healthy.</param>
|
||||
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
|
||||
/// <returns>A ServiceLevel byte between 0 and 255.</returns>
|
||||
public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
if (!mxAccessConnected && !dbConnected)
|
||||
return 0;
|
||||
|
||||
var level = baseLevel;
|
||||
|
||||
if (!mxAccessConnected)
|
||||
level -= 100;
|
||||
|
||||
if (!dbConnected)
|
||||
level -= 50;
|
||||
|
||||
return (byte)Math.Max(0, Math.Min(level, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Status;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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 IUserAuthenticationProvider? _authProviderOverride;
|
||||
|
||||
private readonly AppConfiguration _config;
|
||||
private readonly IGalaxyRepository? _galaxyRepository;
|
||||
private readonly bool _hasAuthProviderOverride;
|
||||
private readonly bool _hasMxAccessClientOverride;
|
||||
private readonly IMxAccessClient? _mxAccessClientOverride;
|
||||
private readonly IMxProxy? _mxProxy;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private HealthCheckService? _healthCheck;
|
||||
private IHistorianDataSource? _historianDataSource;
|
||||
private MxAccessClient? _mxAccessClient;
|
||||
private IMxAccessClient? _mxAccessClientForWiring;
|
||||
private StaComThread? _staThread;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor. Loads configuration from appsettings.json.
|
||||
/// </summary>
|
||||
public OpcUaService()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", false)
|
||||
.AddJsonFile(
|
||||
$"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json",
|
||||
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);
|
||||
configuration.GetSection("Historian").Bind(_config.Historian);
|
||||
configuration.GetSection("Authentication").Bind(_config.Authentication);
|
||||
// Clear the default Profiles list before binding so JSON values replace rather than append
|
||||
_config.Security.Profiles.Clear();
|
||||
configuration.GetSection("Security").Bind(_config.Security);
|
||||
configuration.GetSection("Redundancy").Bind(_config.Redundancy);
|
||||
|
||||
_mxProxy = new MxProxyAdapter();
|
||||
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test constructor. Accepts injected dependencies.
|
||||
/// </summary>
|
||||
/// <param name="config">
|
||||
/// The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard
|
||||
/// behavior during the test run.
|
||||
/// </param>
|
||||
/// <param name="mxProxy">The MXAccess proxy substitute used when a test wants to exercise COM-style wiring.</param>
|
||||
/// <param name="galaxyRepository">
|
||||
/// The repository substitute that supplies Galaxy hierarchy and deploy metadata for
|
||||
/// address-space builds.
|
||||
/// </param>
|
||||
/// <param name="mxAccessClientOverride">
|
||||
/// An optional direct MXAccess client substitute that bypasses STA thread setup and
|
||||
/// COM interop.
|
||||
/// </param>
|
||||
/// <param name="hasMxAccessClientOverride">
|
||||
/// A value indicating whether the override client should be used instead of
|
||||
/// creating a client from <paramref name="mxProxy" />.
|
||||
/// </param>
|
||||
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
|
||||
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false,
|
||||
IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false)
|
||||
{
|
||||
_config = config;
|
||||
_mxProxy = mxProxy;
|
||||
_galaxyRepository = galaxyRepository;
|
||||
_mxAccessClientOverride = mxAccessClientOverride;
|
||||
_hasMxAccessClientOverride = hasMxAccessClientOverride;
|
||||
_authProviderOverride = authProviderOverride;
|
||||
_hasAuthProviderOverride = hasAuthProviderOverride;
|
||||
}
|
||||
|
||||
// Accessors for testing
|
||||
/// <summary>
|
||||
/// Gets the MXAccess client instance currently wired into the service for test inspection.
|
||||
/// </summary>
|
||||
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the metrics collector that tracks bridge operation timings during the service lifetime.
|
||||
/// </summary>
|
||||
internal PerformanceMetrics? Metrics { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OPC UA server host that owns the runtime endpoint.
|
||||
/// </summary>
|
||||
internal OpcUaServerHost? ServerHost { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the node manager instance that holds the current Galaxy-derived address space.
|
||||
/// </summary>
|
||||
internal LmxNodeManager? NodeManagerInstance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild.
|
||||
/// </summary>
|
||||
internal ChangeDetectionService? ChangeDetectionInstance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hosted status web server when the dashboard is enabled and successfully bound.
|
||||
/// Null when <c>Dashboard.Enabled</c> is false or when <see cref="DashboardStartFailed"/> is true.
|
||||
/// </summary>
|
||||
internal StatusWebServer? StatusWeb { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a flag indicating that the dashboard was enabled in configuration but failed to bind
|
||||
/// its HTTP port at startup. The service continues in degraded mode (matching the pattern
|
||||
/// for other optional subsystems: MxAccess connect, Galaxy DB connect, initial address space
|
||||
/// build). Surfaced for tests and any external health probe that needs to distinguish
|
||||
/// "dashboard disabled by config" from "dashboard failed to start".
|
||||
/// </summary>
|
||||
internal bool DashboardStartFailed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard report generator used to assemble operator-facing status snapshots.
|
||||
/// </summary>
|
||||
internal StatusReportService? StatusReportInstance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds.
|
||||
/// </summary>
|
||||
internal GalaxyRepositoryStats? GalaxyStatsInstance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA
|
||||
/// address space, and optionally hosting the status dashboard.
|
||||
/// </summary>
|
||||
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 (_hasMxAccessClientOverride)
|
||||
{
|
||||
// Test path: use injected IMxAccessClient directly (skips STA thread + COM)
|
||||
_mxAccessClientForWiring = _mxAccessClientOverride;
|
||||
if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected)
|
||||
_mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
else if (_mxProxy != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, Metrics);
|
||||
try
|
||||
{
|
||||
_mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"MxAccess connection failed at startup - monitor will continue retrying in the background");
|
||||
}
|
||||
|
||||
// Step 6: Start monitor loop even if initial connect failed
|
||||
_mxAccessClient.StartMonitor();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "MxAccess initialization failed - continuing without runtime data access");
|
||||
_mxAccessClient?.Dispose();
|
||||
_mxAccessClient = null;
|
||||
_staThread?.Dispose();
|
||||
_staThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Create GalaxyRepositoryService → TestConnection
|
||||
GalaxyStatsInstance = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName };
|
||||
|
||||
if (_galaxyRepository != null)
|
||||
{
|
||||
var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
GalaxyStatsInstance.DbConnected = dbOk;
|
||||
if (!dbOk)
|
||||
Log.Warning("Galaxy repository database connection failed — continuing without initial data");
|
||||
}
|
||||
|
||||
// Step 8: Create OPC UA server host + node manager
|
||||
var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ??
|
||||
_mxAccessClientForWiring ?? new NullMxAccessClient();
|
||||
if (_config.Historian.Enabled)
|
||||
{
|
||||
_historianDataSource = HistorianPluginLoader.TryLoad(_config.Historian);
|
||||
}
|
||||
else
|
||||
{
|
||||
HistorianPluginLoader.MarkDisabled();
|
||||
_historianDataSource = null;
|
||||
}
|
||||
IUserAuthenticationProvider? authProvider = null;
|
||||
if (_hasAuthProviderOverride)
|
||||
{
|
||||
authProvider = _authProviderOverride;
|
||||
}
|
||||
else if (_config.Authentication.Ldap.Enabled)
|
||||
{
|
||||
authProvider = new LdapAuthenticationProvider(_config.Authentication.Ldap);
|
||||
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",
|
||||
_config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port,
|
||||
_config.Authentication.Ldap.BaseDN);
|
||||
}
|
||||
|
||||
var alarmObjectFilter = new AlarmObjectFilter(_config.OpcUa.AlarmFilter);
|
||||
if (alarmObjectFilter.Enabled)
|
||||
Log.Information(
|
||||
"Alarm object filter compiled with {PatternCount} pattern(s): [{Patterns}]",
|
||||
alarmObjectFilter.PatternCount,
|
||||
string.Join(", ", _config.OpcUa.AlarmFilter.ObjectFilters));
|
||||
|
||||
ServerHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, Metrics, _historianDataSource,
|
||||
_config.Authentication, authProvider, _config.Security, _config.Redundancy, alarmObjectFilter,
|
||||
_config.MxAccess, _config.Historian);
|
||||
|
||||
// Step 9-10: Query hierarchy, start server, build address space
|
||||
DateTime? initialDeployTime = null;
|
||||
if (_galaxyRepository != null && GalaxyStatsInstance.DbConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter()
|
||||
.GetResult();
|
||||
var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult();
|
||||
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
|
||||
GalaxyStatsInstance.AttributeCount = attributes.Count;
|
||||
|
||||
ServerHost.StartAsync().GetAwaiter().GetResult();
|
||||
NodeManagerInstance = ServerHost.NodeManager;
|
||||
|
||||
if (NodeManagerInstance != null)
|
||||
{
|
||||
NodeManagerInstance.BuildAddressSpace(hierarchy, attributes);
|
||||
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to build initial address space");
|
||||
if (!ServerHost.IsRunning)
|
||||
{
|
||||
ServerHost.StartAsync().GetAwaiter().GetResult();
|
||||
NodeManagerInstance = ServerHost.NodeManager;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ServerHost.StartAsync().GetAwaiter().GetResult();
|
||||
NodeManagerInstance = ServerHost.NodeManager;
|
||||
}
|
||||
|
||||
// Step 11-12: Change detection wired to rebuild
|
||||
if (_galaxyRepository != null)
|
||||
{
|
||||
ChangeDetectionInstance = new ChangeDetectionService(_galaxyRepository,
|
||||
_config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime);
|
||||
ChangeDetectionInstance.OnGalaxyChanged += OnGalaxyChanged;
|
||||
ChangeDetectionInstance.Start();
|
||||
}
|
||||
|
||||
// Step 13: Dashboard
|
||||
_healthCheck = new HealthCheckService();
|
||||
StatusReportInstance = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds);
|
||||
StatusReportInstance.SetComponents(effectiveMxClient, Metrics, GalaxyStatsInstance, ServerHost,
|
||||
NodeManagerInstance,
|
||||
_config.Redundancy, _config.OpcUa.ApplicationUri, _config.Historian);
|
||||
|
||||
if (_config.Dashboard.Enabled)
|
||||
{
|
||||
var dashboardServer = new StatusWebServer(StatusReportInstance, _config.Dashboard.Port);
|
||||
if (dashboardServer.Start())
|
||||
{
|
||||
StatusWeb = dashboardServer;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Degraded mode: StatusWebServer.Start() already logged the underlying exception.
|
||||
// Dispose the unstarted instance, null out the reference, and flag the failure so
|
||||
// tests and health probes can observe it. Service startup continues.
|
||||
Log.Warning("Status dashboard failed to bind on port {Port}; service continues without dashboard",
|
||||
_config.Dashboard.Port);
|
||||
dashboardServer.Dispose();
|
||||
DashboardStartFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Wire ServiceLevel updates from MXAccess health changes
|
||||
if (_config.Redundancy.Enabled)
|
||||
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
|
||||
|
||||
// Step 14
|
||||
Log.Information("LmxOpcUa service started successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "LmxOpcUa service failed to start");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in
|
||||
/// shutdown order.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
Log.Information("LmxOpcUa service stopping");
|
||||
|
||||
try
|
||||
{
|
||||
_cts?.Cancel();
|
||||
ChangeDetectionInstance?.Stop();
|
||||
ServerHost?.Stop();
|
||||
|
||||
if (_mxAccessClient != null)
|
||||
{
|
||||
_mxAccessClient.StopMonitor();
|
||||
_mxAccessClient.DisconnectAsync().GetAwaiter().GetResult();
|
||||
_mxAccessClient.Dispose();
|
||||
}
|
||||
|
||||
_staThread?.Dispose();
|
||||
_historianDataSource?.Dispose();
|
||||
|
||||
StatusWeb?.Dispose();
|
||||
Metrics?.Dispose();
|
||||
ChangeDetectionInstance?.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 || NodeManagerInstance == null) return;
|
||||
|
||||
var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult();
|
||||
var attributes = _galaxyRepository.GetAttributesAsync().GetAwaiter().GetResult();
|
||||
|
||||
NodeManagerInstance.RebuildAddressSpace(hierarchy, attributes);
|
||||
|
||||
if (GalaxyStatsInstance != null)
|
||||
{
|
||||
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
|
||||
GalaxyStatsInstance.AttributeCount = attributes.Count;
|
||||
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
|
||||
GalaxyStatsInstance.LastDeployTime = ChangeDetectionInstance?.LastKnownDeployTime;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to rebuild address space");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMxAccessStateChangedForServiceLevel(object? sender, ConnectionStateChangedEventArgs e)
|
||||
{
|
||||
var mxConnected = e.CurrentState == ConnectionState.Connected;
|
||||
var dbConnected = GalaxyStatsInstance?.DbConnected ?? false;
|
||||
ServerHost?.UpdateServiceLevel(mxConnected, dbConnected);
|
||||
Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected);
|
||||
}
|
||||
|
||||
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})",
|
||||
e.IsTerminating);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers an address space rebuild from the current Galaxy repository data. For testing.
|
||||
/// </summary>
|
||||
internal void TriggerRebuild()
|
||||
{
|
||||
OnGalaxyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IMxAccessClient for when MXAccess is not available.
|
||||
/// </summary>
|
||||
internal sealed class NullMxAccessClient : IMxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity.
|
||||
/// </summary>
|
||||
public ConnectionState State => ConnectionState.Disconnected;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active subscription count, which is always zero for the null runtime client.
|
||||
/// </summary>
|
||||
public int ActiveSubscriptionCount => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reconnect count, which is always zero because the null client never establishes a session.
|
||||
/// </summary>
|
||||
public int ReconnectCount => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the runtime connection state changes. The null client never raises this event.
|
||||
/// </summary>
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a subscribed tag value changes. The null client never raises this event.
|
||||
/// </summary>
|
||||
public event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Completes immediately because no live runtime connection is available or required.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
||||
public Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes immediately because there is no live runtime session to close.
|
||||
/// </summary>
|
||||
public Task DisconnectAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes immediately because the null client does not subscribe to live Galaxy attributes.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The tag reference that would have been subscribed.</param>
|
||||
/// <param name="callback">The callback that would have received runtime value changes.</param>
|
||||
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes immediately because the null client does not maintain runtime subscriptions.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The tag reference that would have been unsubscribed.</param>
|
||||
public Task UnsubscribeAsync(string fullTagReference)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a bad-quality value because no live runtime source exists.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The tag reference that would have been read from the runtime.</param>
|
||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
||||
/// <returns>A bad-quality VTQ indicating that runtime data is unavailable.</returns>
|
||||
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(Vtq.Bad());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects writes because there is no live runtime endpoint behind the null client.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The tag reference that would have been written.</param>
|
||||
/// <param name="value">The value that would have been sent to the runtime.</param>
|
||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
||||
/// <returns>A completed task returning <see langword="false" />.</returns>
|
||||
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the null client. No unmanaged runtime resources exist.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host
|
||||
{
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing OpcUaService with dependency overrides.
|
||||
/// Used by integration tests to substitute fakes for COM/DB components.
|
||||
/// </summary>
|
||||
internal class OpcUaServiceBuilder
|
||||
{
|
||||
private IUserAuthenticationProvider? _authProvider;
|
||||
private bool _authProviderSet;
|
||||
private AppConfiguration _config = new();
|
||||
private IGalaxyRepository? _galaxyRepository;
|
||||
private bool _galaxyRepositorySet;
|
||||
private IMxAccessClient? _mxAccessClient;
|
||||
private bool _mxAccessClientSet;
|
||||
private IMxProxy? _mxProxy;
|
||||
private bool _mxProxySet;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the default service configuration used by the test host.
|
||||
/// </summary>
|
||||
/// <param name="config">The full configuration snapshot to inject into the service under test.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithConfig(AppConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the OPC UA port used by the test host so multiple integration runs can coexist.
|
||||
/// </summary>
|
||||
/// <param name="port">The TCP port to expose for the test server.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithOpcUaPort(int port)
|
||||
{
|
||||
_config.OpcUa.Port = port;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Galaxy name represented by the test address space.
|
||||
/// </summary>
|
||||
/// <param name="name">The Galaxy name to expose through OPC UA and diagnostics.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithGalaxyName(string name)
|
||||
{
|
||||
_config.OpcUa.GalaxyName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path.
|
||||
/// </summary>
|
||||
/// <param name="proxy">The proxy fake or stub to supply to the service.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy)
|
||||
{
|
||||
_mxProxy = proxy;
|
||||
_mxProxySet = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata.
|
||||
/// </summary>
|
||||
/// <param name="repository">The repository fake or stub to supply to the service.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository)
|
||||
{
|
||||
_galaxyRepository = repository;
|
||||
_galaxyRepositorySet = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override the MxAccessClient directly, skipping STA thread and COM interop entirely.
|
||||
/// When set, the service will use this client instead of creating one from IMxProxy.
|
||||
/// </summary>
|
||||
/// <param name="client">The direct MXAccess client substitute to inject into the service.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client)
|
||||
{
|
||||
_mxAccessClient = client;
|
||||
_mxAccessClientSet = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests.
|
||||
/// </summary>
|
||||
/// <param name="hierarchy">The object hierarchy to expose through the test OPC UA namespace.</param>
|
||||
/// <param name="attributes">The attribute rows to attach to the hierarchy.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithHierarchy(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
if (!_galaxyRepositorySet)
|
||||
{
|
||||
var fake = new FakeBuilderGalaxyRepository();
|
||||
_galaxyRepository = fake;
|
||||
_galaxyRepositorySet = true;
|
||||
}
|
||||
|
||||
if (_galaxyRepository is FakeBuilderGalaxyRepository fakeRepo)
|
||||
{
|
||||
fakeRepo.Hierarchy = hierarchy;
|
||||
fakeRepo.Attributes = attributes;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener.
|
||||
/// </summary>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
/// <summary>
|
||||
/// Injects a custom authentication provider for tests that need deterministic role resolution.
|
||||
/// </summary>
|
||||
public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider)
|
||||
{
|
||||
_authProvider = provider;
|
||||
_authProviderSet = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the authentication configuration for the test host.
|
||||
/// </summary>
|
||||
public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig)
|
||||
{
|
||||
_config.Authentication = authConfig;
|
||||
return this;
|
||||
}
|
||||
|
||||
public OpcUaServiceBuilder DisableDashboard()
|
||||
{
|
||||
_config.Dashboard.Enabled = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the redundancy configuration for the test host.
|
||||
/// </summary>
|
||||
/// <param name="redundancy">The redundancy configuration to inject.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithRedundancy(RedundancyConfiguration redundancy)
|
||||
{
|
||||
_config.Redundancy = redundancy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the application URI for the test host, distinct from the namespace URI.
|
||||
/// </summary>
|
||||
/// <param name="applicationUri">The unique application URI for this server instance.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithApplicationUri(string applicationUri)
|
||||
{
|
||||
_config.OpcUa.ApplicationUri = applicationUri;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the security profile configuration for the test host.
|
||||
/// </summary>
|
||||
/// <param name="security">The security profile configuration to inject.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
/// <summary>
|
||||
/// Enables alarm condition tracking on the test host so integration tests can exercise the alarm-creation path.
|
||||
/// </summary>
|
||||
/// <param name="enabled">Whether alarm tracking should be enabled.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithAlarmTracking(bool enabled)
|
||||
{
|
||||
_config.OpcUa.AlarmTrackingEnabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the template-based alarm object filter for integration tests.
|
||||
/// </summary>
|
||||
/// <param name="filters">Zero or more wildcard patterns. Empty → filter disabled.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithAlarmFilter(params string[] filters)
|
||||
{
|
||||
_config.OpcUa.AlarmFilter = new AlarmFilterConfiguration
|
||||
{
|
||||
ObjectFilters = filters.ToList()
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security)
|
||||
{
|
||||
_config.Security = security;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations.
|
||||
/// </summary>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder DisableChangeDetection()
|
||||
{
|
||||
_config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="OpcUaService" /> using the accumulated test doubles and configuration overrides.
|
||||
/// </summary>
|
||||
/// <returns>A service instance ready for integration-style testing.</returns>
|
||||
public OpcUaService Build()
|
||||
{
|
||||
return new OpcUaService(
|
||||
_config,
|
||||
_mxProxySet ? _mxProxy : null,
|
||||
_galaxyRepositorySet ? _galaxyRepository : null,
|
||||
_mxAccessClientSet ? _mxAccessClient : null,
|
||||
_mxAccessClientSet,
|
||||
_authProviderSet ? _authProvider : null,
|
||||
_authProviderSet);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal fake repository used by WithHierarchy for convenience.
|
||||
/// </summary>
|
||||
private class FakeBuilderGalaxyRepository : IGalaxyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the hierarchy rows that the fake repository returns to the service.
|
||||
/// </summary>
|
||||
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the attribute rows that the fake repository returns to the service.
|
||||
/// </summary>
|
||||
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the fake repository wants to simulate a Galaxy deploy change.
|
||||
/// </summary>
|
||||
public event Action? OnGalaxyChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the seeded hierarchy rows for address-space construction.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||
/// <returns>The configured hierarchy rows.</returns>
|
||||
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(Hierarchy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the seeded attribute rows for address-space construction.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||
/// <returns>The configured attribute rows.</returns>
|
||||
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(Attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||
/// <returns>The current UTC time.</returns>
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<DateTime?>(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a healthy repository connection for builder-based test setups.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||
/// <returns>A completed task returning <see langword="true" />.</returns>
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using Serilog;
|
||||
using Topshelf;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
// Set working directory to exe location so relative log paths resolve correctly
|
||||
// (Windows services default to System32)
|
||||
Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
"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("OtOpcUa");
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
using System.Linq;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines health status based on connection state and operation success rates. (DASH-003)
|
||||
/// </summary>
|
||||
public class HealthCheckService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates bridge health from runtime connectivity, recorded performance metrics, and optional
|
||||
/// historian/alarm integration state.
|
||||
/// </summary>
|
||||
/// <param name="connectionState">The current MXAccess connection state.</param>
|
||||
/// <param name="metrics">The recorded performance metrics, if available.</param>
|
||||
/// <param name="historian">Optional historian integration snapshot; pass <c>null</c> to skip historian health rules.</param>
|
||||
/// <param name="alarms">Optional alarm integration snapshot; pass <c>null</c> to skip alarm health rules.</param>
|
||||
/// <returns>A dashboard health snapshot describing the current service condition.</returns>
|
||||
public HealthInfo CheckHealth(
|
||||
ConnectionState connectionState,
|
||||
PerformanceMetrics? metrics,
|
||||
HistorianStatusInfo? historian = null,
|
||||
AlarmStatusInfo? alarms = null,
|
||||
RuntimeStatusInfo? runtime = null)
|
||||
{
|
||||
// Rule 1: Not connected → Unhealthy
|
||||
if (connectionState != ConnectionState.Connected)
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Unhealthy",
|
||||
Message = $"MXAccess not connected (state: {connectionState})",
|
||||
Color = "red"
|
||||
};
|
||||
|
||||
// Rule 2b: Historian enabled but plugin did not load → Degraded
|
||||
if (historian != null && historian.Enabled && historian.PluginStatus != "Loaded")
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Degraded",
|
||||
Message =
|
||||
$"Historian enabled but plugin status is {historian.PluginStatus}: {historian.PluginError ?? "(no error)"}",
|
||||
Color = "yellow"
|
||||
};
|
||||
|
||||
// Rule 2b2: Historian plugin loaded but queries are failing consecutively → Degraded.
|
||||
// Threshold of 3 avoids flagging a single transient blip; anything beyond that means
|
||||
// the SDK is in a broken state that the reconnect loop isn't recovering from.
|
||||
if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded"
|
||||
&& historian.ConsecutiveFailures >= 3)
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Degraded",
|
||||
Message =
|
||||
$"Historian plugin has {historian.ConsecutiveFailures} consecutive query failures: " +
|
||||
$"{historian.LastQueryError ?? "(no error)"}",
|
||||
Color = "yellow"
|
||||
};
|
||||
|
||||
// Rule 2b3: Historian cluster has nodes in cooldown → Degraded (partial cluster).
|
||||
// Only surfaces when the operator actually configured a multi-node cluster.
|
||||
if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded"
|
||||
&& historian.NodeCount > 1 && historian.HealthyNodeCount < historian.NodeCount)
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Degraded",
|
||||
Message =
|
||||
$"Historian cluster has {historian.HealthyNodeCount} of {historian.NodeCount} " +
|
||||
"nodes healthy — one or more nodes are in failure cooldown",
|
||||
Color = "yellow"
|
||||
};
|
||||
|
||||
// Rule 2 / 2c: Success rate too low for any recorded operation
|
||||
if (metrics != null)
|
||||
{
|
||||
var stats = metrics.GetStatistics();
|
||||
foreach (var kvp in stats)
|
||||
{
|
||||
var isHistoryOp = kvp.Key.StartsWith("HistoryRead", System.StringComparison.OrdinalIgnoreCase);
|
||||
// History reads are rare; drop the sample threshold so a stuck historian surfaces quickly.
|
||||
var sampleThreshold = isHistoryOp ? 10 : 100;
|
||||
if (kvp.Value.TotalCount > sampleThreshold && 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 2d: Any alarm acknowledge write has failed since startup → Degraded (latched)
|
||||
if (alarms != null && alarms.TrackingEnabled && alarms.AckWriteFailures > 0)
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Degraded",
|
||||
Message = $"Alarm acknowledge writes have failed ({alarms.AckWriteFailures} total)",
|
||||
Color = "yellow"
|
||||
};
|
||||
|
||||
// Rule 2e: Any Galaxy runtime host (Platform/AppEngine) is Stopped → Degraded.
|
||||
// Runs after the transport check so that MxAccess-disconnected remains Unhealthy via
|
||||
// Rule 1 without also firing the runtime rule — avoids a double-message when the
|
||||
// transport is the root cause of every host going Unknown/Stopped.
|
||||
if (runtime != null && runtime.StoppedCount > 0)
|
||||
{
|
||||
var stoppedNames = string.Join(", ",
|
||||
runtime.Hosts.Where(h => h.State == Domain.GalaxyRuntimeState.Stopped).Select(h => h.ObjectName));
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Degraded",
|
||||
Message =
|
||||
$"Galaxy runtime has {runtime.StoppedCount} of {runtime.Total} host(s) stopped: {stoppedNames}",
|
||||
Color = "yellow"
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 3: All good
|
||||
return new HealthInfo
|
||||
{
|
||||
Status = "Healthy",
|
||||
Message = "All systems operational",
|
||||
Color = "green"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the bridge should currently be treated as healthy.
|
||||
/// </summary>
|
||||
/// <param name="connectionState">The current MXAccess connection state.</param>
|
||||
/// <param name="metrics">The recorded performance metrics, if available.</param>
|
||||
/// <returns><see langword="true" /> when the bridge is not unhealthy; otherwise, <see langword="false" />.</returns>
|
||||
public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics)
|
||||
{
|
||||
var health = CheckHealth(connectionState, metrics);
|
||||
return health.Status != "Unhealthy";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO containing all dashboard data. (DASH-001 through DASH-009)
|
||||
/// </summary>
|
||||
public class StatusData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard.
|
||||
/// </summary>
|
||||
public ConnectionInfo Connection { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the overall health state communicated to operators.
|
||||
/// </summary>
|
||||
public HealthInfo Health { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining.
|
||||
/// </summary>
|
||||
public SubscriptionInfo Subscriptions { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts.
|
||||
/// </summary>
|
||||
public GalaxyInfo Galaxy { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets MXAccess data change dispatch queue metrics.
|
||||
/// </summary>
|
||||
public DataChangeInfo DataChange { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency.
|
||||
/// </summary>
|
||||
public Dictionary<string, MetricsStatistics> Operations { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the historian integration status (plugin load outcome, server target).
|
||||
/// </summary>
|
||||
public HistorianStatusInfo Historian { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the alarm integration status and event counters.
|
||||
/// </summary>
|
||||
public AlarmStatusInfo Alarms { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redundancy state when redundancy is enabled.
|
||||
/// </summary>
|
||||
public RedundancyInfo? Redundancy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the listening OPC UA endpoints and active security profiles.
|
||||
/// </summary>
|
||||
public EndpointsInfo Endpoints { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy runtime host state (Platforms + AppEngines).
|
||||
/// </summary>
|
||||
public RuntimeStatusInfo RuntimeStatus { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets footer details such as the snapshot timestamp and service version.
|
||||
/// </summary>
|
||||
public FooterInfo Footer { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model summarizing per-host Galaxy runtime state.
|
||||
/// </summary>
|
||||
public class RuntimeStatusInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of tracked runtime hosts ($WinPlatform + $AppEngine).
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of hosts currently reported Running.
|
||||
/// </summary>
|
||||
public int RunningCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of hosts currently reported Stopped.
|
||||
/// </summary>
|
||||
public int StoppedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of hosts whose state is still Unknown (either awaiting initial
|
||||
/// probe resolution or transported-through-disconnected).
|
||||
/// </summary>
|
||||
public int UnknownCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-host state in stable alphabetical order.
|
||||
/// </summary>
|
||||
public List<GalaxyRuntimeStatus> Hosts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model describing the OPC UA server's listening endpoints and active security profiles.
|
||||
/// </summary>
|
||||
public class EndpointsInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of opc.tcp base addresses the server is listening on.
|
||||
/// </summary>
|
||||
public List<string> BaseAddresses { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of configured user token policies (Anonymous, UserName, Certificate).
|
||||
/// </summary>
|
||||
public List<string> UserTokenPolicies { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the active security profiles reported to clients.
|
||||
/// </summary>
|
||||
public List<SecurityProfileInfo> SecurityProfiles { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for a single configured OPC UA server security profile.
|
||||
/// </summary>
|
||||
public class SecurityProfileInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the OPC UA security policy URI (e.g., http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256).
|
||||
/// </summary>
|
||||
public string PolicyUri { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the short policy name extracted from the policy URI.
|
||||
/// </summary>
|
||||
public string PolicyName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message security mode (None, Sign, SignAndEncrypt).
|
||||
/// </summary>
|
||||
public string SecurityMode { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for current runtime connection details.
|
||||
/// </summary>
|
||||
public class ConnectionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current MXAccess connection state shown to operators.
|
||||
/// </summary>
|
||||
public string State { get; set; } = "Disconnected";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many reconnect attempts have occurred since the service started.
|
||||
/// </summary>
|
||||
public int ReconnectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of active OPC UA sessions connected to the bridge.
|
||||
/// </summary>
|
||||
public int ActiveSessions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for the overall health banner.
|
||||
/// </summary>
|
||||
public class HealthInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "Unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the operator-facing explanation for the current health state.
|
||||
/// </summary>
|
||||
public string Message { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color token used by the dashboard UI to render the health banner.
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "gray";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for subscription load.
|
||||
/// </summary>
|
||||
public class SubscriptionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA.
|
||||
/// This total includes bridge-owned runtime status probes; see <see cref="ProbeCount"/> for the
|
||||
/// subset attributable to probes.
|
||||
/// </summary>
|
||||
public int ActiveCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of bridge-owned runtime status probes included in
|
||||
/// <see cref="ActiveCount"/>. Surfaced on the dashboard so operators can distinguish probe
|
||||
/// overhead from client-driven subscription load.
|
||||
/// </summary>
|
||||
public int ProbeCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for Galaxy metadata and rebuild status.
|
||||
/// </summary>
|
||||
public class GalaxyInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy name currently being bridged into OPC UA.
|
||||
/// </summary>
|
||||
public string GalaxyName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the repository database is currently reachable.
|
||||
/// </summary>
|
||||
public bool DbConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the most recent deploy timestamp observed in the Galaxy repository.
|
||||
/// </summary>
|
||||
public DateTime? LastDeployTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of Galaxy objects currently represented in the address space.
|
||||
/// </summary>
|
||||
public int ObjectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables.
|
||||
/// </summary>
|
||||
public int AttributeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the last completed address-space rebuild.
|
||||
/// </summary>
|
||||
public DateTime? LastRebuildTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for MXAccess data change dispatch metrics.
|
||||
/// </summary>
|
||||
public class DataChangeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the rate of MXAccess data change events received per second.
|
||||
/// </summary>
|
||||
public double EventsPerSecond { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the average number of items processed per dispatch cycle.
|
||||
/// </summary>
|
||||
public double AvgBatchSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of items currently waiting in the dispatch queue.
|
||||
/// </summary>
|
||||
public int PendingItems { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total MXAccess data change events received since startup.
|
||||
/// </summary>
|
||||
public long TotalEvents { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for the Wonderware historian integration (runtime-loaded plugin).
|
||||
/// </summary>
|
||||
public class HistorianStatusInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether historian support is enabled in configuration.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the most recent plugin load outcome as a string.
|
||||
/// Values: <c>Disabled</c>, <c>NotFound</c>, <c>LoadFailed</c>, <c>Loaded</c>.
|
||||
/// </summary>
|
||||
public string PluginStatus { get; set; } = "Disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message from the last load attempt when <see cref="PluginStatus"/> is <c>LoadFailed</c>.
|
||||
/// </summary>
|
||||
public string? PluginError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the absolute path the loader probed for the plugin assembly.
|
||||
/// </summary>
|
||||
public string PluginPath { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configured historian server hostname.
|
||||
/// </summary>
|
||||
public string ServerName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configured historian TCP port.
|
||||
/// </summary>
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of historian read queries attempted since startup.
|
||||
/// </summary>
|
||||
public long QueryTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of historian queries that completed without an exception.
|
||||
/// </summary>
|
||||
public long QuerySuccesses { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of historian queries that raised an exception.
|
||||
/// </summary>
|
||||
public long QueryFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of consecutive failures since the last successful query.
|
||||
/// </summary>
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the last successful query.
|
||||
/// </summary>
|
||||
public DateTime? LastSuccessTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of the last query failure.
|
||||
/// </summary>
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the exception message from the most recent failure.
|
||||
/// </summary>
|
||||
public string? LastQueryError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the plugin currently holds an open process-path
|
||||
/// SDK connection.
|
||||
/// </summary>
|
||||
public bool ProcessConnectionOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the plugin currently holds an open event-path
|
||||
/// SDK connection.
|
||||
/// </summary>
|
||||
public bool EventConnectionOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of configured historian cluster nodes.
|
||||
/// </summary>
|
||||
public int NodeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of cluster nodes currently eligible for new connections
|
||||
/// (i.e., not in failure cooldown).
|
||||
/// </summary>
|
||||
public int HealthyNodeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the node currently serving process (historical value) queries, or null
|
||||
/// when no process connection is open.
|
||||
/// </summary>
|
||||
public string? ActiveProcessNode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the node currently serving event (alarm history) queries, or null when
|
||||
/// no event connection is open.
|
||||
/// </summary>
|
||||
public string? ActiveEventNode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-node cluster state in configuration order.
|
||||
/// </summary>
|
||||
public List<Historian.HistorianClusterNodeState> Nodes { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for alarm integration health and event counters.
|
||||
/// </summary>
|
||||
public class AlarmStatusInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether alarm condition tracking is enabled in configuration.
|
||||
/// </summary>
|
||||
public bool TrackingEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of distinct alarm conditions currently tracked.
|
||||
/// </summary>
|
||||
public int ConditionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of alarms currently in the InAlarm=true state.
|
||||
/// </summary>
|
||||
public int ActiveAlarmCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of InAlarm transitions observed since startup.
|
||||
/// </summary>
|
||||
public long TransitionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of alarm acknowledgement transitions observed since startup.
|
||||
/// </summary>
|
||||
public long AckEventCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of alarm acknowledgement MXAccess writes that have failed since startup.
|
||||
/// </summary>
|
||||
public long AckWriteFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the template-based alarm object filter is active.
|
||||
/// </summary>
|
||||
public bool FilterEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of compiled alarm filter patterns.
|
||||
/// </summary>
|
||||
public int FilterPatternCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of Galaxy objects included by the alarm filter during the most recent build.
|
||||
/// </summary>
|
||||
public int FilterIncludedObjectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw alarm filter patterns exactly as configured, for dashboard display.
|
||||
/// </summary>
|
||||
public List<string> FilterPatterns { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for redundancy state. Only populated when redundancy is enabled.
|
||||
/// </summary>
|
||||
public class RedundancyInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether redundancy is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redundancy mode (e.g., "Warm", "Hot").
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets this instance's role ("Primary" or "Secondary").
|
||||
/// </summary>
|
||||
public string Role { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current ServiceLevel byte.
|
||||
/// </summary>
|
||||
public byte ServiceLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets this instance's ApplicationUri.
|
||||
/// </summary>
|
||||
public string ApplicationUri { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of all server URIs in the redundant set.
|
||||
/// </summary>
|
||||
public List<string> ServerUris { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for the /api/health endpoint. Includes component-level health, ServiceLevel, and redundancy state.
|
||||
/// </summary>
|
||||
public class HealthEndpointData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the overall health status: Healthy, Degraded, or Unhealthy.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "Unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the computed OPC UA ServiceLevel byte (0-255). Only meaningful when redundancy is enabled.
|
||||
/// </summary>
|
||||
public byte ServiceLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether redundancy is enabled.
|
||||
/// </summary>
|
||||
public bool RedundancyEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets this instance's redundancy role when enabled (Primary/Secondary), or null when disabled.
|
||||
/// </summary>
|
||||
public string? RedundancyRole { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redundancy mode when enabled (Warm/Hot), or null when disabled.
|
||||
/// </summary>
|
||||
public string? RedundancyMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-component health breakdown.
|
||||
/// </summary>
|
||||
public ComponentHealth Components { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server uptime since the health endpoint was initialized.
|
||||
/// </summary>
|
||||
public string Uptime { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC timestamp of this health snapshot.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-component health breakdown for the health endpoint.
|
||||
/// </summary>
|
||||
public class ComponentHealth
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets MXAccess runtime connectivity status.
|
||||
/// </summary>
|
||||
public string MxAccess { get; set; } = "Disconnected";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets Galaxy repository database connectivity status.
|
||||
/// </summary>
|
||||
public string Database { get; set; } = "Disconnected";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets OPC UA server status.
|
||||
/// </summary>
|
||||
public string OpcUaServer { get; set; } = "Stopped";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the historian plugin status.
|
||||
/// Values: <c>Disabled</c>, <c>NotFound</c>, <c>LoadFailed</c>, <c>Loaded</c>.
|
||||
/// </summary>
|
||||
public string Historian { get; set; } = "Disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether alarm condition tracking is enabled.
|
||||
/// Values: <c>Disabled</c>, <c>Enabled</c>.
|
||||
/// </summary>
|
||||
public string Alarms { get; set; } = "Disabled";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard model for the status page footer.
|
||||
/// </summary>
|
||||
public class FooterInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time when the status snapshot was generated.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service version displayed to operators for support and traceability.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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 readonly DateTime _startTime = DateTime.UtcNow;
|
||||
private string? _applicationUri;
|
||||
private GalaxyRepositoryStats? _galaxyStats;
|
||||
private PerformanceMetrics? _metrics;
|
||||
|
||||
private HistorianConfiguration? _historianConfig;
|
||||
private IMxAccessClient? _mxAccessClient;
|
||||
private LmxNodeManager? _nodeManager;
|
||||
private RedundancyConfiguration? _redundancyConfig;
|
||||
private OpcUaServerHost? _serverHost;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh
|
||||
/// interval.
|
||||
/// </summary>
|
||||
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
|
||||
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
|
||||
public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds)
|
||||
{
|
||||
_healthCheck = healthCheck;
|
||||
_refreshIntervalSeconds = refreshIntervalSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots.
|
||||
/// </summary>
|
||||
/// <param name="mxAccessClient">The runtime client whose connection and subscription state should be reported.</param>
|
||||
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
|
||||
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
|
||||
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
|
||||
/// <param name="nodeManager">
|
||||
/// The node manager whose queue depth and MXAccess event throughput should be surfaced on the
|
||||
/// dashboard.
|
||||
/// </param>
|
||||
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
|
||||
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost,
|
||||
LmxNodeManager? nodeManager = null,
|
||||
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
|
||||
HistorianConfiguration? historianConfig = null)
|
||||
{
|
||||
_mxAccessClient = mxAccessClient;
|
||||
_metrics = metrics;
|
||||
_galaxyStats = galaxyStats;
|
||||
_serverHost = serverHost;
|
||||
_nodeManager = nodeManager;
|
||||
_redundancyConfig = redundancyConfig;
|
||||
_applicationUri = applicationUri;
|
||||
_historianConfig = historianConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers.
|
||||
/// </summary>
|
||||
/// <returns>The current dashboard status data for the bridge.</returns>
|
||||
public StatusData GetStatusData()
|
||||
{
|
||||
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
||||
var historianInfo = BuildHistorianStatusInfo();
|
||||
var alarmInfo = BuildAlarmStatusInfo();
|
||||
|
||||
return new StatusData
|
||||
{
|
||||
Connection = new ConnectionInfo
|
||||
{
|
||||
State = connectionState.ToString(),
|
||||
ReconnectCount = _mxAccessClient?.ReconnectCount ?? 0,
|
||||
ActiveSessions = _serverHost?.ActiveSessionCount ?? 0
|
||||
},
|
||||
Health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo, BuildRuntimeStatusInfo()),
|
||||
Subscriptions = new SubscriptionInfo
|
||||
{
|
||||
ActiveCount = _mxAccessClient?.ActiveSubscriptionCount ?? 0,
|
||||
ProbeCount = _nodeManager?.ActiveRuntimeProbeCount ?? 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
|
||||
},
|
||||
DataChange = new DataChangeInfo
|
||||
{
|
||||
EventsPerSecond = _nodeManager?.MxChangeEventsPerSecond ?? 0,
|
||||
AvgBatchSize = _nodeManager?.AverageDispatchBatchSize ?? 0,
|
||||
PendingItems = _nodeManager?.PendingDataChangeCount ?? 0,
|
||||
TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0
|
||||
},
|
||||
Operations = _metrics?.GetStatistics() ?? new Dictionary<string, MetricsStatistics>(),
|
||||
Historian = historianInfo,
|
||||
Alarms = alarmInfo,
|
||||
Redundancy = BuildRedundancyInfo(),
|
||||
Endpoints = BuildEndpointsInfo(),
|
||||
RuntimeStatus = BuildRuntimeStatusInfo(),
|
||||
Footer = new FooterInfo
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Version = typeof(StatusReportService).Assembly.GetName().Version?.ToString() ?? "1.0.0"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private HistorianStatusInfo BuildHistorianStatusInfo()
|
||||
{
|
||||
var outcome = HistorianPluginLoader.LastOutcome;
|
||||
var health = _nodeManager?.HistorianHealth;
|
||||
return new HistorianStatusInfo
|
||||
{
|
||||
Enabled = _historianConfig?.Enabled ?? false,
|
||||
PluginStatus = outcome.Status.ToString(),
|
||||
PluginError = outcome.Error,
|
||||
PluginPath = outcome.PluginPath,
|
||||
ServerName = _historianConfig?.ServerName ?? "",
|
||||
Port = _historianConfig?.Port ?? 0,
|
||||
QueryTotal = health?.TotalQueries ?? 0,
|
||||
QuerySuccesses = health?.TotalSuccesses ?? 0,
|
||||
QueryFailures = health?.TotalFailures ?? 0,
|
||||
ConsecutiveFailures = health?.ConsecutiveFailures ?? 0,
|
||||
LastSuccessTime = health?.LastSuccessTime,
|
||||
LastFailureTime = health?.LastFailureTime,
|
||||
LastQueryError = health?.LastError,
|
||||
ProcessConnectionOpen = health?.ProcessConnectionOpen ?? false,
|
||||
EventConnectionOpen = health?.EventConnectionOpen ?? false,
|
||||
NodeCount = health?.NodeCount ?? 0,
|
||||
HealthyNodeCount = health?.HealthyNodeCount ?? 0,
|
||||
ActiveProcessNode = health?.ActiveProcessNode,
|
||||
ActiveEventNode = health?.ActiveEventNode,
|
||||
Nodes = health?.Nodes ?? new List<Historian.HistorianClusterNodeState>()
|
||||
};
|
||||
}
|
||||
|
||||
private AlarmStatusInfo BuildAlarmStatusInfo()
|
||||
{
|
||||
return new AlarmStatusInfo
|
||||
{
|
||||
TrackingEnabled = _nodeManager?.AlarmTrackingEnabled ?? false,
|
||||
ConditionCount = _nodeManager?.AlarmConditionCount ?? 0,
|
||||
ActiveAlarmCount = _nodeManager?.ActiveAlarmCount ?? 0,
|
||||
TransitionCount = _nodeManager?.AlarmTransitionCount ?? 0,
|
||||
AckEventCount = _nodeManager?.AlarmAckEventCount ?? 0,
|
||||
AckWriteFailures = _nodeManager?.AlarmAckWriteFailures ?? 0,
|
||||
FilterEnabled = _nodeManager?.AlarmFilterEnabled ?? false,
|
||||
FilterPatternCount = _nodeManager?.AlarmFilterPatternCount ?? 0,
|
||||
FilterIncludedObjectCount = _nodeManager?.AlarmFilterIncludedObjectCount ?? 0,
|
||||
FilterPatterns = _nodeManager?.AlarmFilterPatterns?.ToList() ?? new List<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private EndpointsInfo BuildEndpointsInfo()
|
||||
{
|
||||
var info = new EndpointsInfo();
|
||||
if (_serverHost == null)
|
||||
return info;
|
||||
|
||||
info.BaseAddresses = _serverHost.BaseAddresses.ToList();
|
||||
info.UserTokenPolicies = _serverHost.UserTokenPolicies.Distinct().ToList();
|
||||
foreach (var policy in _serverHost.SecurityPolicies)
|
||||
{
|
||||
var uri = policy.SecurityPolicyUri ?? "";
|
||||
var hashIdx = uri.LastIndexOf('#');
|
||||
var name = hashIdx >= 0 && hashIdx < uri.Length - 1 ? uri.Substring(hashIdx + 1) : uri;
|
||||
info.SecurityProfiles.Add(new SecurityProfileInfo
|
||||
{
|
||||
PolicyUri = uri,
|
||||
PolicyName = name,
|
||||
SecurityMode = policy.SecurityMode.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private RuntimeStatusInfo BuildRuntimeStatusInfo()
|
||||
{
|
||||
var hosts = _nodeManager?.RuntimeStatuses?.ToList() ?? new List<GalaxyRuntimeStatus>();
|
||||
var info = new RuntimeStatusInfo
|
||||
{
|
||||
Total = hosts.Count,
|
||||
Hosts = hosts
|
||||
};
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
switch (host.State)
|
||||
{
|
||||
case GalaxyRuntimeState.Running: info.RunningCount++; break;
|
||||
case GalaxyRuntimeState.Stopped: info.StoppedCount++; break;
|
||||
default: info.UnknownCount++; break;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
private RedundancyInfo? BuildRedundancyInfo()
|
||||
{
|
||||
if (_redundancyConfig == null || !_redundancyConfig.Enabled)
|
||||
return null;
|
||||
|
||||
var mxConnected = (_mxAccessClient?.State ?? ConnectionState.Disconnected) == ConnectionState.Connected;
|
||||
var dbConnected = _galaxyStats?.DbConnected ?? false;
|
||||
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
|
||||
var baseLevel = isPrimary
|
||||
? _redundancyConfig.ServiceLevelBase
|
||||
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
|
||||
var calculator = new ServiceLevelCalculator();
|
||||
|
||||
return new RedundancyInfo
|
||||
{
|
||||
Enabled = true,
|
||||
Mode = _redundancyConfig.Mode,
|
||||
Role = _redundancyConfig.Role,
|
||||
ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected),
|
||||
ApplicationUri = _applicationUri ?? "",
|
||||
ServerUris = new List<string>(_redundancyConfig.ServerUris)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the operator-facing HTML dashboard for the current bridge status.
|
||||
/// </summary>
|
||||
/// <returns>An HTML document containing the latest dashboard snapshot.</returns>
|
||||
public string GenerateHtml()
|
||||
{
|
||||
var data = GetStatusData();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html><html><head>");
|
||||
sb.AppendLine("<meta charset='utf-8'>");
|
||||
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("h1 .version { color: #888; font-size: 0.5em; font-weight: normal; margin-left: 12px; }");
|
||||
sb.AppendLine("</style></head><body>");
|
||||
sb.AppendLine(
|
||||
$"<h1>LmxOpcUa Status Dashboard<span class='version'>v{WebUtility.HtmlEncode(data.Footer.Version)}</span></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>");
|
||||
|
||||
// Endpoints panel (exposed URLs + security profiles)
|
||||
var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray";
|
||||
sb.AppendLine($"<div class='panel {endpointsColor}'><h2>Endpoints</h2>");
|
||||
if (data.Endpoints.BaseAddresses.Count == 0)
|
||||
{
|
||||
sb.AppendLine("<p>No endpoints — OPC UA server not started.</p>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("<p><b>Base Addresses:</b></p><ul>");
|
||||
foreach (var addr in data.Endpoints.BaseAddresses)
|
||||
sb.AppendLine($"<li>{WebUtility.HtmlEncode(addr)}</li>");
|
||||
sb.AppendLine("</ul>");
|
||||
|
||||
sb.AppendLine("<p><b>Security Profiles:</b></p>");
|
||||
sb.AppendLine("<table><tr><th>Mode</th><th>Policy</th><th>Policy URI</th></tr>");
|
||||
foreach (var profile in data.Endpoints.SecurityProfiles)
|
||||
{
|
||||
sb.AppendLine(
|
||||
$"<tr><td>{WebUtility.HtmlEncode(profile.SecurityMode)}</td>" +
|
||||
$"<td>{WebUtility.HtmlEncode(profile.PolicyName)}</td>" +
|
||||
$"<td>{WebUtility.HtmlEncode(profile.PolicyUri)}</td></tr>");
|
||||
}
|
||||
sb.AppendLine("</table>");
|
||||
|
||||
if (data.Endpoints.UserTokenPolicies.Count > 0)
|
||||
sb.AppendLine(
|
||||
$"<p><b>User Token Policies:</b> {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}</p>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Redundancy panel (only when enabled)
|
||||
if (data.Redundancy != null)
|
||||
{
|
||||
var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow";
|
||||
sb.AppendLine($"<div class='panel {roleColor}'><h2>Redundancy</h2>");
|
||||
sb.AppendLine(
|
||||
$"<p>Mode: <b>{data.Redundancy.Mode}</b> | Role: <b>{data.Redundancy.Role}</b> | Service Level: <b>{data.Redundancy.ServiceLevel}</b></p>");
|
||||
sb.AppendLine($"<p>Application URI: {data.Redundancy.ApplicationUri}</p>");
|
||||
sb.AppendLine($"<p>Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}</p>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// Subscriptions panel
|
||||
sb.AppendLine("<div class='panel gray'><h2>Subscriptions</h2>");
|
||||
sb.AppendLine($"<p>Active: <b>{data.Subscriptions.ActiveCount}</b></p>");
|
||||
if (data.Subscriptions.ProbeCount > 0)
|
||||
sb.AppendLine(
|
||||
$"<p>Probes: {data.Subscriptions.ProbeCount} (bridge-owned runtime status)</p>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Data Change Dispatch panel
|
||||
sb.AppendLine("<div class='panel gray'><h2>Data Change Dispatch</h2>");
|
||||
sb.AppendLine(
|
||||
$"<p>Events/sec: <b>{data.DataChange.EventsPerSecond:F1}</b> | Avg Batch Size: <b>{data.DataChange.AvgBatchSize:F1}</b> | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}</p>");
|
||||
sb.AppendLine("</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>");
|
||||
|
||||
// Galaxy Runtime panel — per-host Platform + AppEngine state
|
||||
if (data.RuntimeStatus.Total > 0)
|
||||
{
|
||||
var rtColor = data.RuntimeStatus.StoppedCount > 0 ? "red"
|
||||
: data.RuntimeStatus.UnknownCount > 0 ? "yellow"
|
||||
: "green";
|
||||
sb.AppendLine($"<div class='panel {rtColor}'><h2>Galaxy Runtime</h2>");
|
||||
sb.AppendLine(
|
||||
$"<p>{data.RuntimeStatus.RunningCount} of {data.RuntimeStatus.Total} hosts running" +
|
||||
$" ({data.RuntimeStatus.StoppedCount} stopped, {data.RuntimeStatus.UnknownCount} unknown)</p>");
|
||||
sb.AppendLine("<table><tr><th>Name</th><th>Kind</th><th>State</th><th>Since</th><th>Last Error</th></tr>");
|
||||
foreach (var host in data.RuntimeStatus.Hosts)
|
||||
{
|
||||
var since = host.LastStateChangeTime?.ToString("O") ?? "-";
|
||||
var err = WebUtility.HtmlEncode(host.LastError ?? "");
|
||||
sb.AppendLine(
|
||||
$"<tr><td>{WebUtility.HtmlEncode(host.ObjectName)}</td>" +
|
||||
$"<td>{WebUtility.HtmlEncode(host.Kind)}</td>" +
|
||||
$"<td>{host.State}</td>" +
|
||||
$"<td>{since}</td>" +
|
||||
$"<td><code>{err}</code></td></tr>");
|
||||
}
|
||||
sb.AppendLine("</table>");
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// Historian panel
|
||||
var anyClusterNodeFailed =
|
||||
data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount < data.Historian.NodeCount;
|
||||
var allClusterNodesFailed =
|
||||
data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount == 0;
|
||||
var histColor = !data.Historian.Enabled ? "gray"
|
||||
: data.Historian.PluginStatus != "Loaded" ? "red"
|
||||
: allClusterNodesFailed ? "red"
|
||||
: data.Historian.ConsecutiveFailures >= 5 ? "red"
|
||||
: anyClusterNodeFailed || data.Historian.ConsecutiveFailures > 0 ? "yellow"
|
||||
: "green";
|
||||
sb.AppendLine($"<div class='panel {histColor}'><h2>Historian</h2>");
|
||||
sb.AppendLine(
|
||||
$"<p>Enabled: <b>{data.Historian.Enabled}</b> | Plugin: <b>{data.Historian.PluginStatus}</b> | Port: {data.Historian.Port}</p>");
|
||||
if (!string.IsNullOrEmpty(data.Historian.PluginError))
|
||||
sb.AppendLine($"<p>Plugin Error: {WebUtility.HtmlEncode(data.Historian.PluginError)}</p>");
|
||||
if (data.Historian.PluginStatus == "Loaded")
|
||||
{
|
||||
sb.AppendLine(
|
||||
$"<p>Queries: <b>{data.Historian.QueryTotal:N0}</b> " +
|
||||
$"(Success: {data.Historian.QuerySuccesses:N0}, Failure: {data.Historian.QueryFailures:N0}) " +
|
||||
$"| Consecutive Failures: <b>{data.Historian.ConsecutiveFailures}</b></p>");
|
||||
var procBadge = data.Historian.ProcessConnectionOpen
|
||||
? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveProcessNode ?? "?")})"
|
||||
: "closed";
|
||||
var evtBadge = data.Historian.EventConnectionOpen
|
||||
? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveEventNode ?? "?")})"
|
||||
: "closed";
|
||||
sb.AppendLine(
|
||||
$"<p>Process Conn: <b>{procBadge}</b> | Event Conn: <b>{evtBadge}</b></p>");
|
||||
if (data.Historian.LastSuccessTime.HasValue)
|
||||
sb.AppendLine($"<p>Last Success: {data.Historian.LastSuccessTime:O}</p>");
|
||||
if (data.Historian.LastFailureTime.HasValue)
|
||||
sb.AppendLine($"<p>Last Failure: {data.Historian.LastFailureTime:O}</p>");
|
||||
if (!string.IsNullOrEmpty(data.Historian.LastQueryError))
|
||||
sb.AppendLine(
|
||||
$"<p>Last Error: <code>{WebUtility.HtmlEncode(data.Historian.LastQueryError)}</code></p>");
|
||||
|
||||
// Cluster table: only when a true multi-node cluster is configured.
|
||||
if (data.Historian.NodeCount > 1)
|
||||
{
|
||||
sb.AppendLine(
|
||||
$"<p><b>Cluster:</b> {data.Historian.HealthyNodeCount} of {data.Historian.NodeCount} nodes healthy</p>");
|
||||
sb.AppendLine(
|
||||
"<table><tr><th>Node</th><th>State</th><th>Cooldown Until</th><th>Failures</th><th>Last Error</th></tr>");
|
||||
foreach (var node in data.Historian.Nodes)
|
||||
{
|
||||
var state = node.IsHealthy ? "healthy" : "cooldown";
|
||||
var cooldown = node.CooldownUntil?.ToString("O") ?? "-";
|
||||
var lastErr = WebUtility.HtmlEncode(node.LastError ?? "");
|
||||
sb.AppendLine(
|
||||
$"<tr><td>{WebUtility.HtmlEncode(node.Name)}</td><td>{state}</td>" +
|
||||
$"<td>{cooldown}</td><td>{node.FailureCount}</td><td><code>{lastErr}</code></td></tr>");
|
||||
}
|
||||
sb.AppendLine("</table>");
|
||||
}
|
||||
else if (data.Historian.NodeCount == 1)
|
||||
{
|
||||
sb.AppendLine($"<p>Node: {WebUtility.HtmlEncode(data.Historian.Nodes[0].Name)}</p>");
|
||||
}
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Alarms panel
|
||||
var alarmPanelColor = !data.Alarms.TrackingEnabled ? "gray"
|
||||
: data.Alarms.AckWriteFailures > 0 ? "yellow" : "green";
|
||||
sb.AppendLine($"<div class='panel {alarmPanelColor}'><h2>Alarms</h2>");
|
||||
sb.AppendLine(
|
||||
$"<p>Tracking: <b>{data.Alarms.TrackingEnabled}</b> | Conditions: {data.Alarms.ConditionCount} | Active: <b>{data.Alarms.ActiveAlarmCount}</b></p>");
|
||||
sb.AppendLine(
|
||||
$"<p>Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}</p>");
|
||||
if (data.Alarms.FilterEnabled)
|
||||
{
|
||||
sb.AppendLine(
|
||||
$"<p>Filter: <b>{data.Alarms.FilterPatternCount}</b> pattern(s), <b>{data.Alarms.FilterIncludedObjectCount}</b> object(s) included</p>");
|
||||
if (data.Alarms.FilterPatterns.Count > 0)
|
||||
{
|
||||
sb.AppendLine("<ul>");
|
||||
foreach (var pattern in data.Alarms.FilterPatterns)
|
||||
sb.AppendLine($"<li><code>{WebUtility.HtmlEncode(pattern)}</code></li>");
|
||||
sb.AppendLine("</ul>");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("<p>Filter: <b>disabled</b> (all alarm-bearing objects tracked)</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>");
|
||||
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an indented JSON status payload for API consumers.
|
||||
/// </summary>
|
||||
/// <returns>A JSON representation of the current dashboard snapshot.</returns>
|
||||
public string GenerateJson()
|
||||
{
|
||||
var data = GetStatusData();
|
||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true" /> when the bridge meets the health policy; otherwise, <see langword="false" />.</returns>
|
||||
public bool IsHealthy()
|
||||
{
|
||||
var state = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
||||
return _healthCheck.IsHealthy(state, _metrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state.
|
||||
/// </summary>
|
||||
public HealthEndpointData GetHealthData()
|
||||
{
|
||||
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
||||
var mxConnected = connectionState == ConnectionState.Connected;
|
||||
var dbConnected = _galaxyStats?.DbConnected ?? false;
|
||||
var historianInfo = BuildHistorianStatusInfo();
|
||||
var alarmInfo = BuildAlarmStatusInfo();
|
||||
var health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo);
|
||||
var uptime = DateTime.UtcNow - _startTime;
|
||||
|
||||
var data = new HealthEndpointData
|
||||
{
|
||||
Status = health.Status,
|
||||
RedundancyEnabled = _redundancyConfig?.Enabled ?? false,
|
||||
Components = new ComponentHealth
|
||||
{
|
||||
MxAccess = connectionState.ToString(),
|
||||
Database = dbConnected ? "Connected" : "Disconnected",
|
||||
OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped",
|
||||
Historian = historianInfo.PluginStatus,
|
||||
Alarms = alarmInfo.TrackingEnabled ? "Enabled" : "Disabled"
|
||||
},
|
||||
Uptime = FormatUptime(uptime),
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (_redundancyConfig != null && _redundancyConfig.Enabled)
|
||||
{
|
||||
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
|
||||
var baseLevel = isPrimary
|
||||
? _redundancyConfig.ServiceLevelBase
|
||||
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
|
||||
var calculator = new ServiceLevelCalculator();
|
||||
|
||||
data.ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected);
|
||||
data.RedundancyRole = _redundancyConfig.Role;
|
||||
data.RedundancyMode = _redundancyConfig.Mode;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-redundant: 255 when healthy, 0 when both down
|
||||
data.ServiceLevel = mxConnected ? (byte)255 : (byte)0;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the JSON payload for the /api/health endpoint.
|
||||
/// </summary>
|
||||
public string GenerateHealthJson()
|
||||
{
|
||||
var data = GetHealthData();
|
||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a focused health status HTML page for operators and monitoring dashboards.
|
||||
/// </summary>
|
||||
public string GenerateHealthHtml()
|
||||
{
|
||||
var data = GetHealthData();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var statusColor = data.Status == "Healthy" ? "#00cc66" : data.Status == "Degraded" ? "#cccc33" : "#cc3333";
|
||||
var mxColor = data.Components.MxAccess == "Connected" ? "#00cc66" : "#cc3333";
|
||||
var dbColor = data.Components.Database == "Connected" ? "#00cc66" : "#cc3333";
|
||||
var uaColor = data.Components.OpcUaServer == "Running" ? "#00cc66" : "#cc3333";
|
||||
|
||||
sb.AppendLine("<!DOCTYPE html><html><head>");
|
||||
sb.AppendLine("<meta charset='utf-8'>");
|
||||
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
||||
sb.AppendLine("<title>LmxOpcUa Health</title>");
|
||||
sb.AppendLine("<style>");
|
||||
sb.AppendLine(
|
||||
"body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; margin: 0; }");
|
||||
sb.AppendLine(".header { text-align: center; padding: 30px 0; }");
|
||||
sb.AppendLine(
|
||||
".status-badge { display: inline-block; font-size: 2em; font-weight: bold; padding: 15px 40px; border-radius: 12px; letter-spacing: 2px; }");
|
||||
sb.AppendLine(".service-level { text-align: center; font-size: 4em; font-weight: bold; margin: 20px 0; }");
|
||||
sb.AppendLine(".service-level .label { font-size: 0.3em; color: #999; display: block; }");
|
||||
sb.AppendLine(
|
||||
".components { display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin: 30px auto; max-width: 800px; }");
|
||||
sb.AppendLine(
|
||||
".component { border: 2px solid #444; border-radius: 8px; padding: 20px; min-width: 200px; text-align: center; }");
|
||||
sb.AppendLine(".component .name { font-size: 0.9em; color: #999; margin-bottom: 8px; }");
|
||||
sb.AppendLine(".component .value { font-size: 1.3em; font-weight: bold; }");
|
||||
sb.AppendLine(".meta { text-align: center; margin-top: 30px; color: #666; font-size: 0.85em; }");
|
||||
sb.AppendLine(".redundancy { text-align: center; margin: 10px 0; color: #999; }");
|
||||
sb.AppendLine(".redundancy b { color: #66ccff; }");
|
||||
sb.AppendLine("</style></head><body>");
|
||||
|
||||
// Status badge
|
||||
sb.AppendLine("<div class='header'>");
|
||||
sb.AppendLine(
|
||||
$"<div class='status-badge' style='background: {statusColor}; color: #000;'>{data.Status.ToUpperInvariant()}</div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Service Level
|
||||
sb.AppendLine($"<div class='service-level' style='color: {statusColor};'>");
|
||||
sb.AppendLine("<span class='label'>SERVICE LEVEL</span>");
|
||||
sb.AppendLine($"{data.ServiceLevel}");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Redundancy info
|
||||
if (data.RedundancyEnabled)
|
||||
sb.AppendLine(
|
||||
$"<div class='redundancy'>Role: <b>{data.RedundancyRole}</b> | Mode: <b>{data.RedundancyMode}</b></div>");
|
||||
|
||||
var historianColor = data.Components.Historian == "Loaded" ? "#00cc66"
|
||||
: data.Components.Historian == "Disabled" ? "#666" : "#cc3333";
|
||||
var alarmColor = data.Components.Alarms == "Enabled" ? "#00cc66" : "#666";
|
||||
|
||||
// Component health cards
|
||||
sb.AppendLine("<div class='components'>");
|
||||
sb.AppendLine(
|
||||
$"<div class='component' style='border-color: {mxColor};'><div class='name'>MXAccess</div><div class='value' style='color: {mxColor};'>{data.Components.MxAccess}</div></div>");
|
||||
sb.AppendLine(
|
||||
$"<div class='component' style='border-color: {dbColor};'><div class='name'>Galaxy Database</div><div class='value' style='color: {dbColor};'>{data.Components.Database}</div></div>");
|
||||
sb.AppendLine(
|
||||
$"<div class='component' style='border-color: {uaColor};'><div class='name'>OPC UA Server</div><div class='value' style='color: {uaColor};'>{data.Components.OpcUaServer}</div></div>");
|
||||
sb.AppendLine(
|
||||
$"<div class='component' style='border-color: {historianColor};'><div class='name'>Historian</div><div class='value' style='color: {historianColor};'>{data.Components.Historian}</div></div>");
|
||||
sb.AppendLine(
|
||||
$"<div class='component' style='border-color: {alarmColor};'><div class='name'>Alarm Tracking</div><div class='value' style='color: {alarmColor};'>{data.Components.Alarms}</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// Footer
|
||||
sb.AppendLine($"<div class='meta'>Uptime: {data.Uptime} | {data.Timestamp:O}</div>");
|
||||
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatUptime(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalDays >= 1)
|
||||
return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m";
|
||||
if (ts.TotalHours >= 1)
|
||||
return $"{(int)ts.TotalHours}h {ts.Minutes}m";
|
||||
return $"{(int)ts.TotalMinutes}m {ts.Seconds}s";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.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 int _port;
|
||||
|
||||
private readonly StatusReportService _reportService;
|
||||
private CancellationTokenSource? _cts;
|
||||
private HttpListener? _listener;
|
||||
private Task? _listenTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new dashboard web server bound to the supplied report service and HTTP port.
|
||||
/// </summary>
|
||||
/// <param name="reportService">The report service used to generate dashboard responses.</param>
|
||||
/// <param name="port">The HTTP port to listen on.</param>
|
||||
public StatusWebServer(StatusReportService reportService, int port)
|
||||
{
|
||||
_reportService = reportService;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dashboard listener is currently accepting requests.
|
||||
/// </summary>
|
||||
public bool IsRunning => _listener?.IsListening ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Stops the dashboard listener and releases its resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the HTTP listener and background request loop for the status dashboard.
|
||||
/// </summary>
|
||||
public bool Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add($"http://localhost:{_port}/");
|
||||
_listener.Start();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_listenTask = Task.Run(() => ListenLoopAsync(_cts.Token));
|
||||
|
||||
Log.Information("Status dashboard started on http://localhost:{Port}/", _port);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to start status dashboard on port {Port}", _port);
|
||||
_listener = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the dashboard listener and releases its HTTP resources.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
try
|
||||
{
|
||||
_listener?.Stop();
|
||||
_listener?.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
_listener = null;
|
||||
try { _listenTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
|
||||
_listenTask = 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 "/health":
|
||||
await WriteResponse(response, _reportService.GenerateHealthHtml(), "text/html", 200);
|
||||
break;
|
||||
|
||||
case "/api/status":
|
||||
await WriteResponse(response, _reportService.GenerateJson(), "application/json", 200);
|
||||
break;
|
||||
|
||||
case "/api/health":
|
||||
var healthData = _reportService.GetHealthData();
|
||||
var healthJson = _reportService.GenerateHealthJson();
|
||||
var healthStatusCode = healthData.Status == "Unhealthy" ? 503 : 200;
|
||||
await WriteResponse(response, healthJson, "application/json", healthStatusCode);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Utilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Bounded safety wrappers for blocking on async tasks from synchronous OPC UA stack
|
||||
/// callbacks (Read, Write, HistoryRead*, BuildAddressSpace). These are backstops: the
|
||||
/// underlying MxAccess / Historian clients already enforce inner timeouts on the async
|
||||
/// path, but an outer bound is still required so the stack thread cannot be parked
|
||||
/// indefinitely by a hung scheduler, a slow reconnect, or any other non-returning
|
||||
/// async path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On timeout, the underlying task is NOT cancelled — it runs to completion on the
|
||||
/// thread pool and is abandoned. Callers must be comfortable with the fire-forget
|
||||
/// semantics of the background continuation. This is acceptable for the current call
|
||||
/// sites because MxAccess and Historian clients are shared singletons whose background
|
||||
/// work does not capture request-scoped state.
|
||||
/// </remarks>
|
||||
internal static class SyncOverAsync
|
||||
{
|
||||
public static void WaitSync(Task task, TimeSpan timeout, string operation)
|
||||
{
|
||||
if (task == null) throw new ArgumentNullException(nameof(task));
|
||||
try
|
||||
{
|
||||
if (!task.Wait(timeout))
|
||||
throw new TimeoutException($"{operation} exceeded {timeout.TotalSeconds:0.#}s");
|
||||
}
|
||||
catch (AggregateException ae) when (ae.InnerExceptions.Count == 1)
|
||||
{
|
||||
// Unwrap the single inner exception so callers can write natural catch blocks.
|
||||
throw ae.InnerExceptions[0];
|
||||
}
|
||||
}
|
||||
|
||||
public static T WaitSync<T>(Task<T> task, TimeSpan timeout, string operation)
|
||||
{
|
||||
if (task == null) throw new ArgumentNullException(nameof(task));
|
||||
try
|
||||
{
|
||||
if (!task.Wait(timeout))
|
||||
throw new TimeoutException($"{operation} exceeded {timeout.TotalSeconds:0.#}s");
|
||||
return task.Result;
|
||||
}
|
||||
catch (AggregateException ae) when (ae.InnerExceptions.Count == 1)
|
||||
{
|
||||
throw ae.InnerExceptions[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<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.OtOpcUa.Host</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Host</AssemblyName>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. Functionally superseded by:
|
||||
src/ZB.MOM.WW.OtOpcUa.Server (host process, .NET 10)
|
||||
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (out-of-process MXAccess, net48 x86)
|
||||
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process driver, .NET 10)
|
||||
Kept in the build graph because Historian.Aveva + IntegrationTests still
|
||||
transitively reference it. Deletion is the subject of Phase 2 PR 3 (separate from
|
||||
this PR 2). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.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"/>
|
||||
|
||||
<!-- Single-EXE bundling -->
|
||||
<PackageReference Include="Costura.Fody" Version="6.0.0-alpha0384" PrivateAssets="all"/>
|
||||
<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>
|
||||
<!-- LDAP authentication -->
|
||||
<Reference Include="System.DirectoryServices.Protocols"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- MXAccess COM interop (unrelated to the historian SDK) -->
|
||||
<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>
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"OpcUa": {
|
||||
"BindAddress": "0.0.0.0",
|
||||
"Port": 4840,
|
||||
"EndpointPath": "/LmxOpcUa",
|
||||
"ServerName": "LmxOpcUa",
|
||||
"GalaxyName": "ZB",
|
||||
"MaxSessions": 100,
|
||||
"SessionTimeoutMinutes": 30,
|
||||
"AlarmTrackingEnabled": false,
|
||||
"AlarmFilter": {
|
||||
"ObjectFilters": []
|
||||
},
|
||||
"ApplicationUri": null
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa",
|
||||
"NodeName": null,
|
||||
"GalaxyName": null,
|
||||
"ReadTimeoutSeconds": 5,
|
||||
"WriteTimeoutSeconds": 5,
|
||||
"MaxConcurrentOperations": 10,
|
||||
"MonitorIntervalSeconds": 5,
|
||||
"AutoReconnect": true,
|
||||
"ProbeTag": null,
|
||||
"ProbeStaleThresholdSeconds": 60,
|
||||
"RuntimeStatusProbesEnabled": true,
|
||||
"RuntimeStatusUnknownTimeoutSeconds": 15
|
||||
},
|
||||
"GalaxyRepository": {
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;",
|
||||
"ChangeDetectionIntervalSeconds": 30,
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"ExtendedAttributes": false,
|
||||
"Scope": "Galaxy",
|
||||
"PlatformName": null
|
||||
},
|
||||
"Dashboard": {
|
||||
"Enabled": true,
|
||||
"Port": 8081,
|
||||
"RefreshIntervalSeconds": 10
|
||||
},
|
||||
"Authentication": {
|
||||
"AllowAnonymous": true,
|
||||
"AnonymousCanWrite": false,
|
||||
"Ldap": {
|
||||
"Enabled": false,
|
||||
"Host": "localhost",
|
||||
"Port": 3893,
|
||||
"BaseDN": "dc=lmxopcua,dc=local",
|
||||
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"TimeoutSeconds": 5,
|
||||
"ReadOnlyGroup": "ReadOnly",
|
||||
"WriteOperateGroup": "WriteOperate",
|
||||
"WriteTuneGroup": "WriteTune",
|
||||
"WriteConfigureGroup": "WriteConfigure",
|
||||
"AlarmAckGroup": "AlarmAck"
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Profiles": [
|
||||
"None"
|
||||
],
|
||||
"AutoAcceptClientCertificates": true,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048,
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": false,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": [],
|
||||
"ServiceLevelBase": 200
|
||||
},
|
||||
"Historian": {
|
||||
"Enabled": false,
|
||||
"ServerName": "localhost",
|
||||
"ServerNames": [],
|
||||
"FailureCooldownSeconds": 60,
|
||||
"IntegratedSecurity": true,
|
||||
"UserName": null,
|
||||
"Password": null,
|
||||
"Port": 32568,
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxValuesPerRead": 10000
|
||||
}
|
||||
}
|
||||
363
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
Normal file
363
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
Normal file
@@ -0,0 +1,363 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete <see cref="CustomNodeManager2"/> that materializes the driver's address space
|
||||
/// into OPC UA nodes. Implements <see cref="IAddressSpaceBuilder"/> itself so
|
||||
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
|
||||
/// OPC UA server's namespace. PR 15's <c>MarkAsAlarmCondition</c> hook creates a sibling
|
||||
/// <see cref="AlarmConditionState"/> node per alarm-flagged variable; subsequent driver
|
||||
/// <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
|
||||
/// Acknowledge / Deactivate transitions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read / Subscribe / Write are routed to the driver's capability interfaces — the node
|
||||
/// manager holds references to <see cref="IReadable"/>, <see cref="ISubscribable"/>, and
|
||||
/// <see cref="IWritable"/> when present. Nodes with no driver backing (plain folders) are
|
||||
/// served directly from the internal PredefinedNodes table.
|
||||
/// </remarks>
|
||||
public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
{
|
||||
private readonly IDriver _driver;
|
||||
private readonly IReadable? _readable;
|
||||
private readonly IWritable? _writable;
|
||||
private readonly ILogger<DriverNodeManager> _logger;
|
||||
|
||||
/// <summary>The driver whose address space this node manager exposes.</summary>
|
||||
public IDriver Driver => _driver;
|
||||
|
||||
private FolderState? _driverRoot;
|
||||
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Active building folder — set per Folder() call so Variable() lands under the right parent.
|
||||
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
|
||||
// returns a child builder per Folder call and the caller threads nesting through those references.
|
||||
private FolderState _currentFolder = null!;
|
||||
|
||||
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
||||
IDriver driver, ILogger<DriverNodeManager> logger)
|
||||
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
||||
{
|
||||
_driver = driver;
|
||||
_readable = driver as IReadable;
|
||||
_writable = driver as IWritable;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
|
||||
|
||||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_driverRoot = new FolderState(null)
|
||||
{
|
||||
SymbolicName = _driver.DriverInstanceId,
|
||||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(_driver.DriverInstanceId),
|
||||
EventNotifier = EventNotifiers.None,
|
||||
};
|
||||
|
||||
// Link under Objects folder so clients see the driver subtree at browse root.
|
||||
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var references))
|
||||
{
|
||||
references = new List<IReference>();
|
||||
externalReferences[ObjectIds.ObjectsFolder] = references;
|
||||
}
|
||||
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, _driverRoot.NodeId));
|
||||
|
||||
AddPredefinedNode(SystemContext, _driverRoot);
|
||||
_currentFolder = _driverRoot;
|
||||
}
|
||||
}
|
||||
|
||||
// ------- IAddressSpaceBuilder implementation (PR 15 contract) -------
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var folder = new FolderState(_currentFolder)
|
||||
{
|
||||
SymbolicName = browseName,
|
||||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
|
||||
BrowseName = new QualifiedName(browseName, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(displayName),
|
||||
};
|
||||
_currentFolder.AddChild(folder);
|
||||
AddPredefinedNode(SystemContext, folder);
|
||||
return new NestedBuilder(this, folder);
|
||||
}
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var v = new BaseDataVariableState(_currentFolder)
|
||||
{
|
||||
SymbolicName = browseName,
|
||||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||||
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(browseName, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(displayName),
|
||||
DataType = MapDataType(attributeInfo.DriverDataType),
|
||||
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||||
AccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
Historizing = attributeInfo.IsHistorized,
|
||||
};
|
||||
_currentFolder.AddChild(v);
|
||||
AddPredefinedNode(SystemContext, v);
|
||||
_variablesByFullRef[attributeInfo.FullName] = v;
|
||||
|
||||
v.OnReadValue = OnReadValue;
|
||||
v.OnWriteValue = OnWriteValue;
|
||||
return new VariableHandle(this, v, attributeInfo.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var p = new PropertyState(_currentFolder)
|
||||
{
|
||||
SymbolicName = browseName,
|
||||
ReferenceTypeId = ReferenceTypeIds.HasProperty,
|
||||
TypeDefinitionId = VariableTypeIds.PropertyType,
|
||||
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
|
||||
BrowseName = new QualifiedName(browseName, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(browseName),
|
||||
DataType = MapDataType(dataType),
|
||||
ValueRank = ValueRanks.Scalar,
|
||||
Value = value,
|
||||
};
|
||||
_currentFolder.AddChild(p);
|
||||
AddPredefinedNode(SystemContext, p);
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
|
||||
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
|
||||
{
|
||||
if (_readable is null)
|
||||
{
|
||||
statusCode = StatusCodes.BadNotReadable;
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullRef = node.NodeId.Identifier as string ?? "";
|
||||
var result = _readable.ReadAsync([fullRef], CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (result.Count == 0)
|
||||
{
|
||||
statusCode = StatusCodes.BadNoData;
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
var snap = result[0];
|
||||
value = snap.Value;
|
||||
statusCode = snap.StatusCode;
|
||||
timestamp = snap.ServerTimestampUtc;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OnReadValue failed for {NodeId}", node.NodeId);
|
||||
statusCode = StatusCodes.BadInternalError;
|
||||
}
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
private static NodeId MapDataType(DriverDataType t) => t switch
|
||||
{
|
||||
DriverDataType.Boolean => DataTypeIds.Boolean,
|
||||
DriverDataType.Int32 => DataTypeIds.Int32,
|
||||
DriverDataType.Float32 => DataTypeIds.Float,
|
||||
DriverDataType.Float64 => DataTypeIds.Double,
|
||||
DriverDataType.String => DataTypeIds.String,
|
||||
DriverDataType.DateTime => DataTypeIds.DateTime,
|
||||
_ => DataTypeIds.BaseDataType,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Nested builder returned by <see cref="Folder"/>. Temporarily retargets the parent's
|
||||
/// <see cref="_currentFolder"/> during each call so Variable/Folder calls land under the
|
||||
/// correct subtree. Not thread-safe if callers drive Discovery concurrently — but
|
||||
/// <c>GenericDriverNodeManager</c> discovery is sequential per driver.
|
||||
/// </summary>
|
||||
private sealed class NestedBuilder(DriverNodeManager owner, FolderState folder) : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
var prior = owner._currentFolder;
|
||||
owner._currentFolder = folder;
|
||||
try { return owner.Folder(browseName, displayName); }
|
||||
finally { owner._currentFolder = prior; }
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
var prior = owner._currentFolder;
|
||||
owner._currentFolder = folder;
|
||||
try { return owner.Variable(browseName, displayName, attributeInfo); }
|
||||
finally { owner._currentFolder = prior; }
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
{
|
||||
var prior = owner._currentFolder;
|
||||
owner._currentFolder = folder;
|
||||
try { owner.AddProperty(browseName, dataType, value); }
|
||||
finally { owner._currentFolder = prior; }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class VariableHandle : IVariableHandle
|
||||
{
|
||||
private readonly DriverNodeManager _owner;
|
||||
private readonly BaseDataVariableState _variable;
|
||||
public string FullReference { get; }
|
||||
|
||||
public VariableHandle(DriverNodeManager owner, BaseDataVariableState variable, string fullRef)
|
||||
{
|
||||
_owner = owner;
|
||||
_variable = variable;
|
||||
FullReference = fullRef;
|
||||
}
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
{
|
||||
lock (_owner.Lock)
|
||||
{
|
||||
var alarm = new AlarmConditionState(_variable)
|
||||
{
|
||||
SymbolicName = _variable.BrowseName.Name + "_Condition",
|
||||
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
||||
NodeId = new NodeId(FullReference + ".Condition", _owner.NamespaceIndex),
|
||||
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
||||
DisplayName = new LocalizedText(info.SourceName),
|
||||
};
|
||||
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
|
||||
alarm.SourceName.Value = info.SourceName;
|
||||
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
||||
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
|
||||
alarm.EnabledState.Value = new LocalizedText("Enabled");
|
||||
alarm.EnabledState.Id.Value = true;
|
||||
alarm.Retain.Value = false;
|
||||
alarm.AckedState.Value = new LocalizedText("Acknowledged");
|
||||
alarm.AckedState.Id.Value = true;
|
||||
alarm.ActiveState.Value = new LocalizedText("Inactive");
|
||||
alarm.ActiveState.Id.Value = false;
|
||||
|
||||
_variable.AddChild(alarm);
|
||||
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
|
||||
|
||||
return new ConditionSink(_owner, alarm);
|
||||
}
|
||||
}
|
||||
|
||||
private static int MapSeverity(AlarmSeverity s) => s switch
|
||||
{
|
||||
AlarmSeverity.Low => 250,
|
||||
AlarmSeverity.Medium => 500,
|
||||
AlarmSeverity.High => 700,
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 500,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
||||
: IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args)
|
||||
{
|
||||
lock (owner.Lock)
|
||||
{
|
||||
alarm.Severity.Value = (ushort)MapSeverity(args.Severity);
|
||||
alarm.Time.Value = args.SourceTimestampUtc;
|
||||
alarm.Message.Value = new LocalizedText(args.Message);
|
||||
|
||||
// Map the driver's transition type to OPC UA Part 9 state. The driver uses
|
||||
// AlarmEventArgs but the state transition kind is encoded in AlarmType by
|
||||
// convention — Galaxy's GalaxyAlarmTracker emits "Active"/"Acknowledged"/"Inactive".
|
||||
switch (args.AlarmType)
|
||||
{
|
||||
case "Active":
|
||||
alarm.SetActiveState(owner.SystemContext, true);
|
||||
alarm.SetAcknowledgedState(owner.SystemContext, false);
|
||||
alarm.Retain.Value = true;
|
||||
break;
|
||||
case "Acknowledged":
|
||||
alarm.SetAcknowledgedState(owner.SystemContext, true);
|
||||
break;
|
||||
case "Inactive":
|
||||
alarm.SetActiveState(owner.SystemContext, false);
|
||||
// Retain stays true until the condition is both Inactive and Acknowledged
|
||||
// so alarm clients keep the record in their condition refresh snapshot.
|
||||
if (alarm.AckedState.Id.Value) alarm.Retain.Value = false;
|
||||
break;
|
||||
}
|
||||
|
||||
alarm.ClearChangeMasks(owner.SystemContext, true);
|
||||
alarm.ReportEvent(owner.SystemContext, alarm);
|
||||
}
|
||||
}
|
||||
|
||||
private static int MapSeverity(AlarmSeverity s) => s switch
|
||||
{
|
||||
AlarmSeverity.Low => 250,
|
||||
AlarmSeverity.Medium => 500,
|
||||
AlarmSeverity.High => 700,
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 500,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-variable write hook wired on each <see cref="BaseDataVariableState"/>. Routes the
|
||||
/// value into the driver's <see cref="IWritable"/> and surfaces its per-tag status code.
|
||||
/// </summary>
|
||||
private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
|
||||
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
|
||||
{
|
||||
if (_writable is null) return StatusCodes.BadNotWritable;
|
||||
var fullRef = node.NodeId.Identifier as string;
|
||||
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
|
||||
|
||||
try
|
||||
{
|
||||
var results = _writable.WriteAsync(
|
||||
[new DriverWriteRequest(fullRef!, value)],
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (results.Count > 0 && results[0].StatusCode != 0)
|
||||
{
|
||||
statusCode = results[0].StatusCode;
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Write failed for {FullRef}", fullRef);
|
||||
return new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics hook for tests — number of variables registered in this node manager.
|
||||
internal int VariableCount => _variablesByFullRef.Count;
|
||||
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
|
||||
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
|
||||
}
|
||||
222
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
Normal file
222
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <see cref="ApplicationInstance"/> to bring the OPC UA server online — builds an
|
||||
/// <see cref="ApplicationConfiguration"/> programmatically (no external XML file), ensures
|
||||
/// the application certificate exists in the PKI store (auto-generates self-signed on first
|
||||
/// run), starts the server, then walks each <see cref="DriverNodeManager"/> and invokes
|
||||
/// <see cref="GenericDriverNodeManager.BuildAddressSpaceAsync"/> against it so the driver's
|
||||
/// discovery streams into the already-running server's address space.
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
private readonly OpcUaServerOptions _options;
|
||||
private readonly DriverHost _driverHost;
|
||||
private readonly IUserAuthenticator _authenticator;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
private OtOpcUaServer? _server;
|
||||
private bool _disposed;
|
||||
|
||||
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
|
||||
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public OtOpcUaServer? Server => _server;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="ApplicationConfiguration"/>, validates/creates the application
|
||||
/// certificate, constructs + starts the <see cref="OtOpcUaServer"/>, then drives
|
||||
/// <see cref="GenericDriverNodeManager.BuildAddressSpaceAsync"/> per registered driver so
|
||||
/// the address space is populated before the first client connects.
|
||||
/// </summary>
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_application = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ApplicationConfiguration = BuildConfiguration(),
|
||||
};
|
||||
|
||||
var hasCert = await _application.CheckApplicationInstanceCertificate(silent: true, minimumKeySize: CertificateFactory.DefaultKeySize).ConfigureAwait(false);
|
||||
if (!hasCert)
|
||||
throw new InvalidOperationException(
|
||||
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
||||
|
||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _loggerFactory);
|
||||
await _application.Start(_server).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||
_options.EndpointUrl, _server.DriverNodeManagers.Count);
|
||||
|
||||
// Drive each driver's discovery through its node manager. The node manager IS the
|
||||
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
|
||||
// its internal map and wires OnAlarmEvent → sink routing.
|
||||
foreach (var nodeManager in _server.DriverNodeManagers)
|
||||
{
|
||||
var driverId = nodeManager.Driver.DriverInstanceId;
|
||||
try
|
||||
{
|
||||
var generic = new GenericDriverNodeManager(nodeManager.Driver);
|
||||
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Per decision #12: driver exceptions isolate — log and keep the server serving
|
||||
// the other drivers' subtrees. Re-building this one takes a Reinitialize call.
|
||||
_logger.LogError(ex, "Discovery failed for driver {Driver}; subtree faulted", driverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ApplicationConfiguration BuildConfiguration()
|
||||
{
|
||||
Directory.CreateDirectory(_options.PkiStoreRoot);
|
||||
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationUri = _options.ApplicationUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ProductUri = "urn:OtOpcUa:Server",
|
||||
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
|
||||
SubjectName = "CN=" + _options.ApplicationName,
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_options.PkiStoreRoot, "issuers"),
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_options.PkiStoreRoot, "trusted"),
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_options.PkiStoreRoot, "rejected"),
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
|
||||
ServerConfiguration = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = new StringCollection { _options.EndpointUrl },
|
||||
SecurityPolicies = BuildSecurityPolicies(),
|
||||
UserTokenPolicies = BuildUserTokenPolicies(),
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
},
|
||||
|
||||
TraceConfiguration = new TraceConfiguration(),
|
||||
};
|
||||
|
||||
cfg.Validate(ApplicationType.Server).GetAwaiter().GetResult();
|
||||
|
||||
if (cfg.SecurityConfiguration.AutoAcceptUntrustedCertificates)
|
||||
{
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) =>
|
||||
{
|
||||
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
|
||||
e.Accept = true;
|
||||
};
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private ServerSecurityPolicyCollection BuildSecurityPolicies()
|
||||
{
|
||||
var policies = new ServerSecurityPolicyCollection
|
||||
{
|
||||
// Keep the None policy present so legacy clients can discover + browse. Locked-down
|
||||
// deployments remove this by setting Ldap.Enabled=true + dropping None here; left in
|
||||
// for PR 19 so the PR 17 test harness continues to pass unchanged.
|
||||
new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
};
|
||||
|
||||
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)
|
||||
{
|
||||
policies.Add(new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
});
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
private UserTokenPolicyCollection BuildUserTokenPolicies()
|
||||
{
|
||||
var tokens = new UserTokenPolicyCollection
|
||||
{
|
||||
new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
{
|
||||
PolicyId = "Anonymous",
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
};
|
||||
|
||||
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt
|
||||
&& _options.Ldap.Enabled)
|
||||
{
|
||||
tokens.Add(new UserTokenPolicy(UserTokenType.UserName)
|
||||
{
|
||||
PolicyId = "UserName",
|
||||
// Passwords must ride an encrypted channel — scope this token to Basic256Sha256
|
||||
// so the stack rejects any attempt to send UserName over the None endpoint.
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
_server?.Stop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OPC UA server stop threw during dispose");
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
74
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs
Normal file
74
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA transport security profile selector. Controls which <c>ServerSecurityPolicy</c>
|
||||
/// entries the endpoint advertises + which token types the <c>UserTokenPolicies</c> permits.
|
||||
/// </summary>
|
||||
public enum OpcUaSecurityProfile
|
||||
{
|
||||
/// <summary>Anonymous only on <c>SecurityPolicies.None</c> — dev-only, no signing or encryption.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// <c>Basic256Sha256 SignAndEncrypt</c> with <c>UserName</c> and <c>Anonymous</c> token
|
||||
/// policies. Clients must present a valid application certificate + user credentials.
|
||||
/// </summary>
|
||||
Basic256Sha256SignAndEncrypt,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA server endpoint + application-identity configuration. Bound from the
|
||||
/// <c>OpcUaServer</c> section of <c>appsettings.json</c>. PR 17 minimum-viable scope: no LDAP,
|
||||
/// no security profiles beyond None — those wire in alongside a future deployment-policy PR
|
||||
/// that reads from the central config DB instead of appsettings.
|
||||
/// </summary>
|
||||
public sealed class OpcUaServerOptions
|
||||
{
|
||||
public const string SectionName = "OpcUaServer";
|
||||
|
||||
/// <summary>
|
||||
/// Fully-qualified endpoint URI clients connect to. Use <c>0.0.0.0</c> to bind all
|
||||
/// interfaces; the stack rewrites to the machine's hostname for the returned endpoint
|
||||
/// description at GetEndpoints time.
|
||||
/// </summary>
|
||||
public string EndpointUrl { get; init; } = "opc.tcp://0.0.0.0:4840/OtOpcUa";
|
||||
|
||||
/// <summary>Human-readable application name surfaced in the endpoint description.</summary>
|
||||
public string ApplicationName { get; init; } = "OtOpcUa Server";
|
||||
|
||||
/// <summary>Stable application URI — must match the subjectAltName of the app cert.</summary>
|
||||
public string ApplicationUri { get; init; } = "urn:OtOpcUa:Server";
|
||||
|
||||
/// <summary>
|
||||
/// Directory where the OPC UA stack stores the application certificate + trusted /
|
||||
/// rejected cert folders. Defaults to <c>%ProgramData%\OtOpcUa\pki</c>; the stack
|
||||
/// creates the directory tree on first run and generates a self-signed cert.
|
||||
/// </summary>
|
||||
public string PkiStoreRoot { get; init; } =
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"OtOpcUa", "pki");
|
||||
|
||||
/// <summary>
|
||||
/// When true, the stack auto-trusts client certs on first connect. Dev-default = true,
|
||||
/// production deployments should flip this to false and manually trust clients via the
|
||||
/// Admin UI.
|
||||
/// </summary>
|
||||
public bool AutoAcceptUntrustedClientCertificates { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Security profile advertised on the endpoint. Default <see cref="OpcUaSecurityProfile.None"/>
|
||||
/// preserves the PR 17 endpoint shape; set to <see cref="OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt"/>
|
||||
/// for production deployments with LDAP-backed UserName auth.
|
||||
/// </summary>
|
||||
public OpcUaSecurityProfile SecurityProfile { get; init; } = OpcUaSecurityProfile.None;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP binding for UserName token validation. Only consulted when the active
|
||||
/// <see cref="SecurityProfile"/> advertises a UserName token policy. When
|
||||
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
|
||||
/// </summary>
|
||||
public LdapOptions Ldap { get; init; } = new();
|
||||
}
|
||||
122
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
Normal file
122
src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="StandardServer"/> subclass that wires one <see cref="DriverNodeManager"/> per
|
||||
/// registered driver from <see cref="DriverHost"/>. Anonymous endpoint on
|
||||
/// <c>opc.tcp://0.0.0.0:4840</c>, no security — PR 16 minimum-viable scope; LDAP + security
|
||||
/// profiles are deferred to their own PR on top of this.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaServer : StandardServer
|
||||
{
|
||||
private readonly DriverHost _driverHost;
|
||||
private readonly IUserAuthenticator _authenticator;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||
|
||||
public OtOpcUaServer(DriverHost driverHost, IUserAuthenticator authenticator, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only snapshot of the driver node managers materialized at server start. Used by
|
||||
/// the generic-driver-node-manager-driven discovery flow after the server starts — the
|
||||
/// host walks each entry and invokes
|
||||
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync(manager)</c> passing the manager
|
||||
/// as its own <see cref="IAddressSpaceBuilder"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DriverNodeManager> DriverNodeManagers => _driverNodeManagers;
|
||||
|
||||
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
||||
{
|
||||
foreach (var driverId in _driverHost.RegisteredDriverIds)
|
||||
{
|
||||
var driver = _driverHost.GetDriver(driverId);
|
||||
if (driver is null) continue;
|
||||
|
||||
var logger = _loggerFactory.CreateLogger<DriverNodeManager>();
|
||||
var manager = new DriverNodeManager(server, configuration, driver, logger);
|
||||
_driverNodeManagers.Add(manager);
|
||||
}
|
||||
|
||||
return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray());
|
||||
}
|
||||
|
||||
protected override void OnServerStarted(IServerInternal server)
|
||||
{
|
||||
base.OnServerStarted(server);
|
||||
// Hook UserName / Anonymous token validation here. Anonymous passes through; UserName
|
||||
// is validated against the IUserAuthenticator (LDAP in production). Rejected identities
|
||||
// throw ServiceResultException which the stack translates to Bad_IdentityTokenInvalid.
|
||||
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
|
||||
{
|
||||
switch (args.NewIdentity)
|
||||
{
|
||||
case AnonymousIdentityToken:
|
||||
args.Identity = new UserIdentity(); // anonymous
|
||||
return;
|
||||
|
||||
case UserNameIdentityToken user:
|
||||
{
|
||||
var result = _authenticator.AuthenticateAsync(
|
||||
user.UserName, user.DecryptedPassword, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
if (!result.Success)
|
||||
{
|
||||
throw ServiceResultException.Create(
|
||||
StatusCodes.BadUserAccessDenied,
|
||||
"Invalid username or password ({0})", result.Error ?? "no detail");
|
||||
}
|
||||
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
throw ServiceResultException.Create(
|
||||
StatusCodes.BadIdentityTokenInvalid,
|
||||
"Unsupported user identity token type: {0}", args.NewIdentity?.GetType().Name ?? "null");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny UserIdentity carrier that preserves the resolved roles so downstream node
|
||||
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
|
||||
/// uses the stack's default.
|
||||
/// </summary>
|
||||
private sealed class RoleBasedIdentity : UserIdentity
|
||||
{
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
public string? Display { get; }
|
||||
|
||||
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles)
|
||||
: base(userName, "")
|
||||
{
|
||||
Display = displayName;
|
||||
Roles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
protected override ServerProperties LoadServerProperties() => new()
|
||||
{
|
||||
ManufacturerName = "OtOpcUa",
|
||||
ProductName = "OtOpcUa.Server",
|
||||
ProductUri = "urn:OtOpcUa:Server",
|
||||
SoftwareVersion = "2.0.0",
|
||||
BuildNumber = "0",
|
||||
BuildDate = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf).
|
||||
/// Bootstraps config, starts the <see cref="DriverHost"/>, and runs until stopped.
|
||||
/// Phase 1 scope: bootstrap-only — the OPC UA transport layer that serves endpoints stays in
|
||||
/// the legacy Host until the Phase 2 cutover.
|
||||
/// Bootstraps config, starts the <see cref="DriverHost"/>, starts the OPC UA server via
|
||||
/// <see cref="OpcUaApplicationHost"/>, drives each driver's discovery into the address space,
|
||||
/// runs until stopped.
|
||||
/// </summary>
|
||||
public sealed class OpcUaServerService(
|
||||
NodeBootstrap bootstrap,
|
||||
DriverHost driverHost,
|
||||
OpcUaApplicationHost applicationHost,
|
||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -22,8 +24,11 @@ public sealed class OpcUaServerService(
|
||||
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
|
||||
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
|
||||
|
||||
// Phase 1: no drivers are wired up at bootstrap — Galaxy still lives in legacy Host.
|
||||
// Phase 2 will register drivers here based on the fetched generation.
|
||||
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
|
||||
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
|
||||
// extension once the central config DB query + per-driver factory land; for now the
|
||||
// server comes up with whatever drivers are in DriverHost at start time.
|
||||
await applicationHost.StartAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
|
||||
|
||||
@@ -40,6 +45,7 @@ public sealed class OpcUaServerService(
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken);
|
||||
await applicationHost.DisposeAsync();
|
||||
await driverHost.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
@@ -29,10 +32,44 @@ var options = new NodeOptions
|
||||
LocalCachePath = nodeSection.GetValue<string>("LocalCachePath") ?? "config_cache.db",
|
||||
};
|
||||
|
||||
var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName);
|
||||
var ldapSection = opcUaSection.GetSection("Ldap");
|
||||
var ldapOptions = new LdapOptions
|
||||
{
|
||||
Enabled = ldapSection.GetValue<bool?>("Enabled") ?? false,
|
||||
Server = ldapSection.GetValue<string>("Server") ?? "localhost",
|
||||
Port = ldapSection.GetValue<int?>("Port") ?? 3893,
|
||||
UseTls = ldapSection.GetValue<bool?>("UseTls") ?? false,
|
||||
AllowInsecureLdap = ldapSection.GetValue<bool?>("AllowInsecureLdap") ?? true,
|
||||
SearchBase = ldapSection.GetValue<string>("SearchBase") ?? "dc=lmxopcua,dc=local",
|
||||
ServiceAccountDn = ldapSection.GetValue<string>("ServiceAccountDn") ?? string.Empty,
|
||||
ServiceAccountPassword = ldapSection.GetValue<string>("ServiceAccountPassword") ?? string.Empty,
|
||||
GroupToRole = ldapSection.GetSection("GroupToRole").Get<Dictionary<string, string>>() ?? new(StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
var opcUaOptions = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = opcUaSection.GetValue<string>("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa",
|
||||
ApplicationName = opcUaSection.GetValue<string>("ApplicationName") ?? "OtOpcUa Server",
|
||||
ApplicationUri = opcUaSection.GetValue<string>("ApplicationUri") ?? "urn:OtOpcUa:Server",
|
||||
PkiStoreRoot = opcUaSection.GetValue<string>("PkiStoreRoot")
|
||||
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"),
|
||||
AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue<bool?>("AutoAcceptUntrustedClientCertificates") ?? true,
|
||||
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
|
||||
? p : OpcUaSecurityProfile.None,
|
||||
Ldap = ldapOptions,
|
||||
};
|
||||
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton(opcUaOptions);
|
||||
builder.Services.AddSingleton(ldapOptions);
|
||||
builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
|
||||
? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService<ILogger<LdapUserAuthenticator>>())
|
||||
: new DenyAllUserAuthenticator());
|
||||
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddSingleton<OpcUaApplicationHost>();
|
||||
builder.Services.AddHostedService<OpcUaServerService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Server/Security/IUserAuthenticator.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Server/Security/IUserAuthenticator.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a (username, password) pair and returns the resolved OPC UA roles for the user.
|
||||
/// The Server's <c>SessionManager_ImpersonateUser</c> hook delegates here so unit tests can
|
||||
/// swap in a fake authenticator without a live LDAP.
|
||||
/// </summary>
|
||||
public interface IUserAuthenticator
|
||||
{
|
||||
Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record UserAuthResult(bool Success, string? DisplayName, IReadOnlyList<string> Roles, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Always-reject authenticator used when no security config is provided. Lets the server
|
||||
/// start (with only an anonymous endpoint) without throwing on UserName token attempts.
|
||||
/// </summary>
|
||||
public sealed class DenyAllUserAuthenticator : IUserAuthenticator
|
||||
{
|
||||
public Task<UserAuthResult> AuthenticateAsync(string _, string __, CancellationToken ___)
|
||||
=> Task.FromResult(new UserAuthResult(false, null, [], "UserName token not supported"));
|
||||
}
|
||||
32
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs
Normal file
32
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapOptions.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
|
||||
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults match the GLAuth dev instance
|
||||
/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set <see cref="UseTls"/>
|
||||
/// true, populate <see cref="ServiceAccountDn"/> for search-then-bind, and maintain
|
||||
/// <see cref="GroupToRole"/> with the real LDAP group names.
|
||||
/// </summary>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
public string Server { get; init; } = "localhost";
|
||||
public int Port { get; init; } = 3893;
|
||||
public bool UseTls { get; init; } = false;
|
||||
|
||||
/// <summary>Dev-only escape hatch — must be false in production.</summary>
|
||||
public bool AllowInsecureLdap { get; init; } = true;
|
||||
|
||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
||||
public string ServiceAccountDn { get; init; } = string.Empty;
|
||||
public string ServiceAccountPassword { get; init; } = string.Empty;
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
|
||||
/// <summary>
|
||||
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
|
||||
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse
|
||||
/// + read), <c>WriteOperate</c>, <c>WriteTune</c>, <c>WriteConfigure</c>, <c>AlarmAck</c>.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
151
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs
Normal file
151
src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IUserAuthenticator"/> that binds to the configured LDAP directory to validate
|
||||
/// the (username, password) pair, then pulls group membership and maps to OPC UA roles.
|
||||
/// Mirrors the bind-then-search pattern in <c>Admin.Security.LdapAuthService</c> but stays
|
||||
/// in the Server project so the Server process doesn't take a cross-app dependency on Admin.
|
||||
/// </summary>
|
||||
public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserAuthenticator> logger)
|
||||
: IUserAuthenticator
|
||||
{
|
||||
public async Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return new UserAuthResult(false, null, [], "LDAP authentication disabled");
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return new UserAuthResult(false, null, [], "Credentials required");
|
||||
|
||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||
return new UserAuthResult(false, null, [],
|
||||
"Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test.");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new LdapConnection();
|
||||
if (options.UseTls) conn.SecureSocketLayer = true;
|
||||
await Task.Run(() => conn.Connect(options.Server, options.Port), ct);
|
||||
|
||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||
|
||||
// Rebind as service account for attribute read, if configured — otherwise the just-
|
||||
// bound user reads their own entry (works when ACL permits self-read).
|
||||
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
||||
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, attrs: null, typesOnly: false), ct);
|
||||
|
||||
while (results.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = results.Next();
|
||||
var name = entry.GetAttribute(options.DisplayNameAttribute);
|
||||
if (name is not null) displayName = name.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(options.GroupAttribute);
|
||||
if (groupAttr is not null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
|
||||
// GLAuth fallback: primary group is encoded as the ou= RDN above cn=.
|
||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||
{
|
||||
var primary = ExtractOuSegment(entry.Dn);
|
||||
if (primary is not null) groups.Add(primary);
|
||||
}
|
||||
}
|
||||
catch (LdapException) { break; }
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||
}
|
||||
|
||||
conn.Disconnect();
|
||||
|
||||
var roles = groups
|
||||
.Where(g => options.GroupToRole.ContainsKey(g))
|
||||
.Select(g => options.GroupToRole[g])
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new UserAuthResult(true, displayName, roles, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode);
|
||||
return new UserAuthResult(false, null, [], "Invalid username or password");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||
return new UserAuthResult(false, null, [], "Authentication error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||
{
|
||||
if (username.Contains('=')) return username; // caller passed a DN directly
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
if (results.HasMore())
|
||||
return results.Next().Dn;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for uid={username}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(options.SearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{options.SearchBase}";
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string input) =>
|
||||
input.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
|
||||
internal static string? ExtractOuSegment(string dn)
|
||||
{
|
||||
foreach (var segment in dn.Split(','))
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||
return trimmed[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
var eq = dn.IndexOf('=');
|
||||
if (eq < 0) return dn;
|
||||
var valueStart = eq + 1;
|
||||
var comma = dn.IndexOf(',', valueStart);
|
||||
return comma > valueStart ? dn[valueStart..comma] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,25 @@
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.374.126"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Server.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
<!-- OPCFoundation.NetStandard.Opc.Ua.Core advisory — v1 already uses this package at the
|
||||
same version, risk already accepted in the v1 stack. -->
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDataTypeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ModbusDataType.BitInRegister, 1)]
|
||||
[InlineData(ModbusDataType.Int16, 1)]
|
||||
[InlineData(ModbusDataType.UInt16, 1)]
|
||||
[InlineData(ModbusDataType.Int32, 2)]
|
||||
[InlineData(ModbusDataType.UInt32, 2)]
|
||||
[InlineData(ModbusDataType.Float32, 2)]
|
||||
[InlineData(ModbusDataType.Int64, 4)]
|
||||
[InlineData(ModbusDataType.UInt64, 4)]
|
||||
[InlineData(ModbusDataType.Float64, 4)]
|
||||
public void RegisterCount_returns_correct_register_count_per_type(ModbusDataType t, int expected)
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, t);
|
||||
ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)] // 0 chars → still 1 byte / 1 register (pathological but well-defined: length 0 is 0 bytes)
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(2, 1)]
|
||||
[InlineData(3, 2)]
|
||||
[InlineData(10, 5)]
|
||||
public void RegisterCount_for_String_rounds_up_to_register_pair(ushort chars, int expectedRegs)
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, StringLength: chars);
|
||||
// 0-char is encoded as 0 regs; the test case expects 1 for lengths 1-2, 2 for 3-4, etc.
|
||||
if (chars == 0) ModbusDriver.RegisterCount(tag).ShouldBe((ushort)0);
|
||||
else ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expectedRegs);
|
||||
}
|
||||
|
||||
// --- Int32 / UInt32 / Float32 with byte-order variants ---
|
||||
|
||||
[Fact]
|
||||
public void Int32_BigEndian_decodes_ABCD_layout()
|
||||
{
|
||||
// Value 0x12345678 → bytes [0x12, 0x34, 0x56, 0x78] as PLC wrote them.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
|
||||
ByteOrder: ModbusByteOrder.BigEndian);
|
||||
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int32_WordSwap_decodes_CDAB_layout()
|
||||
{
|
||||
// Siemens/AB PLC stored 0x12345678 as register[0] = 0x5678, register[1] = 0x1234.
|
||||
// Wire bytes are [0x56, 0x78, 0x12, 0x34]; with ByteOrder=WordSwap we get 0x12345678 back.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var bytes = new byte[] { 0x56, 0x78, 0x12, 0x34 };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Float32_WordSwap_encode_decode_roundtrips()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var wire = ModbusDriver.EncodeRegister(25.5f, tag);
|
||||
wire.Length.ShouldBe(4);
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
// --- Int64 / UInt64 / Float64 ---
|
||||
|
||||
[Fact]
|
||||
public void Int64_BigEndian_roundtrips()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int64);
|
||||
var wire = ModbusDriver.EncodeRegister(0x0123456789ABCDEFL, tag);
|
||||
wire.Length.ShouldBe(8);
|
||||
BinaryPrimitives.ReadInt64BigEndian(wire).ShouldBe(0x0123456789ABCDEFL);
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(0x0123456789ABCDEFL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UInt64_WordSwap_reverses_four_words()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.UInt64,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var value = 0xAABBCCDDEEFF0011UL;
|
||||
|
||||
var wireBE = new byte[8];
|
||||
BinaryPrimitives.WriteUInt64BigEndian(wireBE, value);
|
||||
|
||||
// Word-swap layout: [word3, word2, word1, word0] where each word keeps its bytes big-endian.
|
||||
var wireWS = new byte[] { wireBE[6], wireBE[7], wireBE[4], wireBE[5], wireBE[2], wireBE[3], wireBE[0], wireBE[1] };
|
||||
ModbusDriver.DecodeRegister(wireWS, tag).ShouldBe(value);
|
||||
|
||||
var roundtrip = ModbusDriver.EncodeRegister(value, tag);
|
||||
roundtrip.ShouldBe(wireWS);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Float64_roundtrips_under_word_swap()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var wire = ModbusDriver.EncodeRegister(3.14159265358979d, tag);
|
||||
wire.Length.ShouldBe(8);
|
||||
((double)ModbusDriver.DecodeRegister(wire, tag)!).ShouldBe(3.14159265358979d, tolerance: 1e-12);
|
||||
}
|
||||
|
||||
// --- BitInRegister ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(0b0000_0000_0000_0001, 0, true)]
|
||||
[InlineData(0b0000_0000_0000_0001, 1, false)]
|
||||
[InlineData(0b1000_0000_0000_0000, 15, true)]
|
||||
[InlineData(0b0100_0000_0100_0000, 6, true)]
|
||||
[InlineData(0b0100_0000_0100_0000, 14, true)]
|
||||
[InlineData(0b0100_0000_0100_0000, 7, false)]
|
||||
public void BitInRegister_extracts_bit_at_index(ushort raw, byte bitIndex, bool expected)
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||
BitIndex: bitIndex);
|
||||
var bytes = new byte[] { (byte)(raw >> 8), (byte)(raw & 0xFF) };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BitInRegister_write_is_not_supported_in_PR24()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||
BitIndex: 5);
|
||||
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
|
||||
.Message.ShouldContain("read-modify-write");
|
||||
}
|
||||
|
||||
// --- String ---
|
||||
|
||||
[Fact]
|
||||
public void String_decodes_ASCII_packed_two_chars_per_register()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 6);
|
||||
// "HELLO!" = 0x48 0x45 0x4C 0x4C 0x4F 0x21 across 3 registers.
|
||||
var bytes = "HELLO!"u8.ToArray();
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("HELLO!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_decode_truncates_at_first_nul()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 10);
|
||||
var bytes = new byte[] { 0x48, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_encode_nul_pads_remaining_bytes()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 8);
|
||||
var wire = ModbusDriver.EncodeRegister("Hi", tag);
|
||||
wire.Length.ShouldBe(8);
|
||||
wire[0].ShouldBe((byte)'H');
|
||||
wire[1].ShouldBe((byte)'i');
|
||||
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user