PR 7.2 — Retire legacy Galaxy projects + service
Matrix-gate satisfied (14 passed / 1 skipped / 0 failed on 2026-04-30 per docs/v2/Galaxy.ParityMatrix.md). Galaxy access flows through the in-process GalaxyDriver → mxaccessgw exclusively. Legacy infrastructure deleted in this commit: Source projects (6): - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (.NET 4.8 x86 + MXAccess COM) - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process pipe client) - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared (pipe-IPC contracts) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests Test projects with no consumer after legacy retired (3): - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E (drove Galaxy.Host EXE) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests (drove both backends) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport (only consumed by Host/Proxy tests) Edits: - ZB.MOM.WW.OtOpcUa.slnx: drop nine project entries - Server.csproj: drop Driver.Galaxy.Proxy ProjectReference - Server/Program.cs: drop GalaxyProxyDriverFactoryExtensions.Register + the parallel-registration comment block; only GalaxyDriverFactoryExtensions registers now under DriverType "GalaxyMxGateway" - Install-Services.ps1: rewrite to drop OtOpcUaGalaxyHost service install + the GalaxySharedSecret/ZbConnection/GalaxyClientName/GalaxyPipeName/ AvevaServiceDependencies/MxAccessInitialConnect* parameters that only applied to the legacy host. Adds a closing note pointing operators at the separate mxaccessgw install - Uninstall-Services.ps1: keep OtOpcUaGalaxyHost in the cleanup loop so pre-7.2 rigs upgrade-uninstall cleanly, plus add OtOpcUaWonderwareHistorian - scripts/e2e/test-galaxy.ps1: deleted (drove the legacy E2E) - scripts/e2e/e2e-config.sample.json: rewrite the galaxy section comment to reflect the GalaxyMxGateway-only path - scripts/e2e/README.md: drop OtOpcUaGalaxyHost references - scripts/compliance/phase-7-compliance.ps1: drop Galaxy.Shared HistorianAlarms* checks (those contracts moved to Driver.Historian.Wonderware.Client in PR 3.4) Live state: OtOpcUaGalaxyHost Windows service stopped + removed via NSSM before this commit. The dev box's Galaxy access is now exclusively through the running mxaccessgw (separate repo). Stays out of scope for PR 7.2 (PR 7.3 territory): - CLAUDE.md Galaxy section rewrite - mxaccess_documentation.md deletion - Memory entries for the now-retired Galaxy.Host service Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,260 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the four Galaxy alarm attributes (<c>.InAlarm</c>, <c>.Priority</c>,
|
||||
/// <c>.DescAttrName</c>, <c>.Acked</c>) per alarm-bearing attribute discovered during
|
||||
/// <c>DiscoverAsync</c>. Maintains one <see cref="AlarmState"/> per alarm, raises
|
||||
/// <see cref="AlarmTransition"/> on lifecycle transitions (Active / Unacknowledged /
|
||||
/// Acknowledged / Inactive). Ack path writes <c>.AckMsg</c>. Pure-logic state machine
|
||||
/// with delegate-based subscribe/write so it's testable against in-memory fakes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Active</c> — InAlarm false → true. Default to Unacknowledged.</item>
|
||||
/// <item><c>Acknowledged</c> — Acked false → true while InAlarm is still true.</item>
|
||||
/// <item><c>Inactive</c> — InAlarm true → false. If still unacknowledged the alarm
|
||||
/// is marked latched-inactive-unack; next Ack transitions straight to Inactive.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyAlarmTracker : IDisposable
|
||||
{
|
||||
public const string InAlarmAttr = ".InAlarm";
|
||||
public const string PriorityAttr = ".Priority";
|
||||
public const string DescAttrNameAttr = ".DescAttrName";
|
||||
public const string AckedAttr = ".Acked";
|
||||
public const string AckMsgAttr = ".AckMsg";
|
||||
|
||||
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
||||
private readonly Func<string, Task> _unsubscribe;
|
||||
private readonly Func<string, object, Task<bool>> _write;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
// Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state.
|
||||
private readonly ConcurrentDictionary<string, AlarmState> _alarms =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag.
|
||||
private readonly ConcurrentDictionary<string, (string AlarmTag, AlarmField Field)> _probeToAlarm =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler<AlarmTransition>? TransitionRaised;
|
||||
|
||||
public GalaxyAlarmTracker(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<string, object, Task<bool>> write)
|
||||
: this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { }
|
||||
|
||||
internal GalaxyAlarmTracker(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<string, object, Task<bool>> write,
|
||||
Func<DateTime> clock)
|
||||
{
|
||||
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
||||
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
||||
_write = write ?? throw new ArgumentNullException(nameof(write));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public int TrackedAlarmCount => _alarms.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Advise the four alarm attributes for <paramref name="alarmTag"/>. Idempotent —
|
||||
/// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the
|
||||
/// four rolls back the alarm entry so a stale callback cannot promote a phantom.
|
||||
/// </summary>
|
||||
public async Task TrackAsync(string alarmTag)
|
||||
{
|
||||
if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return;
|
||||
if (_alarms.ContainsKey(alarmTag)) return;
|
||||
|
||||
var state = new AlarmState { AlarmTag = alarmTag };
|
||||
if (!_alarms.TryAdd(alarmTag, state)) return;
|
||||
|
||||
var probes = new[]
|
||||
{
|
||||
(Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm),
|
||||
(Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority),
|
||||
(Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName),
|
||||
(Tag: alarmTag + AckedAttr, Field: AlarmField.Acked),
|
||||
};
|
||||
|
||||
foreach (var p in probes)
|
||||
{
|
||||
_probeToAlarm[p.Tag] = (alarmTag, p.Field);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var p in probes)
|
||||
{
|
||||
await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Rollback so a partial advise doesn't leak state.
|
||||
_alarms.TryRemove(alarmTag, out _);
|
||||
foreach (var p in probes)
|
||||
{
|
||||
_probeToAlarm.TryRemove(p.Tag, out _);
|
||||
try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort.
|
||||
/// </summary>
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
_alarms.Clear();
|
||||
foreach (var kv in _probeToAlarm.ToList())
|
||||
{
|
||||
_probeToAlarm.TryRemove(kv.Key, out _);
|
||||
try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator ack — write the comment text into <c><alarmTag>.AckMsg</c>.
|
||||
/// Returns false when the runtime reports the write failed.
|
||||
/// </summary>
|
||||
public Task<bool> AcknowledgeAsync(string alarmTag, string comment)
|
||||
{
|
||||
if (_disposed || string.IsNullOrWhiteSpace(alarmTag))
|
||||
return Task.FromResult(false);
|
||||
return _write(alarmTag + AckMsgAttr, comment ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscription callback entry point. Exposed for tests and for the Backend to route
|
||||
/// fan-out callbacks through. Runs the state machine and fires TransitionRaised
|
||||
/// outside the lock.
|
||||
/// </summary>
|
||||
public void OnProbeCallback(string probeTag, Vtq vtq)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return;
|
||||
if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return;
|
||||
|
||||
AlarmTransition? transition = null;
|
||||
var now = _clock();
|
||||
|
||||
lock (state.Lock)
|
||||
{
|
||||
switch (link.Field)
|
||||
{
|
||||
case AlarmField.InAlarm:
|
||||
{
|
||||
var wasActive = state.InAlarm;
|
||||
var isActive = vtq.Value is bool b && b;
|
||||
state.InAlarm = isActive;
|
||||
state.LastUpdateUtc = now;
|
||||
if (!wasActive && isActive)
|
||||
{
|
||||
state.Acked = false;
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
else if (wasActive && !isActive)
|
||||
{
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AlarmField.Priority:
|
||||
if (vtq.Value is int pi) state.Priority = pi;
|
||||
else if (vtq.Value is short ps) state.Priority = ps;
|
||||
else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl;
|
||||
state.LastUpdateUtc = now;
|
||||
break;
|
||||
case AlarmField.DescAttrName:
|
||||
state.DescAttrName = vtq.Value as string;
|
||||
state.LastUpdateUtc = now;
|
||||
break;
|
||||
case AlarmField.Acked:
|
||||
{
|
||||
var wasAcked = state.Acked;
|
||||
var isAcked = vtq.Value is bool b && b;
|
||||
state.Acked = isAcked;
|
||||
state.LastUpdateUtc = now;
|
||||
// Fire Acknowledged only when transitioning false→true. Don't fire on initial
|
||||
// subscribe callback (wasAcked==isAcked in that case because the state starts
|
||||
// with Acked=false and the initial probe is usually true for an un-active alarm).
|
||||
if (!wasAcked && isAcked && state.InAlarm)
|
||||
{
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transition is { } t)
|
||||
{
|
||||
TransitionRaised?.Invoke(this, t);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AlarmSnapshot> SnapshotStates()
|
||||
{
|
||||
return _alarms.Values.Select(s =>
|
||||
{
|
||||
lock (s.Lock)
|
||||
return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_alarms.Clear();
|
||||
_probeToAlarm.Clear();
|
||||
}
|
||||
|
||||
private sealed class AlarmState
|
||||
{
|
||||
public readonly object Lock = new();
|
||||
public string AlarmTag = "";
|
||||
public bool InAlarm;
|
||||
public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire
|
||||
public int Priority;
|
||||
public string? DescAttrName;
|
||||
public DateTime LastUpdateUtc;
|
||||
public DateTime LastTransitionUtc;
|
||||
}
|
||||
|
||||
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
|
||||
}
|
||||
|
||||
public enum AlarmStateTransition { Active, Acknowledged, Inactive }
|
||||
|
||||
public sealed record AlarmTransition(
|
||||
string AlarmTag,
|
||||
AlarmStateTransition Transition,
|
||||
int Priority,
|
||||
string? DescAttrName,
|
||||
DateTime AtUtc);
|
||||
|
||||
public sealed record AlarmSnapshot(
|
||||
string AlarmTag,
|
||||
bool InAlarm,
|
||||
bool Acked,
|
||||
int Priority,
|
||||
string? DescAttrName);
|
||||
@@ -1,188 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy backend that uses the live <c>ZB</c> repository for <see cref="DiscoverAsync"/> —
|
||||
/// real gobject hierarchy + attributes flow through to the Proxy without needing the MXAccess
|
||||
/// COM client. Runtime data-plane calls (Read/Write/Subscribe/Alarm/History) still surface
|
||||
/// as "MXAccess code lift pending" until the COM client port lands. This is the highest-value
|
||||
/// intermediate state because Discover is what powers the OPC UA address-space build, so
|
||||
/// downstream Proxy + parity tests can exercise the complete tree shape today.
|
||||
/// </summary>
|
||||
public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend
|
||||
{
|
||||
private long _nextSessionId;
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
// DB-only backend doesn't have a runtime data plane; never raises events.
|
||||
#pragma warning disable CS0067
|
||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSessionId);
|
||||
return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
|
||||
}
|
||||
|
||||
public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public async Task<DiscoverHierarchyResponse> DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hierarchy = await repository.GetHierarchyAsync(ct).ConfigureAwait(false);
|
||||
var attributes = await repository.GetAttributesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Group attributes by their owning gobject for the IPC payload.
|
||||
var attrsByGobject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
|
||||
|
||||
var parentByChild = hierarchy
|
||||
.ToDictionary(o => o.GobjectId, o => o.ParentGobjectId);
|
||||
var nameByGobject = hierarchy
|
||||
.ToDictionary(o => o.GobjectId, o => o.TagName);
|
||||
|
||||
var objects = hierarchy.Select(o => new GalaxyObjectInfo
|
||||
{
|
||||
ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
|
||||
TagName = o.TagName,
|
||||
ParentContainedName = parentByChild.TryGetValue(o.GobjectId, out var p)
|
||||
&& p != 0
|
||||
&& nameByGobject.TryGetValue(p, out var pName)
|
||||
? pName
|
||||
: null,
|
||||
TemplateCategory = MapCategory(o.CategoryId),
|
||||
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : System.Array.Empty<GalaxyAttributeInfo>(),
|
||||
}).ToArray();
|
||||
|
||||
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Data.SqlClient.SqlException
|
||||
or InvalidOperationException
|
||||
or TimeoutException)
|
||||
{
|
||||
return new DiscoverHierarchyResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Galaxy ZB repository error: {ex.Message}",
|
||||
Objects = System.Array.Empty<GalaxyObjectInfo>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReadValuesResponse> ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new ReadValuesResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess code lift pending (Phase 2 Task B.1) — DB-backed backend covers Discover only",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<WriteValuesResponse> WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
|
||||
{
|
||||
var results = new WriteValueResult[req.Writes.Length];
|
||||
for (var i = 0; i < req.Writes.Length; i++)
|
||||
{
|
||||
results[i] = new WriteValueResult
|
||||
{
|
||||
TagReference = req.Writes[i].TagReference,
|
||||
StatusCode = 0x80020000u,
|
||||
Error = "MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(new WriteValuesResponse { Results = results });
|
||||
}
|
||||
|
||||
public Task<SubscribeResponse> SubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
var sid = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
return Task.FromResult(new SubscribeResponse
|
||||
{
|
||||
Success = true,
|
||||
SubscriptionId = sid,
|
||||
ActualIntervalMs = req.RequestedIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||
HistoryReadEventsRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Events = System.Array.Empty<GalaxyHistoricalEvent>(),
|
||||
});
|
||||
|
||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||
|
||||
private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
|
||||
{
|
||||
AttributeName = row.AttributeName,
|
||||
MxDataType = row.MxDataType,
|
||||
IsArray = row.IsArray,
|
||||
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||
SecurityClassification = row.SecurityClassification,
|
||||
IsHistorized = row.IsHistorized,
|
||||
IsAlarm = row.IsAlarm,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy <c>template_definition.category_id</c> → human-readable name.
|
||||
/// Mirrors v1 Host's <c>AlarmObjectFilter</c> mapping.
|
||||
/// </summary>
|
||||
private static string MapCategory(int categoryId) => categoryId switch
|
||||
{
|
||||
1 => "$WinPlatform",
|
||||
3 => "$AppEngine",
|
||||
4 => "$Area",
|
||||
10 => "$UserDefined",
|
||||
11 => "$ApplicationObject",
|
||||
13 => "$Area",
|
||||
17 => "$DeviceIntegration",
|
||||
24 => "$ViewEngine",
|
||||
26 => "$ViewApp",
|
||||
_ => $"category-{categoryId}",
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// One row from the v1 <c>HierarchySql</c>. Galaxy <c>gobject</c> deployed instance with its
|
||||
/// hierarchy parent + template-chain context.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyRow
|
||||
{
|
||||
public int GobjectId { get; init; }
|
||||
public string TagName { get; init; } = string.Empty;
|
||||
public string ContainedName { get; init; } = string.Empty;
|
||||
public string BrowseName { get; init; } = string.Empty;
|
||||
public int ParentGobjectId { get; init; }
|
||||
public bool IsArea { get; init; }
|
||||
public int CategoryId { get; init; }
|
||||
public int HostedByGobjectId { get; init; }
|
||||
public System.Collections.Generic.IReadOnlyList<string> TemplateChain { get; init; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>One row from the v1 <c>AttributesSql</c>.</summary>
|
||||
public sealed class GalaxyAttributeRow
|
||||
{
|
||||
public int GobjectId { get; init; }
|
||||
public string TagName { get; init; } = string.Empty;
|
||||
public string AttributeName { get; init; } = string.Empty;
|
||||
public string FullTagReference { get; init; } = string.Empty;
|
||||
public int MxDataType { get; init; }
|
||||
public string? DataTypeName { get; init; }
|
||||
public bool IsArray { get; init; }
|
||||
public int? ArrayDimension { get; init; }
|
||||
public int MxAttributeCategory { get; init; }
|
||||
public int SecurityClassification { get; init; }
|
||||
public bool IsHistorized { get; init; }
|
||||
public bool IsAlarm { get; init; }
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.SqlClient;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// SQL access to the Galaxy <c>ZB</c> repository — port of v1 <c>GalaxyRepositoryService</c>.
|
||||
/// The two SQL bodies (Hierarchy + Attributes) are byte-for-byte identical to v1 so the
|
||||
/// queries surface the same row set at parity time. Extended-attributes and scope-filter
|
||||
/// queries from v1 are intentionally not ported yet — they're refinements that aren't on
|
||||
/// the Phase 2 critical path.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
|
||||
{
|
||||
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
using var cmd = new SqlCommand("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return result is int i && i == 1;
|
||||
}
|
||||
catch (SqlException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
using var conn = new SqlConnection(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
using var cmd = new SqlCommand("SELECT time_of_last_deploy FROM galaxy", conn)
|
||||
{ CommandTimeout = options.CommandTimeoutSeconds };
|
||||
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return result is DateTime dt ? dt : null;
|
||||
}
|
||||
|
||||
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = new List<GalaxyHierarchyRow>();
|
||||
|
||||
using var conn = new SqlConnection(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
|
||||
var templateChain = templateChainRaw.Length == 0
|
||||
? Array.Empty<string>()
|
||||
: templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
rows.Add(new GalaxyHierarchyRow
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
TagName = reader.GetString(1),
|
||||
ContainedName = reader.IsDBNull(2) ? string.Empty : 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,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = new List<GalaxyAttributeRow>();
|
||||
|
||||
using var conn = new SqlConnection(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
rows.Add(new GalaxyAttributeRow
|
||||
{
|
||||
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) ? null : reader.GetString(5),
|
||||
IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
|
||||
ArrayDimension = reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)),
|
||||
MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
|
||||
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
|
||||
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
|
||||
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Connection settings for the Galaxy <c>ZB</c> repository database. Set from the
|
||||
/// <c>DriverConfig</c> JSON section <c>Database</c> per <c>plan.md</c> §"Galaxy DriverConfig".
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptions
|
||||
{
|
||||
public string ConnectionString { get; init; } =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
public int CommandTimeoutSeconds { get; init; } = 60;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy data-plane abstraction. Replaces the placeholder <c>StubFrameHandler</c> with a
|
||||
/// real boundary the lifted <c>MxAccessClient</c> + <c>GalaxyRepository</c> implement during
|
||||
/// Phase 2 Task B.1. Splitting the IPC dispatch (<c>GalaxyFrameHandler</c>) from the
|
||||
/// backend means the dispatcher is unit-testable against an in-memory mock without needing
|
||||
/// live Galaxy.
|
||||
/// </summary>
|
||||
public interface IGalaxyBackend
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-pushed events the backend raises asynchronously (data-change, alarm,
|
||||
/// host-status). The frame handler subscribes once on connect and forwards each
|
||||
/// event to the Proxy as a typed <see cref="MessageKind"/> notification.
|
||||
/// </summary>
|
||||
event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
|
||||
Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct);
|
||||
Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct);
|
||||
|
||||
Task<DiscoverHierarchyResponse> DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct);
|
||||
|
||||
Task<ReadValuesResponse> ReadValuesAsync(ReadValuesRequest req, CancellationToken ct);
|
||||
Task<WriteValuesResponse> WriteValuesAsync(WriteValuesRequest req, CancellationToken ct);
|
||||
|
||||
Task<SubscribeResponse> SubscribeAsync(SubscribeRequest req, CancellationToken ct);
|
||||
Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct);
|
||||
|
||||
Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct);
|
||||
Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct);
|
||||
|
||||
Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct);
|
||||
Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct);
|
||||
Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(HistoryReadAtTimeRequest req, CancellationToken ct);
|
||||
Task<HistoryReadEventsResponse> HistoryReadEventsAsync(HistoryReadEventsRequest req, CancellationToken ct);
|
||||
|
||||
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Delegate matching <c>LMXProxyServer.OnDataChange</c> COM event signature. Allows
|
||||
/// <see cref="MxAccessClient"/> to subscribe via the abstracted <see cref="IMxProxy"/>
|
||||
/// instead of the COM object directly (so the test mock works without MXAccess registered).
|
||||
/// </summary>
|
||||
public delegate void MxDataChangeHandler(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
object pvItemValue,
|
||||
int pwItemQuality,
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] ItemStatus);
|
||||
|
||||
public delegate void MxWriteCompleteHandler(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] ItemStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over <c>LMXProxyServer</c> — port of v1 <c>IMxProxy</c>. Same surface area
|
||||
/// so the lifted client behaves identically; only the namespace + apartment-marshalling
|
||||
/// entry-point change.
|
||||
/// </summary>
|
||||
public interface IMxProxy
|
||||
{
|
||||
int Register(string clientName);
|
||||
void Unregister(int handle);
|
||||
|
||||
int AddItem(int handle, string address);
|
||||
void RemoveItem(int handle, int itemHandle);
|
||||
|
||||
void AdviseSupervisory(int handle, int itemHandle);
|
||||
void UnAdviseSupervisory(int handle, int itemHandle);
|
||||
|
||||
void Write(int handle, int itemHandle, object value, int securityClassification);
|
||||
|
||||
event MxDataChangeHandler? OnDataChange;
|
||||
event MxWriteCompleteHandler? OnWriteComplete;
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess runtime client — focused port of v1 <c>MxAccessClient</c>. Owns one
|
||||
/// <c>LMXProxyServer</c> COM connection on the supplied <see cref="StaPump"/>; serializes
|
||||
/// read / write / subscribe through the pump because all COM calls must run on the STA
|
||||
/// thread. Subscriptions are stored so they can be replayed on reconnect (full reconnect
|
||||
/// loop is the deferred-but-non-blocking refinement; this version covers connect/read/write
|
||||
/// /subscribe/unsubscribe — the MVP needed for parity testing).
|
||||
/// </summary>
|
||||
public sealed class MxAccessClient : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
||||
|
||||
private readonly StaPump _pump;
|
||||
private readonly IMxProxy _proxy;
|
||||
private readonly string _clientName;
|
||||
private readonly MxAccessClientOptions _options;
|
||||
|
||||
// Galaxy attribute reference → MXAccess item handle (set on first Subscribe/Read).
|
||||
private readonly ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<int, string> _handleToAddress = new();
|
||||
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _subscriptions =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites = new();
|
||||
|
||||
private int _connectionHandle;
|
||||
private bool _connected;
|
||||
private DateTime _lastObservedActivityUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _monitorCts;
|
||||
private int _reconnectCount;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Fires whenever the connection transitions Connected ↔ Disconnected.</summary>
|
||||
public event EventHandler<bool>? ConnectionStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Fires once per failed subscription replay after a reconnect. Carries the tag reference
|
||||
/// and the exception so the backend can propagate the degradation signal (e.g. mark the
|
||||
/// subscription bad on the Proxy side rather than silently losing its callback). Added for
|
||||
/// PR 6 low finding #2 — the replay loop previously ate per-tag failures silently and an
|
||||
/// operator would only find out that a specific subscription stopped updating through a
|
||||
/// data-quality complaint from downstream.
|
||||
/// </summary>
|
||||
public event EventHandler<SubscriptionReplayFailedEventArgs>? SubscriptionReplayFailed;
|
||||
|
||||
public MxAccessClient(StaPump pump, IMxProxy proxy, string clientName, MxAccessClientOptions? options = null)
|
||||
{
|
||||
_pump = pump;
|
||||
_proxy = proxy;
|
||||
_clientName = clientName;
|
||||
_options = options ?? new MxAccessClientOptions();
|
||||
_proxy.OnDataChange += OnDataChange;
|
||||
_proxy.OnWriteComplete += OnWriteComplete;
|
||||
}
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
public int SubscriptionCount => _subscriptions.Count;
|
||||
public int ReconnectCount => _reconnectCount;
|
||||
|
||||
/// <summary>
|
||||
/// Wonderware client identity used when registering with the LMXProxyServer. Surfaced so
|
||||
/// <see cref="Backend.MxAccessGalaxyBackend"/> can tag its <c>OnHostStatusChanged</c> IPC
|
||||
/// pushes with a stable gateway name per PR 8.
|
||||
/// </summary>
|
||||
public string ClientName => _clientName;
|
||||
|
||||
/// <summary>Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call.</summary>
|
||||
public async Task<int> ConnectAsync()
|
||||
{
|
||||
var handle = await _pump.InvokeAsync(() =>
|
||||
{
|
||||
if (_connected) return _connectionHandle;
|
||||
_connectionHandle = _proxy.Register(_clientName);
|
||||
_connected = true;
|
||||
return _connectionHandle;
|
||||
});
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, true);
|
||||
|
||||
if (_options.AutoReconnect && _monitorCts is null)
|
||||
{
|
||||
_monitorCts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => MonitorLoopAsync(_monitorCts.Token));
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
_monitorCts?.Cancel();
|
||||
_monitorCts = null;
|
||||
|
||||
await _pump.InvokeAsync(() =>
|
||||
{
|
||||
if (!_connected) return;
|
||||
try { _proxy.Unregister(_connectionHandle); }
|
||||
finally
|
||||
{
|
||||
_connected = false;
|
||||
_addressToHandle.Clear();
|
||||
_handleToAddress.Clear();
|
||||
}
|
||||
});
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background loop that watches for connection liveness signals and triggers
|
||||
/// reconnect-with-replay when the connection appears dead. Per Phase 2 high finding #2:
|
||||
/// v1's MxAccessClient.Monitor pattern lifted into the new pump-based client. Uses
|
||||
/// observed-activity timestamp + optional probe-tag subscription. Without an explicit
|
||||
/// probe tag, falls back to "no data change in N seconds + no successful read in N
|
||||
/// seconds = unhealthy" — same shape as v1.
|
||||
/// </summary>
|
||||
private async Task MonitorLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(_options.MonitorInterval, ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
if (!_connected || _disposed) continue;
|
||||
|
||||
var idle = DateTime.UtcNow - _lastObservedActivityUtc;
|
||||
if (idle <= _options.StaleThreshold) continue;
|
||||
|
||||
// Probe: try a no-op COM call. If the proxy is dead, the call will throw — that's
|
||||
// our reconnect signal. PR 6 low finding #1: AddItem allocates an MXAccess item
|
||||
// handle; we must RemoveItem it on the same pump turn or the long-running monitor
|
||||
// leaks one handle per probe cycle (one every MonitorInterval seconds, indefinitely).
|
||||
bool probeOk;
|
||||
try
|
||||
{
|
||||
probeOk = await _pump.InvokeAsync(() =>
|
||||
{
|
||||
int probeHandle = 0;
|
||||
try
|
||||
{
|
||||
probeHandle = _proxy.AddItem(_connectionHandle, "$Heartbeat");
|
||||
return probeHandle > 0;
|
||||
}
|
||||
catch { return false; }
|
||||
finally
|
||||
{
|
||||
if (probeHandle > 0)
|
||||
{
|
||||
try { _proxy.RemoveItem(_connectionHandle, probeHandle); }
|
||||
catch { /* proxy is dying; best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch { probeOk = false; }
|
||||
|
||||
if (probeOk)
|
||||
{
|
||||
_lastObservedActivityUtc = DateTime.UtcNow;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Connection appears dead — reconnect-with-replay.
|
||||
try
|
||||
{
|
||||
await _pump.InvokeAsync(() =>
|
||||
{
|
||||
try { _proxy.Unregister(_connectionHandle); } catch { /* dead anyway */ }
|
||||
_connected = false;
|
||||
});
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
|
||||
await _pump.InvokeAsync(() =>
|
||||
{
|
||||
_connectionHandle = _proxy.Register(_clientName);
|
||||
_connected = true;
|
||||
});
|
||||
_reconnectCount++;
|
||||
ConnectionStateChanged?.Invoke(this, true);
|
||||
|
||||
// Replay every subscription that was active before the disconnect. PR 6 low
|
||||
// finding #2: surface per-tag failures — log them and raise
|
||||
// SubscriptionReplayFailed so the backend can propagate the degraded state
|
||||
// (previously swallowed silently; downstream quality dropped without a signal).
|
||||
var snapshot = _addressToHandle.Keys.ToArray();
|
||||
_addressToHandle.Clear();
|
||||
_handleToAddress.Clear();
|
||||
var failed = 0;
|
||||
foreach (var fullRef in snapshot)
|
||||
{
|
||||
try { await SubscribeOnPumpAsync(fullRef); }
|
||||
catch (Exception subEx)
|
||||
{
|
||||
failed++;
|
||||
Log.Warning(subEx,
|
||||
"MXAccess subscription replay failed for {TagReference} after reconnect #{Reconnect}",
|
||||
fullRef, _reconnectCount);
|
||||
SubscriptionReplayFailed?.Invoke(this,
|
||||
new SubscriptionReplayFailedEventArgs(fullRef, subEx));
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0)
|
||||
Log.Warning("Subscription replay completed — {Failed} of {Total} failed", failed, snapshot.Length);
|
||||
else
|
||||
Log.Information("Subscription replay completed — {Total} re-subscribed cleanly", snapshot.Length);
|
||||
|
||||
_lastObservedActivityUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Reconnect failed; back off and retry on the next tick.
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot read implemented as a transient subscribe + unsubscribe.
|
||||
/// <c>LMXProxyServer</c> doesn't expose a synchronous read, so the canonical pattern
|
||||
/// (lifted from v1) is to subscribe, await the first OnDataChange, then unsubscribe.
|
||||
/// This method captures that single value.
|
||||
/// </summary>
|
||||
public async Task<Vtq> ReadAsync(string fullReference, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
|
||||
|
||||
var tcs = new TaskCompletionSource<Vtq>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Action<string, Vtq> oneShot = (_, value) => tcs.TrySetResult(value);
|
||||
|
||||
// Stash the one-shot handler before sending the subscribe, then remove it after firing.
|
||||
_subscriptions.AddOrUpdate(fullReference, oneShot, (_, existing) => Combine(existing, oneShot));
|
||||
var addedToReadOnlyAttribute = !_addressToHandle.ContainsKey(fullReference);
|
||||
|
||||
try
|
||||
{
|
||||
await SubscribeOnPumpAsync(fullReference);
|
||||
|
||||
using var _ = ct.Register(() => tcs.TrySetCanceled());
|
||||
var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(timeout, ct));
|
||||
if (raceTask != tcs.Task) throw new TimeoutException($"MXAccess read of {fullReference} timed out after {timeout}");
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// High 1 — always detach the one-shot handler, even on cancellation/timeout/throw.
|
||||
// If we were the one who added the underlying MXAccess subscription (no other
|
||||
// caller had it), tear it down too so we don't leak a probe item handle.
|
||||
_subscriptions.AddOrUpdate(fullReference, _ => default!, (_, existing) => Remove(existing, oneShot));
|
||||
if (addedToReadOnlyAttribute)
|
||||
{
|
||||
try { await UnsubscribeAsync(fullReference); }
|
||||
catch { /* shutdown-best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="value"/> to the runtime and AWAITS the OnWriteComplete
|
||||
/// callback so the caller learns the actual write status. Per Phase 2 medium finding #4
|
||||
/// in <c>exit-gate-phase-2.md</c>: the previous fire-and-forget version returned a
|
||||
/// false-positive Good even when the runtime rejected the write post-callback.
|
||||
/// </summary>
|
||||
public async Task<bool> WriteAsync(string fullReference, object value,
|
||||
int securityClassification = 0, TimeSpan? timeout = null)
|
||||
{
|
||||
if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
|
||||
var actualTimeout = timeout ?? TimeSpan.FromSeconds(5);
|
||||
|
||||
var itemHandle = await _pump.InvokeAsync(() => ResolveItem(fullReference));
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (!_pendingWrites.TryAdd(itemHandle, tcs))
|
||||
{
|
||||
// A prior write to the same item handle is still pending — uncommon but possible
|
||||
// if the caller spammed writes. Replace it: the older TCS observes a Cancelled task.
|
||||
if (_pendingWrites.TryRemove(itemHandle, out var prior))
|
||||
prior.TrySetCanceled();
|
||||
_pendingWrites[itemHandle] = tcs;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _pump.InvokeAsync(() =>
|
||||
_proxy.Write(_connectionHandle, itemHandle, value, securityClassification));
|
||||
|
||||
var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(actualTimeout));
|
||||
if (raceTask != tcs.Task)
|
||||
throw new TimeoutException($"MXAccess write of {fullReference} timed out after {actualTimeout}");
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingWrites.TryRemove(itemHandle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SubscribeAsync(string fullReference, Action<string, Vtq> callback)
|
||||
{
|
||||
if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
|
||||
|
||||
_subscriptions.AddOrUpdate(fullReference, callback, (_, existing) => Combine(existing, callback));
|
||||
await SubscribeOnPumpAsync(fullReference);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(string fullReference) => _pump.InvokeAsync(() =>
|
||||
{
|
||||
if (!_connected) return;
|
||||
if (!_addressToHandle.TryRemove(fullReference, out var handle)) return;
|
||||
_handleToAddress.TryRemove(handle, out _);
|
||||
_subscriptions.TryRemove(fullReference, out _);
|
||||
|
||||
try
|
||||
{
|
||||
_proxy.UnAdviseSupervisory(_connectionHandle, handle);
|
||||
_proxy.RemoveItem(_connectionHandle, handle);
|
||||
}
|
||||
catch { /* best-effort during teardown */ }
|
||||
});
|
||||
|
||||
private Task<int> SubscribeOnPumpAsync(string fullReference) => _pump.InvokeAsync(() =>
|
||||
{
|
||||
if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing;
|
||||
|
||||
var itemHandle = _proxy.AddItem(_connectionHandle, fullReference);
|
||||
_addressToHandle[fullReference] = itemHandle;
|
||||
_handleToAddress[itemHandle] = fullReference;
|
||||
_proxy.AdviseSupervisory(_connectionHandle, itemHandle);
|
||||
return itemHandle;
|
||||
});
|
||||
|
||||
private int ResolveItem(string fullReference)
|
||||
{
|
||||
if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing;
|
||||
var itemHandle = _proxy.AddItem(_connectionHandle, fullReference);
|
||||
_addressToHandle[fullReference] = itemHandle;
|
||||
_handleToAddress[itemHandle] = fullReference;
|
||||
return itemHandle;
|
||||
}
|
||||
|
||||
private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
|
||||
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] itemStatus)
|
||||
{
|
||||
if (!_handleToAddress.TryGetValue(phItemHandle, out var fullRef)) return;
|
||||
|
||||
// Liveness: any data-change event is proof the connection is alive.
|
||||
_lastObservedActivityUtc = DateTime.UtcNow;
|
||||
|
||||
var ts = pftItemTimeStamp is DateTime dt ? dt.ToUniversalTime() : DateTime.UtcNow;
|
||||
var quality = (byte)Math.Min(255, Math.Max(0, pwItemQuality));
|
||||
var vtq = new Vtq(pvItemValue, ts, quality);
|
||||
|
||||
if (_subscriptions.TryGetValue(fullRef, out var cb)) cb?.Invoke(fullRef, vtq);
|
||||
}
|
||||
|
||||
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] itemStatus)
|
||||
{
|
||||
if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
|
||||
tcs.TrySetResult(itemStatus is null || itemStatus.Length == 0 || itemStatus[0].success != 0);
|
||||
}
|
||||
|
||||
private static Action<string, Vtq> Combine(Action<string, Vtq> a, Action<string, Vtq> b)
|
||||
=> (Action<string, Vtq>)Delegate.Combine(a, b)!;
|
||||
|
||||
private static Action<string, Vtq> Remove(Action<string, Vtq> source, Action<string, Vtq> remove)
|
||||
=> (Action<string, Vtq>?)Delegate.Remove(source, remove) ?? ((_, _) => { });
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_monitorCts?.Cancel();
|
||||
|
||||
try { DisconnectAsync().GetAwaiter().GetResult(); }
|
||||
catch { /* swallow */ }
|
||||
|
||||
_proxy.OnDataChange -= OnDataChange;
|
||||
_proxy.OnWriteComplete -= OnWriteComplete;
|
||||
_monitorCts?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunables for <see cref="MxAccessClient"/>'s reconnect monitor. Defaults match the v1
|
||||
/// monitor's polling cadence so behavior is consistent across the lift.
|
||||
/// </summary>
|
||||
public sealed class MxAccessClientOptions
|
||||
{
|
||||
/// <summary>Whether to start the background monitor at connect time.</summary>
|
||||
public bool AutoReconnect { get; init; } = true;
|
||||
|
||||
/// <summary>How often the monitor wakes up to check liveness.</summary>
|
||||
public TimeSpan MonitorInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>If no data-change activity in this window, the monitor probes the connection.</summary>
|
||||
public TimeSpan StaleThreshold { get; init; } = TimeSpan.FromSeconds(60);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete <see cref="IMxProxy"/> backed by a real <c>LMXProxyServer</c> COM object.
|
||||
/// Port of v1 <c>MxProxyAdapter</c>. <strong>Must only be constructed on an STA thread</strong>
|
||||
/// — the StaPump owns this instance.
|
||||
/// </summary>
|
||||
public sealed class MxProxyAdapter : IMxProxy, IDisposable
|
||||
{
|
||||
private LMXProxyServer? _lmxProxy;
|
||||
|
||||
public event MxDataChangeHandler? OnDataChange;
|
||||
public event MxWriteCompleteHandler? OnWriteComplete;
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
_lmxProxy = new LMXProxyServer();
|
||||
_lmxProxy.OnDataChange += ProxyOnDataChange;
|
||||
_lmxProxy.OnWriteComplete += ProxyOnWriteComplete;
|
||||
|
||||
var handle = _lmxProxy.Register(clientName);
|
||||
if (handle <= 0)
|
||||
throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}");
|
||||
return handle;
|
||||
}
|
||||
|
||||
public void Unregister(int handle)
|
||||
{
|
||||
if (_lmxProxy is null) return;
|
||||
try
|
||||
{
|
||||
_lmxProxy.OnDataChange -= ProxyOnDataChange;
|
||||
_lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
|
||||
_lmxProxy.Unregister(handle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// ReleaseComObject loop until refcount = 0 — the Tier C SafeHandle wraps this in
|
||||
// production; here the lifetime is owned by the surrounding MxAccessHandle.
|
||||
while (Marshal.IsComObject(_lmxProxy) && Marshal.ReleaseComObject(_lmxProxy) > 0) { }
|
||||
_lmxProxy = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address);
|
||||
|
||||
public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle);
|
||||
|
||||
public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle);
|
||||
|
||||
public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle);
|
||||
|
||||
public void Write(int handle, int itemHandle, object value, int securityClassification) =>
|
||||
_lmxProxy!.Write(handle, itemHandle, value, securityClassification);
|
||||
|
||||
private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
|
||||
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
|
||||
=> OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp, ref ItemStatus);
|
||||
|
||||
private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
|
||||
=> OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus);
|
||||
|
||||
public void Dispose() => Unregister(0);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Fired by <see cref="MxAccessClient.SubscriptionReplayFailed"/> when a previously-active
|
||||
/// subscription fails to be restored after a reconnect. The backend should treat the tag as
|
||||
/// unhealthy until the next successful resubscribe.
|
||||
/// </summary>
|
||||
public sealed class SubscriptionReplayFailedEventArgs : EventArgs
|
||||
{
|
||||
public SubscriptionReplayFailedEventArgs(string tagReference, Exception exception)
|
||||
{
|
||||
TagReference = tagReference;
|
||||
Exception = exception;
|
||||
}
|
||||
|
||||
public string TagReference { get; }
|
||||
public Exception Exception { get; }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
/// <summary>Value-timestamp-quality triplet — port of v1 <c>Vtq</c>.</summary>
|
||||
public readonly struct Vtq
|
||||
{
|
||||
public object? Value { get; }
|
||||
public DateTime TimestampUtc { get; }
|
||||
public byte Quality { get; }
|
||||
|
||||
public Vtq(object? value, DateTime timestampUtc, byte quality)
|
||||
{
|
||||
Value = value;
|
||||
TimestampUtc = timestampUtc;
|
||||
Quality = quality;
|
||||
}
|
||||
|
||||
/// <summary>OPC DA Good = 192.</summary>
|
||||
public static Vtq Good(object? v) => new(v, DateTime.UtcNow, 192);
|
||||
|
||||
/// <summary>OPC DA Bad = 0.</summary>
|
||||
public static Vtq Bad() => new(null, DateTime.UtcNow, 0);
|
||||
}
|
||||
@@ -1,608 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IGalaxyBackend"/> — combines the SQL-backed
|
||||
/// <see cref="GalaxyRepository"/> for Discover with the live MXAccess
|
||||
/// <see cref="MxAccessClient"/> for Read / Write / Subscribe. History stays bad-coded
|
||||
/// until the Wonderware Historian SDK plugin loader (Task B.1.h) lands. Alarms come from
|
||||
/// MxAccess <c>AlarmExtension</c> primitives but the wire-up is also Phase 2 follow-up
|
||||
/// (the v1 alarm subsystem is its own subtree).
|
||||
/// </summary>
|
||||
public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
||||
{
|
||||
private readonly GalaxyRepository _repository;
|
||||
private readonly MxAccessClient _mx;
|
||||
private readonly IHistorianDataSource? _historian;
|
||||
private long _nextSessionId;
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
// Active SubscriptionId → MXAccess full reference list — so Unsubscribe can find them.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, IReadOnlyList<string>> _subs = new();
|
||||
// Reverse lookup: tag reference → subscription IDs subscribed to it (one tag may belong to many).
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, System.Collections.Concurrent.ConcurrentBag<long>>
|
||||
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
|
||||
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
||||
private readonly GalaxyRuntimeProbeManager _probeManager;
|
||||
private readonly System.EventHandler<HostStateTransition> _onProbeStateChanged;
|
||||
private readonly GalaxyAlarmTracker _alarmTracker;
|
||||
private readonly System.EventHandler<AlarmTransition> _onAlarmTransition;
|
||||
|
||||
// Cached during DiscoverAsync so SubscribeAlarmsAsync knows which attributes to advise.
|
||||
// One entry per IsAlarm=true attribute in the last discovered hierarchy.
|
||||
private readonly System.Collections.Concurrent.ConcurrentBag<string> _discoveredAlarmTags = new();
|
||||
|
||||
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_mx = mx;
|
||||
_historian = historian;
|
||||
|
||||
// PR 8: gateway-level host-status push. When the MXAccess COM proxy transitions
|
||||
// connected↔disconnected, raise OnHostStatusChanged with a synthetic host entry named
|
||||
// after the Wonderware client identity so the Admin UI surfaces top-level transport
|
||||
// health even before per-platform/per-engine probing lands (deferred to a later PR that
|
||||
// ports v1's GalaxyRuntimeProbeManager with ScanState subscriptions).
|
||||
_onConnectionStateChanged = (_, connected) =>
|
||||
{
|
||||
OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
|
||||
{
|
||||
HostName = _mx.ClientName,
|
||||
RuntimeStatus = connected ? "Running" : "Stopped",
|
||||
LastObservedUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
};
|
||||
_mx.ConnectionStateChanged += _onConnectionStateChanged;
|
||||
|
||||
// PR 13: per-platform runtime probes. ScanState subscriptions fire OnProbeCallback,
|
||||
// which runs the state machine and raises StateChanged on transitions we care about.
|
||||
// We forward each transition through the same OnHostStatusChanged IPC event that the
|
||||
// gateway-level ConnectionStateChanged uses — tagged with the platform's TagName so the
|
||||
// Admin UI can show per-host health independently from the top-level transport status.
|
||||
_probeManager = new GalaxyRuntimeProbeManager(
|
||||
subscribe: (probe, cb) => _mx.SubscribeAsync(probe, cb),
|
||||
unsubscribe: probe => _mx.UnsubscribeAsync(probe));
|
||||
_onProbeStateChanged = (_, t) =>
|
||||
{
|
||||
OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
|
||||
{
|
||||
HostName = t.TagName,
|
||||
RuntimeStatus = t.NewState switch
|
||||
{
|
||||
HostRuntimeState.Running => "Running",
|
||||
HostRuntimeState.Stopped => "Stopped",
|
||||
_ => "Unknown",
|
||||
},
|
||||
LastObservedUtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
});
|
||||
};
|
||||
_probeManager.StateChanged += _onProbeStateChanged;
|
||||
|
||||
// PR 14: alarm subsystem. Per IsAlarm=true attribute discovered, subscribe to the four
|
||||
// alarm-state attributes (.InAlarm/.Priority/.DescAttrName/.Acked), track lifecycle,
|
||||
// and raise GalaxyAlarmEvent on transitions — forwarded through the existing
|
||||
// OnAlarmEvent IPC event that the PR 4 ConnectionSink already wires into AlarmEvent frames.
|
||||
_alarmTracker = new GalaxyAlarmTracker(
|
||||
subscribe: (tag, cb) => _mx.SubscribeAsync(tag, cb),
|
||||
unsubscribe: tag => _mx.UnsubscribeAsync(tag),
|
||||
write: (tag, v) => _mx.WriteAsync(tag, v));
|
||||
_onAlarmTransition = (_, t) => OnAlarmEvent?.Invoke(this, new GalaxyAlarmEvent
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString("N"),
|
||||
ObjectTagName = t.AlarmTag,
|
||||
AlarmName = t.AlarmTag,
|
||||
Severity = t.Priority,
|
||||
StateTransition = t.Transition switch
|
||||
{
|
||||
AlarmStateTransition.Active => "Active",
|
||||
AlarmStateTransition.Acknowledged => "Acknowledged",
|
||||
AlarmStateTransition.Inactive => "Inactive",
|
||||
_ => "Unknown",
|
||||
},
|
||||
Message = t.DescAttrName ?? t.AlarmTag,
|
||||
UtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
});
|
||||
_alarmTracker.TransitionRaised += _onAlarmTransition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposed for tests. Production flow: DiscoverAsync completes → backend calls
|
||||
/// <c>SyncProbesAsync</c> with the runtime hosts (WinPlatform + AppEngine gobjects) to
|
||||
/// advise ScanState per host.
|
||||
/// </summary>
|
||||
internal GalaxyRuntimeProbeManager ProbeManager => _probeManager;
|
||||
|
||||
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _mx.ConnectAsync();
|
||||
return new OpenSessionResponse { Success = true, SessionId = Interlocked.Increment(ref _nextSessionId) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OpenSessionResponse { Success = false, Error = $"MXAccess connect failed: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
await _mx.DisconnectAsync();
|
||||
}
|
||||
|
||||
public async Task<DiscoverHierarchyResponse> DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hierarchy = await _repository.GetHierarchyAsync(ct).ConfigureAwait(false);
|
||||
var attributes = await _repository.GetAttributesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var attrsByGobject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
|
||||
var nameByGobject = hierarchy.ToDictionary(o => o.GobjectId, o => o.TagName);
|
||||
|
||||
var objects = hierarchy.Select(o => new GalaxyObjectInfo
|
||||
{
|
||||
ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
|
||||
TagName = o.TagName,
|
||||
ParentContainedName = o.ParentGobjectId != 0 && nameByGobject.TryGetValue(o.ParentGobjectId, out var p) ? p : null,
|
||||
TemplateCategory = MapCategory(o.CategoryId),
|
||||
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
|
||||
}).ToArray();
|
||||
|
||||
// PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise
|
||||
// them on demand. Format matches the Galaxy reference grammar <tag>.<attr>.
|
||||
var freshAlarmTags = attributes
|
||||
.Where(a => a.IsAlarm)
|
||||
.Select(a => nameByGobject.TryGetValue(a.GobjectId, out var tn)
|
||||
? tn + "." + a.AttributeName
|
||||
: null)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
while (_discoveredAlarmTags.TryTake(out _)) { }
|
||||
foreach (var t in freshAlarmTags) _discoveredAlarmTags.Add(t);
|
||||
|
||||
// PR 13: Sync the per-platform probe manager against the just-discovered hierarchy
|
||||
// so ScanState subscriptions track the current runtime set. Best-effort — probe
|
||||
// failures don't block Discover from returning, since the gateway-level signal from
|
||||
// MxAccessClient.ConnectionStateChanged still flows and the Admin UI degrades to
|
||||
// that level if any per-host probe couldn't advise.
|
||||
try
|
||||
{
|
||||
var targets = hierarchy
|
||||
.Where(o => o.CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||
|| o.CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine)
|
||||
.Select(o => new HostProbeTarget(o.TagName, o.CategoryId));
|
||||
await _probeManager.SyncAsync(targets).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* swallow — Discover succeeded; probes are a diagnostic enrichment */ }
|
||||
|
||||
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DiscoverHierarchyResponse { Success = false, Error = ex.Message, Objects = Array.Empty<GalaxyObjectInfo>() };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ReadValuesResponse> ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!_mx.IsConnected) return new ReadValuesResponse { Success = false, Error = "Not connected", Values = Array.Empty<GalaxyDataValue>() };
|
||||
|
||||
var results = new List<GalaxyDataValue>(req.TagReferences.Length);
|
||||
foreach (var reference in req.TagReferences)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vtq = await _mx.ReadAsync(reference, TimeSpan.FromSeconds(5), ct);
|
||||
results.Add(ToWire(reference, vtq));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new GalaxyDataValue
|
||||
{
|
||||
TagReference = reference,
|
||||
StatusCode = 0x80020000u, // Bad_InternalError
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
ValueBytes = MessagePackSerializer.Serialize(ex.Message),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadValuesResponse { Success = true, Values = results.ToArray() };
|
||||
}
|
||||
|
||||
public async Task<WriteValuesResponse> WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
|
||||
{
|
||||
var results = new List<WriteValueResult>(req.Writes.Length);
|
||||
foreach (var w in req.Writes)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the value back from the MessagePack bytes the Proxy sent.
|
||||
var value = w.ValueBytes is null
|
||||
? null
|
||||
: MessagePackSerializer.Deserialize<object>(w.ValueBytes);
|
||||
|
||||
var ok = await _mx.WriteAsync(w.TagReference, value!);
|
||||
results.Add(new WriteValueResult
|
||||
{
|
||||
TagReference = w.TagReference,
|
||||
StatusCode = ok ? 0u : 0x80020000u, // Good or Bad_InternalError
|
||||
Error = ok ? null : "MXAccess runtime reported write failure",
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(new WriteValueResult { TagReference = w.TagReference, StatusCode = 0x80020000u, Error = ex.Message });
|
||||
}
|
||||
}
|
||||
return new WriteValuesResponse { Results = results.ToArray() };
|
||||
}
|
||||
|
||||
public async Task<SubscribeResponse> SubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
var sid = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var tag in req.TagReferences)
|
||||
{
|
||||
_refToSubs.AddOrUpdate(tag,
|
||||
_ => new System.Collections.Concurrent.ConcurrentBag<long> { sid },
|
||||
(_, bag) => { bag.Add(sid); return bag; });
|
||||
|
||||
// The MXAccess SubscribeAsync only takes one callback per tag; the same callback
|
||||
// fires for every active subscription of that tag — we fan out by SubscriptionId.
|
||||
await _mx.SubscribeAsync(tag, OnTagValueChanged);
|
||||
}
|
||||
|
||||
_subs[sid] = req.TagReferences;
|
||||
return new SubscribeResponse { Success = true, SubscriptionId = sid, ActualIntervalMs = req.RequestedIntervalMs };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SubscribeResponse { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!_subs.TryRemove(req.SubscriptionId, out var refs)) return;
|
||||
foreach (var r in refs)
|
||||
{
|
||||
// Drop this subscription from the reverse map; only unsubscribe from MXAccess if no
|
||||
// other subscription is still listening (multiple Proxy subs may share a tag).
|
||||
_refToSubs.TryGetValue(r, out var bag);
|
||||
if (bag is not null)
|
||||
{
|
||||
var remaining = new System.Collections.Concurrent.ConcurrentBag<long>(
|
||||
bag.Where(id => id != req.SubscriptionId));
|
||||
if (remaining.IsEmpty)
|
||||
{
|
||||
_refToSubs.TryRemove(r, out _);
|
||||
await _mx.UnsubscribeAsync(r);
|
||||
}
|
||||
else
|
||||
{
|
||||
_refToSubs[r] = remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires for every value change on any subscribed Galaxy attribute. Wraps the value in
|
||||
/// a <see cref="GalaxyDataValue"/> and raises <see cref="OnDataChange"/> once per
|
||||
/// subscription that includes this tag — the IPC sink translates that into outbound
|
||||
/// <c>OnDataChangeNotification</c> frames.
|
||||
/// </summary>
|
||||
private void OnTagValueChanged(string fullReference, MxAccess.Vtq vtq)
|
||||
{
|
||||
if (!_refToSubs.TryGetValue(fullReference, out var bag) || bag.IsEmpty) return;
|
||||
|
||||
var wireValue = ToWire(fullReference, vtq);
|
||||
// Emit one notification per active SubscriptionId for this tag — the Proxy fans out to
|
||||
// each ISubscribable consumer based on the SubscriptionId in the payload.
|
||||
foreach (var sid in bag.Distinct())
|
||||
{
|
||||
OnDataChange?.Invoke(this, new OnDataChangeNotification
|
||||
{
|
||||
SubscriptionId = sid,
|
||||
Values = new[] { wireValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 14: advise every alarm-bearing attribute's 4-attr quartet. Best-effort per-alarm —
|
||||
/// a subscribe failure on one alarm doesn't abort the whole call, since operators prefer
|
||||
/// partial alarm coverage to none. Idempotent on repeat calls (tracker internally
|
||||
/// skips already-tracked alarms).
|
||||
/// </summary>
|
||||
public async Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
foreach (var tag in _discoveredAlarmTags)
|
||||
{
|
||||
try { await _alarmTracker.TrackAsync(tag).ConfigureAwait(false); }
|
||||
catch { /* swallow per-alarm — tracker rolls back its own state on failure */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 14: route operator ack through the tracker's AckMsg write path. EventId on the
|
||||
/// incoming request maps directly to the alarm full reference (Proxy-side naming
|
||||
/// convention from GalaxyProxyDriver.RaiseAlarmEvent → ev.EventId).
|
||||
/// </summary>
|
||||
public async Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct)
|
||||
{
|
||||
// EventId carries a per-transition Guid.ToString("N"); there's no reverse map from
|
||||
// event id to alarm tag yet, so v1's convention (ack targets the condition) is matched
|
||||
// by reading the alarm name from the Comment envelope: v1 packed "<tag>|<comment>".
|
||||
// Until the Proxy is updated to send the alarm tag separately, fall back to treating
|
||||
// the EventId as the alarm tag — Client CLI passes it through unchanged.
|
||||
var tag = req.EventId;
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
try { await _alarmTracker.AcknowledgeAsync(tag, req.Comment ?? string.Empty).ConfigureAwait(false); }
|
||||
catch { /* swallow — ack failures surface via MxAccessClient.WriteAsync logs */ }
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Tags = Array.Empty<HistoryTagValues>(),
|
||||
};
|
||||
|
||||
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||
var tags = new List<HistoryTagValues>(req.TagReferences.Length);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var reference in req.TagReferences)
|
||||
{
|
||||
var samples = await _historian.ReadRawAsync(reference, start, end, (int)req.MaxValuesPerTag, ct).ConfigureAwait(false);
|
||||
tags.Add(new HistoryTagValues
|
||||
{
|
||||
TagReference = reference,
|
||||
Values = samples.Select(s => ToWire(reference, s)).ToArray(),
|
||||
});
|
||||
}
|
||||
return new HistoryReadResponse { Success = true, Tags = tags.ToArray() };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian read failed: {ex.Message}",
|
||||
Tags = tags.ToArray(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
|
||||
if (req.IntervalMs <= 0)
|
||||
return new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "HistoryReadProcessed requires IntervalMs > 0",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
|
||||
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||
|
||||
try
|
||||
{
|
||||
var samples = await _historian.ReadAggregateAsync(
|
||||
req.TagReference, start, end, req.IntervalMs, req.AggregateColumn, ct).ConfigureAwait(false);
|
||||
|
||||
var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray();
|
||||
return new HistoryReadProcessedResponse { Success = true, Values = wire };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian aggregate read failed: {ex.Message}",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
|
||||
if (req.TimestampsUtcUnixMs.Length == 0)
|
||||
return new HistoryReadAtTimeResponse { Success = true, Values = Array.Empty<GalaxyDataValue>() };
|
||||
|
||||
var timestamps = req.TimestampsUtcUnixMs
|
||||
.Select(ms => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime)
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
var samples = await _historian.ReadAtTimeAsync(req.TagReference, timestamps, ct).ConfigureAwait(false);
|
||||
var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray();
|
||||
return new HistoryReadAtTimeResponse { Success = true, Values = wire };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian at-time read failed: {ex.Message}",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||
HistoryReadEventsRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Events = Array.Empty<GalaxyHistoricalEvent>(),
|
||||
};
|
||||
|
||||
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||
|
||||
try
|
||||
{
|
||||
var events = await _historian.ReadEventsAsync(req.SourceName, start, end, req.MaxEvents, ct).ConfigureAwait(false);
|
||||
var wire = events.Select(e => new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = e.Id.ToString(),
|
||||
SourceName = e.Source,
|
||||
EventTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.EventTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
ReceivedTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.ReceivedTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
DisplayText = e.DisplayText,
|
||||
Severity = e.Severity,
|
||||
}).ToArray();
|
||||
return new HistoryReadEventsResponse { Success = true, Events = wire };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian event read failed: {ex.Message}",
|
||||
Events = Array.Empty<GalaxyHistoricalEvent>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alarmTracker.TransitionRaised -= _onAlarmTransition;
|
||||
_alarmTracker.Dispose();
|
||||
_probeManager.StateChanged -= _onProbeStateChanged;
|
||||
_probeManager.Dispose();
|
||||
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
||||
_historian?.Dispose();
|
||||
}
|
||||
|
||||
private static GalaxyDataValue ToWire(string reference, Vtq vtq) => new()
|
||||
{
|
||||
TagReference = reference,
|
||||
ValueBytes = vtq.Value is null ? null : MessagePackSerializer.Serialize(vtq.Value),
|
||||
ValueMessagePackType = 0,
|
||||
StatusCode = vtq.Quality >= 192 ? 0u : 0x40000000u, // Good vs Uncertain placeholder
|
||||
SourceTimestampUtcUnixMs = new DateTimeOffset(vtq.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="HistorianSample"/> (raw historian row, OPC-UA-free) to the IPC wire
|
||||
/// shape. The Proxy decodes the MessagePack value and maps <see cref="HistorianSample.Quality"/>
|
||||
/// through <c>QualityMapper</c> on its side of the pipe — we keep the raw byte here so
|
||||
/// rich OPC DA status codes (e.g. <c>BadNotConnected</c>, <c>UncertainSubNormal</c>) survive
|
||||
/// the hop intact.
|
||||
/// </summary>
|
||||
private static GalaxyDataValue ToWire(string reference, HistorianSample sample) => new()
|
||||
{
|
||||
TagReference = reference,
|
||||
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value),
|
||||
ValueMessagePackType = 0,
|
||||
StatusCode = HistorianQualityMapper.Map(sample.Quality),
|
||||
SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="HistorianAggregateSample"/> (one aggregate bucket) to the IPC wire
|
||||
/// shape. A null <see cref="HistorianAggregateSample.Value"/> means the aggregate was
|
||||
/// unavailable for the bucket — the Proxy translates that to OPC UA <c>BadNoData</c>.
|
||||
/// </summary>
|
||||
private static GalaxyDataValue ToWire(string reference, HistorianAggregateSample sample) => new()
|
||||
{
|
||||
TagReference = reference,
|
||||
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value.Value),
|
||||
ValueMessagePackType = 0,
|
||||
StatusCode = sample.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u,
|
||||
SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
|
||||
{
|
||||
AttributeName = row.AttributeName,
|
||||
MxDataType = row.MxDataType,
|
||||
IsArray = row.IsArray,
|
||||
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||
SecurityClassification = row.SecurityClassification,
|
||||
IsHistorized = row.IsHistorized,
|
||||
IsAlarm = row.IsAlarm,
|
||||
};
|
||||
|
||||
private static string MapCategory(int categoryId) => categoryId switch
|
||||
{
|
||||
1 => "$WinPlatform",
|
||||
3 => "$AppEngine",
|
||||
4 => "$Area",
|
||||
10 => "$UserDefined",
|
||||
11 => "$ApplicationObject",
|
||||
13 => "$Area",
|
||||
17 => "$DeviceIntegration",
|
||||
24 => "$ViewEngine",
|
||||
26 => "$ViewApp",
|
||||
_ => $"category-{categoryId}",
|
||||
};
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Per-platform + per-AppEngine runtime probe. Subscribes to <c><TagName>.ScanState</c>
|
||||
/// for each $WinPlatform and $AppEngine gobject, tracks Unknown → Running → Stopped
|
||||
/// transitions, and fires <see cref="StateChanged"/> so <see cref="Backend.MxAccessGalaxyBackend"/>
|
||||
/// can forward per-host events through the existing IPC <c>OnHostStatusChanged</c> event.
|
||||
/// Pure-logic state machine with an injected clock so it's deterministically testable —
|
||||
/// port of v1 <c>GalaxyRuntimeProbeManager</c> without the OPC UA node-manager coupling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State machine rules (documented in v1's <c>runtimestatus.md</c> and preserved here):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ScanState</c> is on-change-only — a stably-Running host may go hours without a
|
||||
/// callback. Running → Stopped is driven by an explicit <c>ScanState=false</c> callback,
|
||||
/// never by starvation.</item>
|
||||
/// <item>Unknown → Running is a startup transition and does NOT fire StateChanged (would
|
||||
/// paint every host as "just recovered" at startup, which is noise).</item>
|
||||
/// <item>Stopped → Running and Running → Stopped fire StateChanged. Unknown → Stopped
|
||||
/// fires StateChanged because that's a first-known-bad signal operators need.</item>
|
||||
/// <item>All public methods are thread-safe. Callbacks fire outside the internal lock to
|
||||
/// avoid lock inversion with caller-owned state.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyRuntimeProbeManager : IDisposable
|
||||
{
|
||||
public const int CategoryWinPlatform = 1;
|
||||
public const int CategoryAppEngine = 3;
|
||||
public const string ProbeAttribute = ".ScanState";
|
||||
|
||||
private readonly Func<DateTime> _clock;
|
||||
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
||||
private readonly Func<string, Task> _unsubscribe;
|
||||
private readonly object _lock = new();
|
||||
|
||||
// probe tag → per-host state
|
||||
private readonly Dictionary<string, HostProbeState> _byProbe = new(StringComparer.OrdinalIgnoreCase);
|
||||
// tag name → probe tag (for reverse lookup on the desired-set diff)
|
||||
private readonly Dictionary<string, string> _probeByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Fires on every state transition that operators should react to. See class remarks
|
||||
/// for the rules on which transitions fire.
|
||||
/// </summary>
|
||||
public event EventHandler<HostStateTransition>? StateChanged;
|
||||
|
||||
public GalaxyRuntimeProbeManager(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe)
|
||||
: this(subscribe, unsubscribe, () => DateTime.UtcNow) { }
|
||||
|
||||
internal GalaxyRuntimeProbeManager(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<DateTime> clock)
|
||||
{
|
||||
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
||||
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
/// <summary>Number of probes currently advised. Test/dashboard hook.</summary>
|
||||
public int ActiveProbeCount
|
||||
{
|
||||
get { lock (_lock) return _byProbe.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot every currently-tracked host's state. One entry per probe.
|
||||
/// </summary>
|
||||
public IReadOnlyList<HostProbeSnapshot> SnapshotStates()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _byProbe.Select(kv => new HostProbeSnapshot(
|
||||
TagName: kv.Value.TagName,
|
||||
State: kv.Value.State,
|
||||
LastChangedUtc: kv.Value.LastStateChangeUtc)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query the current runtime state for <paramref name="tagName"/>. Returns
|
||||
/// <see cref="HostRuntimeState.Unknown"/> when the host is not tracked.
|
||||
/// </summary>
|
||||
public HostRuntimeState GetState(string tagName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_probeByTagName.TryGetValue(tagName, out var probe)
|
||||
&& _byProbe.TryGetValue(probe, out var state))
|
||||
return state.State;
|
||||
return HostRuntimeState.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff the desired host set (filtered $WinPlatform / $AppEngine from the latest Discover)
|
||||
/// against the currently-tracked set and advise / unadvise as needed. Idempotent:
|
||||
/// calling twice with the same set does nothing.
|
||||
/// </summary>
|
||||
public async Task SyncAsync(IEnumerable<HostProbeTarget> desiredHosts)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
var desired = desiredHosts
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h.TagName))
|
||||
.ToDictionary(h => h.TagName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<string> toAdvise;
|
||||
List<string> toUnadvise;
|
||||
lock (_lock)
|
||||
{
|
||||
toAdvise = desired.Keys
|
||||
.Where(tag => !_probeByTagName.ContainsKey(tag))
|
||||
.ToList();
|
||||
toUnadvise = _probeByTagName.Keys
|
||||
.Where(tag => !desired.ContainsKey(tag))
|
||||
.Select(tag => _probeByTagName[tag])
|
||||
.ToList();
|
||||
|
||||
foreach (var tag in toAdvise)
|
||||
{
|
||||
var probe = tag + ProbeAttribute;
|
||||
_probeByTagName[tag] = probe;
|
||||
_byProbe[probe] = new HostProbeState
|
||||
{
|
||||
TagName = tag,
|
||||
State = HostRuntimeState.Unknown,
|
||||
LastStateChangeUtc = _clock(),
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var probe in toUnadvise)
|
||||
{
|
||||
_byProbe.Remove(probe);
|
||||
}
|
||||
|
||||
foreach (var removedTag in _probeByTagName.Keys.Where(t => !desired.ContainsKey(t)).ToList())
|
||||
{
|
||||
_probeByTagName.Remove(removedTag);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in toAdvise)
|
||||
{
|
||||
var probe = tag + ProbeAttribute;
|
||||
try
|
||||
{
|
||||
await _subscribe(probe, OnProbeCallback);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Rollback on subscribe failure so a later Tick can't transition a never-advised
|
||||
// probe into a false Stopped state. Callers can re-Sync later to retry.
|
||||
lock (_lock)
|
||||
{
|
||||
_byProbe.Remove(probe);
|
||||
_probeByTagName.Remove(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var probe in toUnadvise)
|
||||
{
|
||||
try { await _unsubscribe(probe); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public entry point for tests and internal callbacks. Production flow: MxAccessClient's
|
||||
/// SubscribeAsync delivers VTQ updates through the callback wired in <see cref="SyncAsync"/>,
|
||||
/// which calls this method under the lock to update state and fires
|
||||
/// <see cref="StateChanged"/> outside the lock for any transition that matters.
|
||||
/// </summary>
|
||||
public void OnProbeCallback(string probeTag, Vtq vtq)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
HostStateTransition? transition = null;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_byProbe.TryGetValue(probeTag, out var state)) return;
|
||||
|
||||
var isRunning = vtq.Quality >= 192 && vtq.Value is bool b && b;
|
||||
var now = _clock();
|
||||
var previous = state.State;
|
||||
state.LastCallbackUtc = now;
|
||||
|
||||
if (isRunning)
|
||||
{
|
||||
state.GoodUpdateCount++;
|
||||
if (previous != HostRuntimeState.Running)
|
||||
{
|
||||
state.State = HostRuntimeState.Running;
|
||||
state.LastStateChangeUtc = now;
|
||||
if (previous == HostRuntimeState.Stopped)
|
||||
{
|
||||
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Running, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
state.FailureCount++;
|
||||
if (previous != HostRuntimeState.Stopped)
|
||||
{
|
||||
state.State = HostRuntimeState.Stopped;
|
||||
state.LastStateChangeUtc = now;
|
||||
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Stopped, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transition is { } t)
|
||||
{
|
||||
StateChanged?.Invoke(this, t);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
lock (_lock)
|
||||
{
|
||||
_byProbe.Clear();
|
||||
_probeByTagName.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HostProbeState
|
||||
{
|
||||
public string TagName { get; set; } = "";
|
||||
public HostRuntimeState State { get; set; }
|
||||
public DateTime LastStateChangeUtc { get; set; }
|
||||
public DateTime? LastCallbackUtc { get; set; }
|
||||
public long GoodUpdateCount { get; set; }
|
||||
public long FailureCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public enum HostRuntimeState
|
||||
{
|
||||
Unknown,
|
||||
Running,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
public sealed record HostStateTransition(
|
||||
string TagName,
|
||||
HostRuntimeState OldState,
|
||||
HostRuntimeState NewState,
|
||||
DateTime AtUtc);
|
||||
|
||||
public sealed record HostProbeSnapshot(
|
||||
string TagName,
|
||||
HostRuntimeState State,
|
||||
DateTime LastChangedUtc);
|
||||
|
||||
public readonly record struct HostProbeTarget(string TagName, int CategoryId)
|
||||
{
|
||||
public bool IsRuntimeHost =>
|
||||
CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||
|| CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 2 placeholder backend — accepts session open/close + responds to recycle, returns
|
||||
/// "not-implemented" results for every data-plane call. Replaced by the lifted
|
||||
/// <c>MxAccessClient</c>-backed implementation during the deferred Galaxy code move
|
||||
/// (Task B.1 + parity gate). Keeps the IPC end-to-end testable today.
|
||||
/// </summary>
|
||||
public sealed class StubGalaxyBackend : IGalaxyBackend
|
||||
{
|
||||
private long _nextSessionId;
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
// Stub backend never raises events — implements the interface members for symmetry.
|
||||
#pragma warning disable CS0067
|
||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSessionId);
|
||||
return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
|
||||
}
|
||||
|
||||
public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<DiscoverHierarchyResponse> DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new DiscoverHierarchyResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Objects = System.Array.Empty<GalaxyObjectInfo>(),
|
||||
});
|
||||
|
||||
public Task<ReadValuesResponse> ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new ReadValuesResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<WriteValuesResponse> WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
|
||||
{
|
||||
var results = new WriteValueResult[req.Writes.Length];
|
||||
for (var i = 0; i < req.Writes.Length; i++)
|
||||
{
|
||||
results[i] = new WriteValueResult
|
||||
{
|
||||
TagReference = req.Writes[i].TagReference,
|
||||
StatusCode = 0x80020000u, // Bad_InternalError
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
};
|
||||
}
|
||||
return Task.FromResult(new WriteValuesResponse { Results = results });
|
||||
}
|
||||
|
||||
public Task<SubscribeResponse> SubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
var sid = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
return Task.FromResult(new SubscribeResponse
|
||||
{
|
||||
Success = true,
|
||||
SubscriptionId = sid,
|
||||
ActualIntervalMs = req.RequestedIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||
HistoryReadEventsRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Events = System.Array.Empty<GalaxyHistoricalEvent>(),
|
||||
});
|
||||
|
||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new RecycleStatusResponse
|
||||
{
|
||||
Accepted = true,
|
||||
GraceSeconds = 15, // matches Phase 2 plan §B.8 default
|
||||
});
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Real IPC dispatcher — routes each <see cref="MessageKind"/> to the matching
|
||||
/// <see cref="IGalaxyBackend"/> method. Replaces <see cref="StubFrameHandler"/>. Heartbeat
|
||||
/// stays handled inline so liveness detection works regardless of backend health.
|
||||
/// </summary>
|
||||
public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) : IFrameHandler
|
||||
{
|
||||
public async Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case MessageKind.Heartbeat:
|
||||
{
|
||||
var hb = Deserialize<Heartbeat>(body);
|
||||
await writer.WriteAsync(MessageKind.HeartbeatAck,
|
||||
new HeartbeatAck { SequenceNumber = hb.SequenceNumber, UtcUnixMs = hb.UtcUnixMs }, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.OpenSessionRequest:
|
||||
{
|
||||
var resp = await backend.OpenSessionAsync(Deserialize<OpenSessionRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.OpenSessionResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.CloseSessionRequest:
|
||||
await backend.CloseSessionAsync(Deserialize<CloseSessionRequest>(body), ct);
|
||||
return; // one-way
|
||||
|
||||
case MessageKind.DiscoverHierarchyRequest:
|
||||
{
|
||||
var resp = await backend.DiscoverAsync(Deserialize<DiscoverHierarchyRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.DiscoverHierarchyResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.ReadValuesRequest:
|
||||
{
|
||||
var resp = await backend.ReadValuesAsync(Deserialize<ReadValuesRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.ReadValuesResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.WriteValuesRequest:
|
||||
{
|
||||
var resp = await backend.WriteValuesAsync(Deserialize<WriteValuesRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.WriteValuesResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.SubscribeRequest:
|
||||
{
|
||||
var resp = await backend.SubscribeAsync(Deserialize<SubscribeRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.SubscribeResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.UnsubscribeRequest:
|
||||
await backend.UnsubscribeAsync(Deserialize<UnsubscribeRequest>(body), ct);
|
||||
return; // one-way
|
||||
|
||||
case MessageKind.AlarmSubscribeRequest:
|
||||
await backend.SubscribeAlarmsAsync(Deserialize<AlarmSubscribeRequest>(body), ct);
|
||||
return; // one-way; subsequent alarm events are server-pushed
|
||||
case MessageKind.AlarmAckRequest:
|
||||
await backend.AcknowledgeAlarmAsync(Deserialize<AlarmAckRequest>(body), ct);
|
||||
return;
|
||||
|
||||
case MessageKind.HistoryReadRequest:
|
||||
{
|
||||
var resp = await backend.HistoryReadAsync(Deserialize<HistoryReadRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.HistoryReadResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.HistoryReadProcessedRequest:
|
||||
{
|
||||
var resp = await backend.HistoryReadProcessedAsync(
|
||||
Deserialize<HistoryReadProcessedRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.HistoryReadAtTimeRequest:
|
||||
{
|
||||
var resp = await backend.HistoryReadAtTimeAsync(
|
||||
Deserialize<HistoryReadAtTimeRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.HistoryReadAtTimeResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.HistoryReadEventsRequest:
|
||||
{
|
||||
var resp = await backend.HistoryReadEventsAsync(
|
||||
Deserialize<HistoryReadEventsRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.HistoryReadEventsResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.RecycleHostRequest:
|
||||
{
|
||||
var resp = await backend.RecycleAsync(Deserialize<RecycleHostRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.RecycleStatusResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
await SendErrorAsync(writer, "unknown-kind", $"Frame kind {kind} not handled by Host", ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "GalaxyFrameHandler threw on {Kind}", kind);
|
||||
await SendErrorAsync(writer, "handler-exception", ex.Message, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes the backend's server-pushed events for the lifetime of the connection.
|
||||
/// The returned disposable unsubscribes when the connection closes — without it the
|
||||
/// backend's static event invocation list would accumulate dead writer references and
|
||||
/// leak memory + raise <see cref="ObjectDisposedException"/> on every push.
|
||||
/// </summary>
|
||||
public IDisposable AttachConnection(FrameWriter writer)
|
||||
{
|
||||
var sink = new ConnectionSink(backend, writer, logger);
|
||||
sink.Attach();
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||
|
||||
private static Task SendErrorAsync(FrameWriter writer, string code, string message, CancellationToken ct)
|
||||
=> writer.WriteAsync(MessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = code, Message = message }, ct);
|
||||
|
||||
private sealed class ConnectionSink : IDisposable
|
||||
{
|
||||
private readonly IGalaxyBackend _backend;
|
||||
private readonly FrameWriter _writer;
|
||||
private readonly ILogger _logger;
|
||||
private EventHandler<OnDataChangeNotification>? _onData;
|
||||
private EventHandler<GalaxyAlarmEvent>? _onAlarm;
|
||||
private EventHandler<HostConnectivityStatus>? _onHost;
|
||||
|
||||
public ConnectionSink(IGalaxyBackend backend, FrameWriter writer, ILogger logger)
|
||||
{
|
||||
_backend = backend; _writer = writer; _logger = logger;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
_onData = (_, e) => Push(MessageKind.OnDataChangeNotification, e);
|
||||
_onAlarm = (_, e) => Push(MessageKind.AlarmEvent, e);
|
||||
_onHost = (_, e) => Push(MessageKind.RuntimeStatusChange,
|
||||
new RuntimeStatusChangeNotification { Status = e });
|
||||
_backend.OnDataChange += _onData;
|
||||
_backend.OnAlarmEvent += _onAlarm;
|
||||
_backend.OnHostStatusChanged += _onHost;
|
||||
}
|
||||
|
||||
private void Push<T>(MessageKind kind, T payload)
|
||||
{
|
||||
// Fire-and-forget — pushes can race with disposal of the writer. We swallow
|
||||
// ObjectDisposedException because the dispose path will detach this sink shortly.
|
||||
try { _writer.WriteAsync(kind, payload, CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (Exception ex) { _logger.Warning(ex, "ConnectionSink push failed for {Kind}", kind); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_onData is not null) _backend.OnDataChange -= _onData;
|
||||
if (_onAlarm is not null) _backend.OnAlarmEvent -= _onAlarm;
|
||||
if (_onHost is not null) _backend.OnHostStatusChanged -= _onHost;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="PipeSecurity"/> required by <c>driver-stability.md §"IPC Security"</c>:
|
||||
/// only the configured OtOpcUa server principal SID gets <c>ReadWrite | Synchronize</c>;
|
||||
/// LocalSystem is explicitly denied. Any other authenticated user falls through to the
|
||||
/// implicit deny.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Earlier revisions also denied <c>BUILTIN\Administrators</c>, which broke live testing
|
||||
/// on dev boxes where the allowed user (<c>dohertj2</c>) is also a member of the local
|
||||
/// Administrators group — UAC's filtered token still carries the Admins SID as deny-only,
|
||||
/// so the deny ACE fired even from non-elevated shells. The per-connection
|
||||
/// <see cref="PipeServer.VerifyCaller"/> check already gates on the exact allowed SID,
|
||||
/// which is the real authorization boundary, so the Admins deny added no defence in depth
|
||||
/// in that topology.
|
||||
/// </remarks>
|
||||
public static class PipeAcl
|
||||
{
|
||||
public static PipeSecurity Create(SecurityIdentifier allowedSid)
|
||||
{
|
||||
if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
|
||||
|
||||
var security = new PipeSecurity();
|
||||
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
allowedSid,
|
||||
PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
|
||||
AccessControlType.Allow));
|
||||
|
||||
var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
if (allowedSid != localSystem)
|
||||
security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
|
||||
|
||||
// Owner = allowed SID so the deny rules can't be removed without write-DACL rights.
|
||||
security.SetOwner(allowedSid);
|
||||
|
||||
return security;
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one client connection at a time on a named pipe with the strict ACL from
|
||||
/// <see cref="PipeAcl"/>. Verifies the peer SID and the per-process shared secret before any
|
||||
/// RPC frame is accepted. Per <c>driver-stability.md §"IPC Security"</c>.
|
||||
/// </summary>
|
||||
public sealed class PipeServer : IDisposable
|
||||
{
|
||||
private readonly string _pipeName;
|
||||
private readonly SecurityIdentifier _allowedSid;
|
||||
private readonly string _sharedSecret;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private NamedPipeServerStream? _current;
|
||||
|
||||
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
|
||||
{
|
||||
_pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
|
||||
_allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
|
||||
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one connection, performs Hello handshake, then dispatches frames to
|
||||
/// <paramref name="handler"/> until EOF or cancel. Returns when the client disconnects.
|
||||
/// </summary>
|
||||
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
|
||||
var acl = PipeAcl.Create(_allowedSid);
|
||||
|
||||
// .NET Framework 4.8 uses the legacy constructor overload that takes a PipeSecurity directly.
|
||||
_current = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.InOut,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous,
|
||||
inBufferSize: 64 * 1024,
|
||||
outBufferSize: 64 * 1024,
|
||||
pipeSecurity: acl);
|
||||
|
||||
try
|
||||
{
|
||||
await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
|
||||
|
||||
using var reader = new FrameReader(_current, leaveOpen: true);
|
||||
using var writer = new FrameWriter(_current, leaveOpen: true);
|
||||
|
||||
// First frame must be a Hello with the correct shared secret. Reading it before
|
||||
// the caller-SID impersonation check satisfies Windows' ERROR_CANNOT_IMPERSONATE
|
||||
// rule — ImpersonateNamedPipeClient fails until at least one frame has been read.
|
||||
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != MessageKind.Hello)
|
||||
{
|
||||
_logger.Warning("IPC first frame was not Hello; dropping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VerifyCaller(_current, out var reason))
|
||||
{
|
||||
_logger.Warning("IPC caller rejected: {Reason}", reason);
|
||||
_current.Disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("IPC Hello rejected: shared-secret-mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hello.ProtocolMajor != Hello.CurrentMajor)
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("IPC Hello rejected: major mismatch peer={Peer} server={Server}",
|
||||
hello.ProtocolMajor, Hello.CurrentMajor);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = true, HostName = Environment.MachineName },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
|
||||
using var attachment = handler.AttachConnection(writer);
|
||||
|
||||
while (!linked.Token.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
|
||||
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_current.Dispose();
|
||||
_current = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the server continuously, handling one connection at a time. When a connection ends
|
||||
/// (clean or error), accepts the next.
|
||||
/// </summary>
|
||||
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.Error(ex, "IPC connection loop error — accepting next"); }
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe.RunAsClient(() =>
|
||||
{
|
||||
using var wi = WindowsIdentity.GetCurrent();
|
||||
if (wi.User is null)
|
||||
throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
|
||||
if (wi.User != _allowedSid)
|
||||
throw new UnauthorizedAccessException(
|
||||
$"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
|
||||
});
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { reason = ex.Message; return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_current?.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public interface IFrameHandler
|
||||
{
|
||||
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Called once per accepted connection after the Hello handshake. Lets the handler
|
||||
/// attach server-pushed event sinks (data-change, alarm, host-status) to the
|
||||
/// connection's <paramref name="writer"/>. Returns an <see cref="IDisposable"/> the
|
||||
/// pipe server disposes when the connection closes — backends use it to unsubscribe.
|
||||
/// Implementations that don't push events can return <see cref="NoopAttachment"/>.
|
||||
/// </summary>
|
||||
IDisposable AttachConnection(FrameWriter writer);
|
||||
|
||||
public sealed class NoopAttachment : IDisposable
|
||||
{
|
||||
public static readonly NoopAttachment Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder handler that responds to the framed IPC with error responses. Replaced by the
|
||||
/// real Galaxy-backed handler when the MXAccess code move (deferred) lands.
|
||||
/// </summary>
|
||||
public sealed class StubFrameHandler : IFrameHandler
|
||||
{
|
||||
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
// Minimal lifecycle: heartbeat ack keeps the supervisor's liveness detector happy even
|
||||
// while the data-plane is stubbed, so integration tests of the supervisor can run end-to-end.
|
||||
if (kind == MessageKind.Heartbeat)
|
||||
{
|
||||
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
|
||||
return writer.WriteAsync(MessageKind.HeartbeatAck,
|
||||
new HeartbeatAck { SequenceNumber = hb.SequenceNumber, UtcUnixMs = hb.UtcUnixMs }, ct);
|
||||
}
|
||||
|
||||
return writer.WriteAsync(MessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = "not-implemented", Message = $"Kind {kind} is stubbed — MXAccess lift deferred" },
|
||||
ct);
|
||||
}
|
||||
|
||||
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Shim — .NET Framework 4.8 doesn't ship with IsExternalInit, required for init-only setters +
|
||||
// positional records. Safe to add in our own namespace; the compiler accepts any type with this name.
|
||||
namespace System.Runtime.CompilerServices;
|
||||
|
||||
internal static class IsExternalInit;
|
||||
@@ -1,139 +0,0 @@
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the <c>OtOpcUaGalaxyHost</c> Windows service / console host. Reads the
|
||||
/// pipe name, allowed-SID, and shared secret from environment (passed by the supervisor at
|
||||
/// spawn time per <c>driver-stability.md</c>).
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.File(
|
||||
@"%ProgramData%\OtOpcUa\galaxy-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
|
||||
rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_PIPE") ?? "OtOpcUaGalaxy";
|
||||
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
|
||||
?? throw new InvalidOperationException("OTOPCUA_ALLOWED_SID not set — supervisor must pass the server principal SID");
|
||||
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_SECRET")
|
||||
?? throw new InvalidOperationException("OTOPCUA_GALAXY_SECRET not set — supervisor must pass the per-process secret at spawn time");
|
||||
|
||||
var allowedSid = new SecurityIdentifier(allowedSidValue);
|
||||
|
||||
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
|
||||
Log.Information("OtOpcUaGalaxyHost starting — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
|
||||
|
||||
// Backend selection — env var picks the implementation:
|
||||
// OTOPCUA_GALAXY_BACKEND=stub → StubGalaxyBackend (no Galaxy required)
|
||||
// OTOPCUA_GALAXY_BACKEND=db → DbBackedGalaxyBackend (Discover only, against ZB)
|
||||
// OTOPCUA_GALAXY_BACKEND=mxaccess → MxAccessGalaxyBackend (real COM + ZB; default)
|
||||
var backendKind = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_BACKEND")?.ToLowerInvariant() ?? "mxaccess";
|
||||
var zbConn = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_ZB_CONN")
|
||||
?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
|
||||
var clientName = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_CLIENT_NAME") ?? "OtOpcUa-Galaxy.Host";
|
||||
|
||||
IGalaxyBackend backend;
|
||||
StaPump? pump = null;
|
||||
MxAccessClient? mx = null;
|
||||
switch (backendKind)
|
||||
{
|
||||
case "stub":
|
||||
backend = new StubGalaxyBackend();
|
||||
break;
|
||||
case "db":
|
||||
backend = new DbBackedGalaxyBackend(new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = zbConn }));
|
||||
break;
|
||||
default: // mxaccess
|
||||
pump = new StaPump("Galaxy.Sta");
|
||||
pump.WaitForStartedAsync().GetAwaiter().GetResult();
|
||||
mx = new MxAccessClient(pump, new MxProxyAdapter(), clientName);
|
||||
var historian = BuildHistorianIfEnabled();
|
||||
backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = zbConn }),
|
||||
mx,
|
||||
historian);
|
||||
break;
|
||||
}
|
||||
|
||||
Log.Information("OtOpcUaGalaxyHost backend={Backend}", backendKind);
|
||||
var handler = new GalaxyFrameHandler(backend, Log.Logger);
|
||||
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
|
||||
finally
|
||||
{
|
||||
(backend as IDisposable)?.Dispose();
|
||||
mx?.Dispose();
|
||||
pump?.Dispose();
|
||||
}
|
||||
|
||||
Log.Information("OtOpcUaGalaxyHost stopped cleanly");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "OtOpcUaGalaxyHost fatal");
|
||||
return 2;
|
||||
}
|
||||
finally { Log.CloseAndFlush(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="HistorianDataSource"/> from the OTOPCUA_HISTORIAN_* environment
|
||||
/// variables the supervisor passes at spawn time. Returns null when the historian is
|
||||
/// disabled (default) so <c>MxAccessGalaxyBackend.HistoryReadAsync</c> returns a clear
|
||||
/// "not configured" error instead of attempting an SDK connection to localhost.
|
||||
/// </summary>
|
||||
private static IHistorianDataSource? BuildHistorianIfEnabled()
|
||||
{
|
||||
var enabled = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED");
|
||||
if (!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase) && enabled != "1")
|
||||
return null;
|
||||
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
||||
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
||||
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
|
||||
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
|
||||
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
|
||||
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
|
||||
MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000),
|
||||
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
|
||||
};
|
||||
|
||||
var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS");
|
||||
if (!string.IsNullOrWhiteSpace(servers))
|
||||
cfg.ServerNames = new System.Collections.Generic.List<string>(
|
||||
servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
Log.Information("Historian enabled — {NodeCount} configured node(s), port={Port}",
|
||||
cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port);
|
||||
return new HistorianDataSource(cfg);
|
||||
}
|
||||
|
||||
private static int TryParseInt(string envName, int defaultValue)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(envName);
|
||||
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.ConstrainedExecution;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// SafeHandle-style lifetime wrapper for an <c>LMXProxyServer</c> COM connection. Per Task B.3
|
||||
/// + decision #65: <see cref="ReleaseHandle"/> must call <c>Marshal.ReleaseComObject</c> until
|
||||
/// refcount = 0, then <c>UnregisterProxy</c>. The finalizer runs as a
|
||||
/// <see cref="CriticalFinalizerObject"/> to honor AppDomain-unload ordering.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This scaffold accepts any RCW (tagged as <see cref="object"/>) so we can unit-test the
|
||||
/// release logic with a mock. The concrete wiring to <c>ArchestrA.MxAccess.LMXProxyServer</c>
|
||||
/// lands when the actual Galaxy code moves over (the part deferred to the parity gate).
|
||||
/// </remarks>
|
||||
public sealed class MxAccessHandle : SafeHandle
|
||||
{
|
||||
private object? _comObject;
|
||||
private readonly Action<object>? _unregister;
|
||||
|
||||
public MxAccessHandle(object comObject, Action<object>? unregister = null)
|
||||
: base(IntPtr.Zero, ownsHandle: true)
|
||||
{
|
||||
_comObject = comObject ?? throw new ArgumentNullException(nameof(comObject));
|
||||
_unregister = unregister;
|
||||
|
||||
// The pointer value itself doesn't matter — we're wrapping an RCW, not a native handle.
|
||||
SetHandle(new IntPtr(1));
|
||||
}
|
||||
|
||||
public override bool IsInvalid => handle == IntPtr.Zero;
|
||||
|
||||
public object? RawComObject => _comObject;
|
||||
|
||||
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
if (_comObject is null) return true;
|
||||
|
||||
try { _unregister?.Invoke(_comObject); }
|
||||
catch { /* swallow — we're in finalizer/cleanup; log elsewhere */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (Marshal.IsComObject(_comObject))
|
||||
{
|
||||
while (Marshal.ReleaseComObject(_comObject) > 0) { /* loop until fully released */ }
|
||||
}
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
|
||||
_comObject = null;
|
||||
SetHandle(IntPtr.Zero);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated STA thread with a Win32 message pump that owns all <c>LMXProxyServer</c> COM
|
||||
/// instances. Lifted from v1 <c>StaComThread</c> per CLAUDE.md "Reference Implementation".
|
||||
/// Per <c>driver-stability.md</c> Galaxy deep dive §"STA thread + Win32 message pump":
|
||||
/// work items dispatched via <c>PostThreadMessage(WM_APP)</c>; <c>WM_APP+1</c> requests a
|
||||
/// graceful drain → <c>WM_QUIT</c>; supervisor escalates to <c>Environment.Exit(2)</c> if the
|
||||
/// pump doesn't drain within the recycle grace window.
|
||||
/// </summary>
|
||||
public sealed class StaPump : IDisposable
|
||||
{
|
||||
private const uint WM_APP = 0x8000;
|
||||
private const uint WM_DRAIN_AND_QUIT = WM_APP + 1;
|
||||
private const uint PM_NOREMOVE = 0x0000;
|
||||
|
||||
private readonly Thread _thread;
|
||||
private readonly ConcurrentQueue<WorkItem> _workItems = new();
|
||||
private readonly TaskCompletionSource<bool> _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private volatile uint _nativeThreadId;
|
||||
private volatile bool _pumpExited;
|
||||
private volatile bool _disposed;
|
||||
|
||||
public int ThreadId => _thread.ManagedThreadId;
|
||||
public DateTime LastDispatchedUtc { get; private set; } = DateTime.MinValue;
|
||||
public int QueueDepth => _workItems.Count;
|
||||
public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited;
|
||||
|
||||
public StaPump(string name = "Galaxy.Sta")
|
||||
{
|
||||
_thread = new Thread(PumpLoop) { Name = name, IsBackground = true };
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public Task WaitForStartedAsync() => _started.Task;
|
||||
|
||||
/// <summary>Posts a work item; resolves once it's executed on the STA thread.</summary>
|
||||
public Task<T> InvokeAsync<T>(Func<T> work)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaPump));
|
||||
if (_pumpExited) throw new InvalidOperationException("STA pump has exited");
|
||||
|
||||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_workItems.Enqueue(new WorkItem(
|
||||
() =>
|
||||
{
|
||||
try { tcs.TrySetResult(work()); }
|
||||
catch (Exception ex) { tcs.TrySetException(ex); }
|
||||
},
|
||||
ex => tcs.TrySetException(ex)));
|
||||
|
||||
if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
|
||||
{
|
||||
_pumpExited = true;
|
||||
DrainAndFaultQueue();
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public Task InvokeAsync(Action work) => InvokeAsync(() => { work(); return 0; });
|
||||
|
||||
/// <summary>
|
||||
/// Health probe — returns true if a no-op work item round-trips within
|
||||
/// <paramref name="timeout"/>. Used by the supervisor; timeout means the pump is wedged
|
||||
/// and a recycle is warranted (Task B.2 acceptance).
|
||||
/// </summary>
|
||||
public async Task<bool> IsResponsiveAsync(TimeSpan timeout)
|
||||
{
|
||||
if (!IsRunning) return false;
|
||||
var task = InvokeAsync(() => { });
|
||||
var completed = await Task.WhenAny(task, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
return completed == task;
|
||||
}
|
||||
|
||||
private void PumpLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_nativeThreadId = GetCurrentThreadId();
|
||||
|
||||
// Force the system to create the thread message queue before we signal Started.
|
||||
// PeekMessage(PM_NOREMOVE) on an empty queue is the documented way to do this.
|
||||
PeekMessage(out _, IntPtr.Zero, 0, 0, PM_NOREMOVE);
|
||||
|
||||
_started.TrySetResult(true);
|
||||
|
||||
// GetMessage returns 0 on WM_QUIT, -1 on error, otherwise a positive value.
|
||||
while (GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
if (msg.message == WM_APP)
|
||||
{
|
||||
DrainQueue();
|
||||
}
|
||||
else if (msg.message == WM_DRAIN_AND_QUIT)
|
||||
{
|
||||
DrainQueue();
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pass through any window/dialog messages the COM proxy may inject.
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_started.TrySetException(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pumpExited = true;
|
||||
DrainAndFaultQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainQueue()
|
||||
{
|
||||
while (_workItems.TryDequeue(out var item))
|
||||
{
|
||||
item.Execute();
|
||||
LastDispatchedUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainAndFaultQueue()
|
||||
{
|
||||
var ex = new InvalidOperationException("STA pump has exited");
|
||||
while (_workItems.TryDequeue(out var item))
|
||||
{
|
||||
try { item.Fault(ex); }
|
||||
catch { /* faulting a TCS shouldn't throw, but be defensive */ }
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (_nativeThreadId != 0 && !_pumpExited)
|
||||
PostThreadMessage(_nativeThreadId, WM_DRAIN_AND_QUIT, IntPtr.Zero, IntPtr.Zero);
|
||||
_thread.Join(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { /* swallow — best effort */ }
|
||||
|
||||
DrainAndFaultQueue();
|
||||
}
|
||||
|
||||
private sealed record WorkItem(Action Execute, Action<Exception> Fault);
|
||||
|
||||
#region Win32 P/Invoke
|
||||
|
||||
[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,64 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy-specific RSS watchdog per <c>driver-stability.md §"Memory Watchdog Thresholds"</c>.
|
||||
/// Baseline-relative + absolute caps. Sustained-slope detection uses a rolling 30-min window.
|
||||
/// Pluggable RSS source keeps it unit-testable.
|
||||
/// </summary>
|
||||
public sealed class MemoryWatchdog
|
||||
{
|
||||
/// <summary>Absolute hard ceiling — process is force-killed above this.</summary>
|
||||
public long HardCeilingBytes { get; init; } = 1_500L * 1024 * 1024;
|
||||
|
||||
/// <summary>Sustained slope (bytes/min) above which soft recycle is scheduled.</summary>
|
||||
public long SustainedSlopeBytesPerMinute { get; init; } = 5L * 1024 * 1024;
|
||||
|
||||
public TimeSpan SlopeWindow { get; init; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly long _baselineBytes;
|
||||
private readonly Queue<RssSample> _samples = new();
|
||||
|
||||
public MemoryWatchdog(long baselineBytes)
|
||||
{
|
||||
_baselineBytes = baselineBytes;
|
||||
}
|
||||
|
||||
/// <summary>Called every 30s with the current RSS. Returns the action the supervisor should take.</summary>
|
||||
public WatchdogAction Sample(long rssBytes, DateTime utcNow)
|
||||
{
|
||||
_samples.Enqueue(new RssSample(utcNow, rssBytes));
|
||||
while (_samples.Count > 0 && utcNow - _samples.Peek().TimestampUtc > SlopeWindow)
|
||||
_samples.Dequeue();
|
||||
|
||||
if (rssBytes >= HardCeilingBytes)
|
||||
return WatchdogAction.HardKill;
|
||||
|
||||
var softThreshold = Math.Max(_baselineBytes * 2, _baselineBytes + 200L * 1024 * 1024);
|
||||
var warnThreshold = Math.Max((long)(_baselineBytes * 1.5), _baselineBytes + 200L * 1024 * 1024);
|
||||
|
||||
if (rssBytes >= softThreshold) return WatchdogAction.SoftRecycle;
|
||||
if (rssBytes >= warnThreshold) return WatchdogAction.Warn;
|
||||
|
||||
if (_samples.Count >= 2)
|
||||
{
|
||||
var oldest = _samples.Peek();
|
||||
var span = (utcNow - oldest.TimestampUtc).TotalMinutes;
|
||||
if (span >= SlopeWindow.TotalMinutes * 0.9) // need ~full window to trust the slope
|
||||
{
|
||||
var delta = rssBytes - oldest.RssBytes;
|
||||
var bytesPerMin = delta / span;
|
||||
if (bytesPerMin >= SustainedSlopeBytesPerMinute)
|
||||
return WatchdogAction.SoftRecycle;
|
||||
}
|
||||
}
|
||||
|
||||
return WatchdogAction.None;
|
||||
}
|
||||
|
||||
private readonly record struct RssSample(DateTime TimestampUtc, long RssBytes);
|
||||
}
|
||||
|
||||
public enum WatchdogAction { None, Warn, SoftRecycle, HardKill }
|
||||
@@ -1,121 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Ring-buffer of the last <see cref="Capacity"/> IPC operations, written into a
|
||||
/// memory-mapped file. On hard crash the supervisor reads the MMF after the corpse is gone
|
||||
/// to see what was in flight. Thread-safe for the single-writer, multi-reader pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File layout:
|
||||
/// <code>
|
||||
/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)]
|
||||
/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]]
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class PostMortemMmf : IDisposable
|
||||
{
|
||||
private const int Magic = 0x4F505043; // 'OPPC'
|
||||
private const int Version = 1;
|
||||
private const int HeaderBytes = 16;
|
||||
public const int EntryBytes = 256;
|
||||
private const int MessageOffset = 16;
|
||||
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||
|
||||
public int Capacity { get; }
|
||||
public string Path { get; }
|
||||
|
||||
private readonly MemoryMappedFile _mmf;
|
||||
private readonly MemoryMappedViewAccessor _accessor;
|
||||
private readonly object _writeGate = new();
|
||||
|
||||
public PostMortemMmf(string path, int capacity = 1000)
|
||||
{
|
||||
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
Capacity = capacity;
|
||||
Path = path;
|
||||
|
||||
var fileBytes = HeaderBytes + capacity * EntryBytes;
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
|
||||
|
||||
var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
|
||||
fs.SetLength(fileBytes);
|
||||
_mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||
_accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||
|
||||
// Initialize header if blank/garbage.
|
||||
if (_accessor.ReadInt32(0) != Magic)
|
||||
{
|
||||
_accessor.Write(0, Magic);
|
||||
_accessor.Write(4, Version);
|
||||
_accessor.Write(8, capacity);
|
||||
_accessor.Write(12, 0); // writeIndex
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(long opKind, string message)
|
||||
{
|
||||
lock (_writeGate)
|
||||
{
|
||||
var idx = _accessor.ReadInt32(12);
|
||||
var offset = HeaderBytes + idx * EntryBytes;
|
||||
|
||||
_accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
_accessor.Write(offset + 8, opKind);
|
||||
|
||||
var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty);
|
||||
var copy = Math.Min(msgBytes.Length, MessageCapacity - 1);
|
||||
_accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy);
|
||||
_accessor.Write(offset + MessageOffset + copy, (byte)0); // null terminator
|
||||
|
||||
var next = (idx + 1) % Capacity;
|
||||
_accessor.Write(12, next);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads all entries in order (oldest → newest). Safe to call from another process.</summary>
|
||||
public PostMortemEntry[] ReadAll()
|
||||
{
|
||||
var magic = _accessor.ReadInt32(0);
|
||||
if (magic != Magic) return [];
|
||||
|
||||
var capacity = _accessor.ReadInt32(8);
|
||||
var writeIndex = _accessor.ReadInt32(12);
|
||||
|
||||
var entries = new PostMortemEntry[capacity];
|
||||
var count = 0;
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
var slot = (writeIndex + i) % capacity;
|
||||
var offset = HeaderBytes + slot * EntryBytes;
|
||||
|
||||
var ts = _accessor.ReadInt64(offset + 0);
|
||||
if (ts == 0) continue; // unwritten
|
||||
|
||||
var op = _accessor.ReadInt64(offset + 8);
|
||||
var msgBuf = new byte[MessageCapacity];
|
||||
_accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||
|
||||
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||
}
|
||||
|
||||
Array.Resize(ref entries, count);
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor.Dispose();
|
||||
_mmf.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);
|
||||
@@ -1,40 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Frequency-capped soft-recycle decision per <c>driver-stability.md §"Recycle Policy"</c>.
|
||||
/// Default cap: 1 soft recycle per hour. Scheduled recycle at 03:00 local; supervisor reads
|
||||
/// <see cref="ShouldSoftRecycleScheduled"/> to decide.
|
||||
/// </summary>
|
||||
public sealed class RecyclePolicy
|
||||
{
|
||||
public TimeSpan SoftRecycleCap { get; init; } = TimeSpan.FromHours(1);
|
||||
public int DailyRecycleHourLocal { get; init; } = 3;
|
||||
|
||||
private readonly List<DateTime> _recentRecyclesUtc = new();
|
||||
|
||||
/// <summary>Returns true if a soft recycle would be allowed under the frequency cap.</summary>
|
||||
public bool TryRequestSoftRecycle(DateTime utcNow, out string? reason)
|
||||
{
|
||||
_recentRecyclesUtc.RemoveAll(t => utcNow - t > SoftRecycleCap);
|
||||
if (_recentRecyclesUtc.Count > 0)
|
||||
{
|
||||
reason = $"soft-recycle frequency cap: last recycle was {(utcNow - _recentRecyclesUtc[_recentRecyclesUtc.Count - 1]).TotalMinutes:F1} min ago";
|
||||
return false;
|
||||
}
|
||||
_recentRecyclesUtc.Add(utcNow);
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ShouldSoftRecycleScheduled(DateTime localNow, ref DateTime lastScheduledDateLocal)
|
||||
{
|
||||
if (localNow.Hour != DailyRecycleHourLocal) return false;
|
||||
if (localNow.Date <= lastScheduledDateLocal.Date) return false;
|
||||
|
||||
lastScheduledDateLocal = localNow.Date;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<!-- Decision #23: x86 required for MXAccess COM interop. The MxAccess COM client is
|
||||
now ported (Backend/MxAccess/) so we need the x86 platform target for the
|
||||
ArchestrA.MxAccess.dll COM interop reference to resolve at runtime. -->
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Driver.Galaxy.Host</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
|
||||
<PackageReference Include="System.Memory" Version="4.5.5"/>
|
||||
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
|
||||
<PackageReference Include="System.Data.SqlClient" Version="4.9.0"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<!-- PR 3.2: Historian SDK code lifted to the Wonderware sidecar. Galaxy.Host still
|
||||
consumes the historian types (MxAccessGalaxyBackend, Program) until phase 7,
|
||||
so reference the sidecar project to keep building. -->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</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,590 +0,0 @@
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
using IpcHostConnectivityStatus = ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts.HostConnectivityStatus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IDriver"/> implementation that forwards every capability over the Galaxy IPC
|
||||
/// channel to the out-of-process Host. Implements the full Phase 2 capability surface;
|
||||
/// bodies that depend on the deferred Host-side MXAccess code lift will surface
|
||||
/// <see cref="GalaxyIpcException"/> with code <c>not-implemented</c> until the Host's
|
||||
/// <c>IGalaxyBackend</c> is wired to the real <c>MxAccessClient</c>.
|
||||
/// </summary>
|
||||
public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
||||
: IDriver,
|
||||
ITagDiscovery,
|
||||
IReadable,
|
||||
IWritable,
|
||||
ISubscribable,
|
||||
IAlarmSource,
|
||||
IHistoryProvider,
|
||||
IRediscoverable,
|
||||
IHostConnectivityProbe,
|
||||
IAlarmHistorianWriter,
|
||||
IDisposable
|
||||
{
|
||||
private GalaxyIpcClient? _client;
|
||||
private long _sessionId;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
private IReadOnlyList<Core.Abstractions.HostConnectivityStatus> _hostStatuses = [];
|
||||
|
||||
public string DriverInstanceId => options.DriverInstanceId;
|
||||
public string DriverType => "Galaxy";
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
_client = await GalaxyIpcClient.ConnectAsync(
|
||||
options.PipeName, options.SharedSecret, options.ConnectTimeout, cancellationToken);
|
||||
|
||||
// Route Host-pushed event frames to the matching Raise* methods. Must be set BEFORE
|
||||
// the first CallAsync so a RuntimeStatusChange arriving between OpenSessionRequest
|
||||
// and OpenSessionResponse lands on the handler rather than unblocking the call with
|
||||
// the wrong kind.
|
||||
_client.SetEventHandler(DispatchHostEventAsync);
|
||||
|
||||
var resp = await _client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = DriverInstanceId, DriverConfigJson = driverConfigJson },
|
||||
MessageKind.OpenSessionResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host OpenSession failed: {resp.Error}");
|
||||
|
||||
_sessionId = resp.SessionId;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
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)
|
||||
{
|
||||
if (_client is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.SendOneWayAsync(
|
||||
MessageKind.CloseSessionRequest,
|
||||
new CloseSessionRequest { SessionId = _sessionId },
|
||||
cancellationToken);
|
||||
}
|
||||
catch { /* shutdown is best effort */ }
|
||||
|
||||
await _client.DisposeAsync();
|
||||
_client = 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 async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var client = RequireClient();
|
||||
|
||||
var resp = await client.CallAsync<DiscoverHierarchyRequest, DiscoverHierarchyResponse>(
|
||||
MessageKind.DiscoverHierarchyRequest,
|
||||
new DiscoverHierarchyRequest { SessionId = _sessionId },
|
||||
MessageKind.DiscoverHierarchyResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host DiscoverHierarchy failed: {resp.Error}");
|
||||
|
||||
foreach (var obj in resp.Objects)
|
||||
{
|
||||
var folder = builder.Folder(obj.ContainedName, obj.ContainedName);
|
||||
foreach (var attr in obj.Attributes)
|
||||
{
|
||||
var fullName = $"{obj.TagName}.{attr.AttributeName}";
|
||||
var handle = folder.Variable(
|
||||
attr.AttributeName,
|
||||
attr.AttributeName,
|
||||
new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: MapDataType(attr.MxDataType),
|
||||
IsArray: attr.IsArray,
|
||||
ArrayDim: attr.ArrayDim,
|
||||
SecurityClass: MapSecurity(attr.SecurityClassification),
|
||||
IsHistorized: attr.IsHistorized,
|
||||
IsAlarm: attr.IsAlarm));
|
||||
|
||||
// PR 15: when Galaxy flags the attribute as alarm-bearing (AlarmExtension
|
||||
// primitive), register an alarm-condition sink so the generic node manager
|
||||
// can route OnAlarmEvent payloads for this tag to the concrete address-space
|
||||
// builder. Severity default Medium — the live severity arrives through
|
||||
// AlarmEventArgs once MxAccessGalaxyBackend's tracker starts firing.
|
||||
if (attr.IsAlarm)
|
||||
{
|
||||
handle.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||
SourceName: fullName,
|
||||
InitialSeverity: AlarmSeverity.Medium,
|
||||
InitialDescription: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<ReadValuesRequest, ReadValuesResponse>(
|
||||
MessageKind.ReadValuesRequest,
|
||||
new ReadValuesRequest { SessionId = _sessionId, TagReferences = [.. fullReferences] },
|
||||
MessageKind.ReadValuesResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host ReadValues failed: {resp.Error}");
|
||||
|
||||
var byRef = resp.Values.ToDictionary(v => v.TagReference);
|
||||
var result = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
result[i] = byRef.TryGetValue(fullReferences[i], out var v)
|
||||
? ToSnapshot(v)
|
||||
: new DataValueSnapshot(null, StatusBadInternalError, null, DateTime.UtcNow);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<WriteValuesRequest, WriteValuesResponse>(
|
||||
MessageKind.WriteValuesRequest,
|
||||
new WriteValuesRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Writes = [.. writes.Select(FromWriteRequest)],
|
||||
},
|
||||
MessageKind.WriteValuesResponse,
|
||||
cancellationToken);
|
||||
|
||||
return [.. resp.Results.Select(r => new WriteResult(r.StatusCode))];
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<SubscribeRequest, SubscribeResponse>(
|
||||
MessageKind.SubscribeRequest,
|
||||
new SubscribeRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TagReferences = [.. fullReferences],
|
||||
RequestedIntervalMs = (int)publishingInterval.TotalMilliseconds,
|
||||
},
|
||||
MessageKind.SubscribeResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host Subscribe failed: {resp.Error}");
|
||||
|
||||
return new GalaxySubscriptionHandle(resp.SubscriptionId);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var sid = ((GalaxySubscriptionHandle)handle).SubscriptionId;
|
||||
await client.SendOneWayAsync(
|
||||
MessageKind.UnsubscribeRequest,
|
||||
new UnsubscribeRequest { SessionId = _sessionId, SubscriptionId = sid },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal entry point used by the IPC client when the Host pushes an
|
||||
/// <see cref="MessageKind.OnDataChangeNotification"/> frame. Surfaces it as a managed
|
||||
/// <see cref="OnDataChange"/> event.
|
||||
/// </summary>
|
||||
internal void RaiseDataChange(OnDataChangeNotification notif)
|
||||
{
|
||||
var handle = new GalaxySubscriptionHandle(notif.SubscriptionId);
|
||||
// ISubscribable.OnDataChange fires once per changed attribute — fan out the batch.
|
||||
foreach (var v in notif.Values)
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, v.TagReference, ToSnapshot(v)));
|
||||
}
|
||||
|
||||
// ---- IAlarmSource ----
|
||||
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
await client.SendOneWayAsync(
|
||||
MessageKind.AlarmSubscribeRequest,
|
||||
new AlarmSubscribeRequest { SessionId = _sessionId },
|
||||
cancellationToken);
|
||||
return new GalaxyAlarmSubscriptionHandle($"alarm-{_sessionId}");
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
foreach (var ack in acknowledgements)
|
||||
{
|
||||
await client.SendOneWayAsync(
|
||||
MessageKind.AlarmAckRequest,
|
||||
new AlarmAckRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
EventId = ack.ConditionId,
|
||||
Comment = ack.Comment ?? string.Empty,
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal void RaiseAlarmEvent(GalaxyAlarmEvent ev)
|
||||
{
|
||||
var handle = new GalaxyAlarmSubscriptionHandle($"alarm-{_sessionId}");
|
||||
OnAlarmEvent?.Invoke(this, new AlarmEventArgs(
|
||||
SubscriptionHandle: handle,
|
||||
SourceNodeId: ev.ObjectTagName,
|
||||
ConditionId: ev.EventId,
|
||||
AlarmType: ev.AlarmName,
|
||||
Message: ev.Message,
|
||||
Severity: MapSeverity(ev.Severity),
|
||||
SourceTimestampUtc: DateTimeOffset.FromUnixTimeMilliseconds(ev.UtcUnixMs).UtcDateTime));
|
||||
}
|
||||
|
||||
// ---- IHistoryProvider ----
|
||||
|
||||
public async Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<HistoryReadRequest, HistoryReadResponse>(
|
||||
MessageKind.HistoryReadRequest,
|
||||
new HistoryReadRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TagReferences = [fullReference],
|
||||
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
MaxValuesPerTag = maxValuesPerNode,
|
||||
},
|
||||
MessageKind.HistoryReadResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryRead failed: {resp.Error}");
|
||||
|
||||
var first = resp.Tags.FirstOrDefault();
|
||||
IReadOnlyList<DataValueSnapshot> samples = first is null
|
||||
? Array.Empty<DataValueSnapshot>()
|
||||
: [.. first.Values.Select(ToSnapshot)];
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public async Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var column = MapAggregateToColumn(aggregate);
|
||||
|
||||
var resp = await client.CallAsync<HistoryReadProcessedRequest, HistoryReadProcessedResponse>(
|
||||
MessageKind.HistoryReadProcessedRequest,
|
||||
new HistoryReadProcessedRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TagReference = fullReference,
|
||||
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
IntervalMs = (long)interval.TotalMilliseconds,
|
||||
AggregateColumn = column,
|
||||
},
|
||||
MessageKind.HistoryReadProcessedResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadProcessed failed: {resp.Error}");
|
||||
|
||||
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<HistoryReadAtTimeRequest, HistoryReadAtTimeResponse>(
|
||||
MessageKind.HistoryReadAtTimeRequest,
|
||||
new HistoryReadAtTimeRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TagReference = fullReference,
|
||||
TimestampsUtcUnixMs = [.. timestampsUtc.Select(t => new DateTimeOffset(t, TimeSpan.Zero).ToUnixTimeMilliseconds())],
|
||||
},
|
||||
MessageKind.HistoryReadAtTimeResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadAtTime failed: {resp.Error}");
|
||||
|
||||
// ReadAtTime returns one sample per requested timestamp in the same order — the Host
|
||||
// pads with bad-quality snapshots when a timestamp can't be interpolated, so response
|
||||
// length matches request length exactly. We trust that contract rather than
|
||||
// re-aligning here, because the Host is the source-of-truth for interpolation policy.
|
||||
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<HistoryReadEventsRequest, HistoryReadEventsResponse>(
|
||||
MessageKind.HistoryReadEventsRequest,
|
||||
new HistoryReadEventsRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
SourceName = sourceName,
|
||||
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
MaxEvents = maxEvents,
|
||||
},
|
||||
MessageKind.HistoryReadEventsResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadEvents failed: {resp.Error}");
|
||||
|
||||
IReadOnlyList<HistoricalEvent> events = [.. resp.Events.Select(ToHistoricalEvent)];
|
||||
return new HistoricalEventsResult(events, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
internal static HistoricalEvent ToHistoricalEvent(GalaxyHistoricalEvent wire) => new(
|
||||
EventId: wire.EventId,
|
||||
SourceName: wire.SourceName,
|
||||
EventTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.EventTimeUtcUnixMs).UtcDateTime,
|
||||
ReceivedTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.ReceivedTimeUtcUnixMs).UtcDateTime,
|
||||
Message: wire.DisplayText,
|
||||
Severity: wire.Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian
|
||||
/// AnalogSummaryQuery column names consumed by <c>HistorianDataSource.ReadAggregateAsync</c>.
|
||||
/// Kept on the Proxy side so Galaxy.Host stays OPC-UA-free.
|
||||
/// </summary>
|
||||
internal static string MapAggregateToColumn(HistoryAggregateType aggregate) => aggregate switch
|
||||
{
|
||||
HistoryAggregateType.Average => "Average",
|
||||
HistoryAggregateType.Minimum => "Minimum",
|
||||
HistoryAggregateType.Maximum => "Maximum",
|
||||
HistoryAggregateType.Count => "ValueCount",
|
||||
HistoryAggregateType.Total => throw new NotSupportedException(
|
||||
"HistoryAggregateType.Total is not supported by the Wonderware Historian AnalogSummary " +
|
||||
"query — use Average × Count on the caller side, or switch to Average/Minimum/Maximum/Count."),
|
||||
_ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"),
|
||||
};
|
||||
|
||||
// ---- IRediscoverable ----
|
||||
|
||||
/// <summary>
|
||||
/// Triggered by the IPC client when the Host pushes a deploy-watermark notification
|
||||
/// (Galaxy <c>time_of_last_deploy</c> changed per decision #54).
|
||||
/// </summary>
|
||||
internal void RaiseRediscoveryNeeded(string reason, string? scopeHint = null) =>
|
||||
OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, scopeHint));
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<Core.Abstractions.HostConnectivityStatus> GetHostStatuses() => _hostStatuses;
|
||||
|
||||
internal void OnHostConnectivityUpdate(IpcHostConnectivityStatus update)
|
||||
{
|
||||
var translated = new Core.Abstractions.HostConnectivityStatus(
|
||||
HostName: update.HostName,
|
||||
State: ParseHostState(update.RuntimeStatus),
|
||||
LastChangedUtc: DateTimeOffset.FromUnixTimeMilliseconds(update.LastObservedUtcUnixMs).UtcDateTime);
|
||||
|
||||
var prior = _hostStatuses.FirstOrDefault(h => h.HostName == translated.HostName);
|
||||
_hostStatuses = [
|
||||
.. _hostStatuses.Where(h => h.HostName != translated.HostName),
|
||||
translated
|
||||
];
|
||||
|
||||
if (prior is null || prior.State != translated.State)
|
||||
{
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(
|
||||
translated.HostName, prior?.State ?? HostState.Unknown, translated.State));
|
||||
}
|
||||
}
|
||||
|
||||
private static HostState ParseHostState(string s) => s switch
|
||||
{
|
||||
"Running" => HostState.Running,
|
||||
"Stopped" => HostState.Stopped,
|
||||
"Faulted" => HostState.Faulted,
|
||||
_ => HostState.Unknown,
|
||||
};
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Event-handler registered with <see cref="GalaxyIpcClient.SetEventHandler"/>. Decodes
|
||||
/// the MessagePack body into the matching wire contract and delegates to the existing
|
||||
/// <c>Raise*</c> helpers. Unknown kinds are silently ignored — the IPC contract is
|
||||
/// append-only, so a newer Host sending a kind this Proxy doesn't recognise shouldn't
|
||||
/// break the session.
|
||||
/// </summary>
|
||||
private Task DispatchHostEventAsync(MessageKind kind, byte[] body)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case MessageKind.OnDataChangeNotification:
|
||||
RaiseDataChange(MessagePackSerializer.Deserialize<OnDataChangeNotification>(body));
|
||||
break;
|
||||
case MessageKind.AlarmEvent:
|
||||
RaiseAlarmEvent(MessagePackSerializer.Deserialize<GalaxyAlarmEvent>(body));
|
||||
break;
|
||||
case MessageKind.HostConnectivityStatus:
|
||||
OnHostConnectivityUpdate(MessagePackSerializer.Deserialize<IpcHostConnectivityStatus>(body));
|
||||
break;
|
||||
case MessageKind.RuntimeStatusChange:
|
||||
var rsc = MessagePackSerializer.Deserialize<RuntimeStatusChangeNotification>(body);
|
||||
OnHostConnectivityUpdate(rsc.Status);
|
||||
break;
|
||||
// HistorianConnectivityStatus has no consumer on this Proxy today — drop.
|
||||
// Response kinds never reach the event handler; the client routes those to
|
||||
// their pending CallAsync TCS.
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private GalaxyIpcClient RequireClient() =>
|
||||
_client ?? throw new InvalidOperationException("Driver not initialized");
|
||||
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
|
||||
private static DataValueSnapshot ToSnapshot(GalaxyDataValue v) => new(
|
||||
Value: v.ValueBytes,
|
||||
StatusCode: v.StatusCode,
|
||||
SourceTimestampUtc: v.SourceTimestampUtcUnixMs > 0
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(v.SourceTimestampUtcUnixMs).UtcDateTime
|
||||
: null,
|
||||
ServerTimestampUtc: DateTimeOffset.FromUnixTimeMilliseconds(v.ServerTimestampUtcUnixMs).UtcDateTime);
|
||||
|
||||
private static GalaxyDataValue FromWriteRequest(WriteRequest w) => new()
|
||||
{
|
||||
TagReference = w.FullReference,
|
||||
ValueBytes = MessagePack.MessagePackSerializer.Serialize(w.Value),
|
||||
ValueMessagePackType = 0,
|
||||
StatusCode = 0,
|
||||
SourceTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
private static DriverDataType MapDataType(int mxDataType) => mxDataType switch
|
||||
{
|
||||
0 => DriverDataType.Boolean,
|
||||
1 => DriverDataType.Int32,
|
||||
2 => DriverDataType.Float32,
|
||||
3 => DriverDataType.Float64,
|
||||
4 => DriverDataType.String,
|
||||
5 => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
|
||||
private static SecurityClassification MapSecurity(int mxSec) => mxSec switch
|
||||
{
|
||||
0 => SecurityClassification.FreeAccess,
|
||||
1 => SecurityClassification.Operate,
|
||||
2 => SecurityClassification.SecuredWrite,
|
||||
3 => SecurityClassification.VerifiedWrite,
|
||||
4 => SecurityClassification.Tune,
|
||||
5 => SecurityClassification.Configure,
|
||||
6 => SecurityClassification.ViewOnly,
|
||||
_ => SecurityClassification.FreeAccess,
|
||||
};
|
||||
|
||||
private static AlarmSeverity MapSeverity(int sev) => sev switch
|
||||
{
|
||||
<= 250 => AlarmSeverity.Low,
|
||||
<= 500 => AlarmSeverity.Medium,
|
||||
<= 800 => AlarmSeverity.High,
|
||||
_ => AlarmSeverity.Critical,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up #247 — IAlarmHistorianWriter implementation. Forwards alarm
|
||||
/// batches to Galaxy.Host over the existing IPC channel, reusing the connection
|
||||
/// the driver already established for data-plane traffic. Throws
|
||||
/// <see cref="InvalidOperationException"/> when called before
|
||||
/// <see cref="InitializeAsync"/> has connected the client; the SQLite drain worker
|
||||
/// translates that to whole-batch RetryPlease per its catch contract.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_client is null)
|
||||
throw new InvalidOperationException(
|
||||
"GalaxyProxyDriver IPC client not connected — historian writes rejected until InitializeAsync completes");
|
||||
return new GalaxyHistorianWriter(_client).WriteBatchAsync(batch, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose() => _client?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
internal sealed record GalaxySubscriptionHandle(long SubscriptionId) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"galaxy-sub-{SubscriptionId}";
|
||||
}
|
||||
|
||||
internal sealed record GalaxyAlarmSubscriptionHandle(string Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => Id;
|
||||
}
|
||||
|
||||
public sealed class GalaxyProxyOptions
|
||||
{
|
||||
public required string DriverInstanceId { get; init; }
|
||||
public required string PipeName { get; init; }
|
||||
public required string SharedSecret { get; init; }
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="GalaxyProxyDriver"/>. Server's
|
||||
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper (task #248)
|
||||
/// then materialises Galaxy DriverInstance rows from the central config DB into live
|
||||
/// driver instances. No dependency on Microsoft.Extensions.DependencyInjection so the
|
||||
/// driver project stays free of DI machinery.
|
||||
/// </summary>
|
||||
public static class GalaxyProxyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "Galaxy";
|
||||
|
||||
/// <summary>
|
||||
/// Register the Galaxy driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||
/// Throws if 'Galaxy' is already registered — single-instance per process.
|
||||
/// </summary>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
// Galaxy is Tier C — out-of-process MXAccess Host, scheduled recycle is allowed.
|
||||
registry.Register(DriverTypeName, CreateInstance, DriverTier.C);
|
||||
}
|
||||
|
||||
internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
// DriverConfig column is a JSON object that mirrors GalaxyProxyOptions.
|
||||
// Required: PipeName, SharedSecret. Optional: ConnectTimeoutMs (defaults to 10s).
|
||||
// The DriverInstanceId from the row wins over any value in the JSON — the row
|
||||
// is the authoritative identity per the schema's UX_DriverInstance_Generation_LogicalId.
|
||||
using var doc = JsonDocument.Parse(driverConfigJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string pipeName = root.TryGetProperty("PipeName", out var p) && p.ValueKind == JsonValueKind.String
|
||||
? p.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required PipeName");
|
||||
string sharedSecret = root.TryGetProperty("SharedSecret", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required SharedSecret");
|
||||
var connectTimeout = root.TryGetProperty("ConnectTimeoutMs", out var t) && t.ValueKind == JsonValueKind.Number
|
||||
? TimeSpan.FromMilliseconds(t.GetInt32())
|
||||
: TimeSpan.FromSeconds(10);
|
||||
|
||||
return new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = driverInstanceId,
|
||||
PipeName = pipeName,
|
||||
SharedSecret = sharedSecret,
|
||||
ConnectTimeout = connectTimeout,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up (task #247) — bridges <see cref="SqliteStoreAndForwardSink"/>'s
|
||||
/// drain worker to <c>Driver.Galaxy.Host</c> over the existing <see cref="GalaxyIpcClient"/>
|
||||
/// pipe. Translates <see cref="AlarmHistorianEvent"/> batches into the
|
||||
/// <see cref="HistorianAlarmEventDto"/> wire format the Host expects + maps per-event
|
||||
/// <see cref="HistorianAlarmEventOutcomeDto"/> responses back to
|
||||
/// <see cref="HistorianWriteOutcome"/> so the SQLite queue knows what to ack /
|
||||
/// dead-letter / retry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Reuses the IPC channel <see cref="GalaxyProxyDriver"/> already opens for the
|
||||
/// Galaxy data plane — no second pipe to <c>Driver.Galaxy.Host</c>, no separate
|
||||
/// auth handshake. The IPC client's call gate serializes historian batches with
|
||||
/// driver Reads/Writes/Subscribes; historian batches are infrequent (every few
|
||||
/// seconds at most under the SQLite sink's drain cadence) so the contention is
|
||||
/// negligible compared to per-tag-read pressure.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pipe-level transport faults (broken pipe, host crash) bubble up as
|
||||
/// <see cref="GalaxyIpcException"/> which the SQLite sink's drain worker catches +
|
||||
/// translates to a whole-batch RetryPlease per the
|
||||
/// <see cref="SqliteStoreAndForwardSink"/> docstring — failed events stay queued
|
||||
/// for the next drain tick after backoff.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyHistorianWriter : IAlarmHistorianWriter
|
||||
{
|
||||
private readonly GalaxyIpcClient _client;
|
||||
|
||||
public GalaxyHistorianWriter(GalaxyIpcClient client)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(batch);
|
||||
if (batch.Count == 0) return [];
|
||||
|
||||
var request = new HistorianAlarmEventRequest
|
||||
{
|
||||
Events = batch.Select(ToDto).ToArray(),
|
||||
};
|
||||
|
||||
var response = await _client.CallAsync<HistorianAlarmEventRequest, HistorianAlarmEventResponse>(
|
||||
requestKind: MessageKind.HistorianAlarmEventRequest,
|
||||
request: request,
|
||||
expectedResponseKind: MessageKind.HistorianAlarmEventResponse,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Outcomes.Length != batch.Count)
|
||||
throw new InvalidOperationException(
|
||||
$"Galaxy.Host returned {response.Outcomes.Length} outcomes for a batch of {batch.Count} — protocol mismatch");
|
||||
|
||||
var outcomes = new HistorianWriteOutcome[response.Outcomes.Length];
|
||||
for (var i = 0; i < response.Outcomes.Length; i++)
|
||||
outcomes[i] = MapOutcome(response.Outcomes[i]);
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
internal static HistorianAlarmEventDto ToDto(AlarmHistorianEvent e) => new()
|
||||
{
|
||||
AlarmId = e.AlarmId,
|
||||
EquipmentPath = e.EquipmentPath,
|
||||
AlarmName = e.AlarmName,
|
||||
AlarmTypeName = e.AlarmTypeName,
|
||||
Severity = (int)e.Severity,
|
||||
EventKind = e.EventKind,
|
||||
Message = e.Message,
|
||||
User = e.User,
|
||||
Comment = e.Comment,
|
||||
TimestampUtcUnixMs = new DateTimeOffset(e.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
internal static HistorianWriteOutcome MapOutcome(HistorianAlarmEventOutcomeDto wire) => wire switch
|
||||
{
|
||||
HistorianAlarmEventOutcomeDto.Ack => HistorianWriteOutcome.Ack,
|
||||
HistorianAlarmEventOutcomeDto.RetryPlease => HistorianWriteOutcome.RetryPlease,
|
||||
HistorianAlarmEventOutcomeDto.PermanentFail => HistorianWriteOutcome.PermanentFail,
|
||||
_ => throw new InvalidOperationException($"Unknown HistorianAlarmEventOutcomeDto byte {(byte)wire}"),
|
||||
};
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
using System.IO.Pipes;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side IPC channel to a running <c>Driver.Galaxy.Host</c>. Owns the data-plane pipe
|
||||
/// connection, serializes request/response round-trips, and routes unsolicited push frames
|
||||
/// (<see cref="MessageKind.OnDataChangeNotification"/>, <see cref="MessageKind.AlarmEvent"/>,
|
||||
/// <see cref="MessageKind.HostConnectivityStatus"/>, <see cref="MessageKind.RuntimeStatusChange"/>,
|
||||
/// <see cref="MessageKind.HistorianConnectivityStatus"/>) to a handler supplied via
|
||||
/// <see cref="SetEventHandler"/>. One instance per session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A single background reader task owns the read side of the pipe. Calls are serialized by
|
||||
/// <see cref="_writeGate"/>, so at most one pending response is outstanding at a time — the
|
||||
/// reader uses a single pending-response slot. Any frame that doesn't match the pending
|
||||
/// expected kind (or <see cref="MessageKind.ErrorResponse"/>) is treated as a push event and
|
||||
/// forwarded to the registered handler. Without this router, a push event arriving between
|
||||
/// request and response would satisfy the caller's read and fail the next
|
||||
/// <see cref="CallAsync{TReq, TResp}"/> with an "Expected X, got Y" error.
|
||||
/// </remarks>
|
||||
public sealed class GalaxyIpcClient : IAsyncDisposable
|
||||
{
|
||||
private readonly NamedPipeClientStream _stream;
|
||||
private readonly FrameReader _reader;
|
||||
private readonly FrameWriter _writer;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly CancellationTokenSource _readerCts = new();
|
||||
|
||||
private readonly object _pendingLock = new();
|
||||
private TaskCompletionSource<(MessageKind Kind, byte[] Body)>? _pending;
|
||||
private MessageKind _pendingExpected;
|
||||
|
||||
private Task? _readerTask;
|
||||
private Func<MessageKind, byte[], Task>? _eventHandler;
|
||||
|
||||
private GalaxyIpcClient(NamedPipeClientStream stream)
|
||||
{
|
||||
_stream = stream;
|
||||
_reader = new FrameReader(stream, leaveOpen: true);
|
||||
_writer = new FrameWriter(stream, leaveOpen: true);
|
||||
}
|
||||
|
||||
/// <summary>Connects, sends Hello with the shared secret, and awaits HelloAck. Throws on rejection.</summary>
|
||||
public static async Task<GalaxyIpcClient> ConnectAsync(
|
||||
string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
|
||||
{
|
||||
var stream = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
|
||||
|
||||
var client = new GalaxyIpcClient(stream);
|
||||
try
|
||||
{
|
||||
await client._writer.WriteAsync(MessageKind.Hello,
|
||||
new Hello { PeerName = "Galaxy.Proxy", SharedSecret = sharedSecret }, ct);
|
||||
|
||||
// Hello/HelloAck is the one round-trip that runs inline before the reader loop
|
||||
// starts — the Host expects its response-side write before accepting any other
|
||||
// frames, so there's no push-event window to worry about here.
|
||||
var ack = await client._reader.ReadFrameAsync(ct);
|
||||
if (ack is null || ack.Value.Kind != MessageKind.HelloAck)
|
||||
throw new InvalidOperationException("Did not receive HelloAck from Galaxy.Host");
|
||||
|
||||
var ackMsg = FrameReader.Deserialize<HelloAck>(ack.Value.Body);
|
||||
if (!ackMsg.Accepted)
|
||||
throw new UnauthorizedAccessException($"Galaxy.Host rejected Hello: {ackMsg.RejectReason}");
|
||||
|
||||
client._readerTask = Task.Run(() => client.ReadLoopAsync(client._readerCts.Token));
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a handler that receives unsolicited push frames. Safe to call once per
|
||||
/// session — typically during the driver's <c>InitializeAsync</c> right after
|
||||
/// <see cref="ConnectAsync"/>. The handler is invoked on the reader's thread-pool
|
||||
/// task; it should not block. Exceptions thrown by the handler are swallowed so a
|
||||
/// buggy event subscriber cannot kill the reader loop.
|
||||
/// </summary>
|
||||
public void SetEventHandler(Func<MessageKind, byte[], Task> handler)
|
||||
=> _eventHandler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
|
||||
/// <summary>Round-trips a request and returns the deserialized response.</summary>
|
||||
public async Task<TResp> CallAsync<TReq, TResp>(
|
||||
MessageKind requestKind, TReq request, MessageKind expectedResponseKind, CancellationToken ct)
|
||||
{
|
||||
await _writeGate.WaitAsync(ct);
|
||||
var tcs = new TaskCompletionSource<(MessageKind, byte[])>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
lock (_pendingLock)
|
||||
{
|
||||
if (_pending is not null)
|
||||
throw new InvalidOperationException(
|
||||
"GalaxyIpcClient pending-response slot is not empty — call re-entry is a bug");
|
||||
_pending = tcs;
|
||||
_pendingExpected = expectedResponseKind;
|
||||
}
|
||||
|
||||
await _writer.WriteAsync(requestKind, request, ct);
|
||||
|
||||
using var reg = ct.Register(static s =>
|
||||
((TaskCompletionSource<(MessageKind, byte[])>)s!).TrySetCanceled(), tcs);
|
||||
var frame = await tcs.Task.ConfigureAwait(false);
|
||||
|
||||
if (frame.Item1 == MessageKind.ErrorResponse)
|
||||
{
|
||||
var err = MessagePackSerializer.Deserialize<ErrorResponse>(frame.Item2);
|
||||
throw new GalaxyIpcException(err.Code, err.Message);
|
||||
}
|
||||
|
||||
return MessagePackSerializer.Deserialize<TResp>(frame.Item2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_pendingLock)
|
||||
{
|
||||
if (ReferenceEquals(_pending, tcs)) _pending = null;
|
||||
}
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fire-and-forget request — used for unsubscribe, alarm-ack, close-session, and other
|
||||
/// calls where the protocol is one-way. The send is still serialized through the write
|
||||
/// gate so it doesn't interleave a frame with a concurrent <see cref="CallAsync{TReq, TResp}"/>.
|
||||
/// </summary>
|
||||
public async Task SendOneWayAsync<TReq>(MessageKind requestKind, TReq request, CancellationToken ct)
|
||||
{
|
||||
await _writeGate.WaitAsync(ct);
|
||||
try { await _writer.WriteAsync(requestKind, request, ct); }
|
||||
finally { _writeGate.Release(); }
|
||||
}
|
||||
|
||||
private async Task ReadLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
(MessageKind Kind, byte[] Body)? frame;
|
||||
try
|
||||
{
|
||||
var read = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
frame = read is null ? null : (read.Value.Kind, read.Value.Body);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
FailPending(ex);
|
||||
break;
|
||||
}
|
||||
|
||||
if (frame is null)
|
||||
{
|
||||
FailPending(new EndOfStreamException("IPC peer closed the pipe"));
|
||||
break;
|
||||
}
|
||||
|
||||
// Route: response-ish frame to pending TCS if one is waiting, else treat as event.
|
||||
// ErrorResponse always terminates a pending call — that's the Host signalling a
|
||||
// request-scoped failure. Unsolicited ErrorResponse with no pending call shouldn't
|
||||
// happen under a well-formed protocol; if it does, we drop it to the event channel
|
||||
// so it shows up in logs rather than deadlocking the next CallAsync.
|
||||
TaskCompletionSource<(MessageKind, byte[])>? pendingTcs = null;
|
||||
lock (_pendingLock)
|
||||
{
|
||||
if (_pending is not null && (frame.Value.Kind == _pendingExpected
|
||||
|| frame.Value.Kind == MessageKind.ErrorResponse))
|
||||
{
|
||||
pendingTcs = _pending;
|
||||
_pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTcs is not null)
|
||||
{
|
||||
pendingTcs.TrySetResult(frame.Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
var handler = _eventHandler;
|
||||
if (handler is null) continue;
|
||||
|
||||
try { await handler(frame.Value.Kind, frame.Value.Body).ConfigureAwait(false); }
|
||||
catch
|
||||
{
|
||||
// A buggy subscriber must not kill the reader. The handler is expected to
|
||||
// do its own logging; swallowing here keeps the channel alive for the next
|
||||
// frame + the next CallAsync.
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Any still-pending call after the loop exits would otherwise hang forever.
|
||||
FailPending(new EndOfStreamException("IPC reader loop exited"));
|
||||
}
|
||||
}
|
||||
|
||||
private void FailPending(Exception ex)
|
||||
{
|
||||
TaskCompletionSource<(MessageKind, byte[])>? tcs;
|
||||
lock (_pendingLock) { tcs = _pending; _pending = null; }
|
||||
tcs?.TrySetException(ex);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_readerCts.Cancel();
|
||||
if (_readerTask is not null)
|
||||
{
|
||||
try { await _readerTask.ConfigureAwait(false); } catch { /* shutdown */ }
|
||||
}
|
||||
|
||||
_writeGate.Dispose();
|
||||
_reader.Dispose();
|
||||
_writer.Dispose();
|
||||
_readerCts.Dispose();
|
||||
await _stream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GalaxyIpcException(string code, string message)
|
||||
: Exception($"[{code}] {message}")
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Respawn-with-backoff schedule per <c>driver-stability.md §"Crash-loop circuit breaker"</c>:
|
||||
/// 5s → 15s → 60s, capped. Reset on a successful (> <see cref="StableRunThreshold"/>)
|
||||
/// run.
|
||||
/// </summary>
|
||||
public sealed class Backoff
|
||||
{
|
||||
public static TimeSpan[] DefaultSequence { get; } =
|
||||
[TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)];
|
||||
|
||||
public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly TimeSpan[] _sequence;
|
||||
private int _index;
|
||||
|
||||
public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence;
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
var delay = _sequence[Math.Min(_index, _sequence.Length - 1)];
|
||||
_index++;
|
||||
return delay;
|
||||
}
|
||||
|
||||
/// <summary>Called when the spawned process has stayed up past the stable threshold.</summary>
|
||||
public void RecordStableRun() => _index = 0;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Crash-loop circuit breaker per <c>driver-stability.md</c>:
|
||||
/// 3 crashes within 5 min → open with escalating cooldown 1h → 4h → 24h manual. A sticky
|
||||
/// alert stays until the operator explicitly resets.
|
||||
/// </summary>
|
||||
public sealed class CircuitBreaker
|
||||
{
|
||||
public int CrashesAllowedPerWindow { get; init; } = 3;
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan[] CooldownEscalation { get; init; } =
|
||||
[TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue];
|
||||
|
||||
private readonly List<DateTime> _crashesUtc = [];
|
||||
private DateTime? _openSinceUtc;
|
||||
private int _escalationLevel;
|
||||
public bool StickyAlertActive { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Called by the supervisor each time the host process exits unexpectedly. Returns
|
||||
/// <c>false</c> when the breaker is open — supervisor must not respawn.
|
||||
/// </summary>
|
||||
public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining)
|
||||
{
|
||||
if (_openSinceUtc is { } openedAt)
|
||||
{
|
||||
var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
if (cooldown == TimeSpan.MaxValue)
|
||||
{
|
||||
cooldownRemaining = TimeSpan.MaxValue;
|
||||
return false; // manual reset required
|
||||
}
|
||||
if (utcNow - openedAt < cooldown)
|
||||
{
|
||||
cooldownRemaining = cooldown - (utcNow - openedAt);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cooldown elapsed — close the breaker but keep the sticky alert per spec.
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel++;
|
||||
}
|
||||
|
||||
_crashesUtc.RemoveAll(t => utcNow - t > Window);
|
||||
_crashesUtc.Add(utcNow);
|
||||
|
||||
if (_crashesUtc.Count > CrashesAllowedPerWindow)
|
||||
{
|
||||
_openSinceUtc = utcNow;
|
||||
StickyAlertActive = true;
|
||||
cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
return false;
|
||||
}
|
||||
|
||||
cooldownRemaining = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ManualReset()
|
||||
{
|
||||
_crashesUtc.Clear();
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel = 0;
|
||||
StickyAlertActive = false;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks missed heartbeats on the dedicated heartbeat pipe per
|
||||
/// <c>driver-stability.md §"Heartbeat between proxy and host"</c>: 2s cadence, 3 consecutive
|
||||
/// misses = host declared dead (~6s detection).
|
||||
/// </summary>
|
||||
public sealed class HeartbeatMonitor
|
||||
{
|
||||
public int MissesUntilDead { get; init; } = 3;
|
||||
|
||||
public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
public int ConsecutiveMisses { get; private set; }
|
||||
public DateTime? LastAckUtc { get; private set; }
|
||||
|
||||
public void RecordAck(DateTime utcNow)
|
||||
{
|
||||
ConsecutiveMisses = 0;
|
||||
LastAckUtc = utcNow;
|
||||
}
|
||||
|
||||
public bool RecordMiss()
|
||||
{
|
||||
ConsecutiveMisses++;
|
||||
return ConsecutiveMisses >= MissesUntilDead;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<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.Galaxy.Proxy</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.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,32 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AlarmSubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyAlarmEvent
|
||||
{
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
[Key(1)] public string ObjectTagName { get; set; } = string.Empty;
|
||||
[Key(2)] public string AlarmName { get; set; } = string.Empty;
|
||||
[Key(3)] public int Severity { get; set; }
|
||||
|
||||
/// <summary>Per OPC UA Part 9 lifecycle: Active, Unacknowledged, Confirmed, Inactive, etc.</summary>
|
||||
[Key(4)] public string StateTransition { get; set; } = string.Empty;
|
||||
|
||||
[Key(5)] public string Message { get; set; } = string.Empty;
|
||||
[Key(6)] public long UtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AlarmAckRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string EventId { get; set; } = string.Empty;
|
||||
[Key(2)] public string Comment { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// IPC-shape for a tag value snapshot. Per decision #13: value + StatusCode + source + server timestamps.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyDataValue
|
||||
{
|
||||
[Key(0)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(1)] public byte[]? ValueBytes { get; set; }
|
||||
[Key(2)] public int ValueMessagePackType { get; set; }
|
||||
[Key(3)] public uint StatusCode { get; set; }
|
||||
[Key(4)] public long SourceTimestampUtcUnixMs { get; set; }
|
||||
[Key(5)] public long ServerTimestampUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadValuesRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadValuesResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteValuesRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public GalaxyDataValue[] Writes { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteValueResult
|
||||
{
|
||||
[Key(0)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(1)] public uint StatusCode { get; set; }
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteValuesResponse
|
||||
{
|
||||
[Key(0)] public WriteValueResult[] Results { get; set; } = System.Array.Empty<WriteValueResult>();
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DiscoverHierarchyRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IPC-shape for a Galaxy object. Proxy maps to/from <c>DriverAttributeInfo</c> (Core.Abstractions).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyObjectInfo
|
||||
{
|
||||
[Key(0)] public string ContainedName { get; set; } = string.Empty;
|
||||
[Key(1)] public string TagName { get; set; } = string.Empty;
|
||||
[Key(2)] public string? ParentContainedName { get; set; }
|
||||
[Key(3)] public string TemplateCategory { get; set; } = string.Empty;
|
||||
[Key(4)] public GalaxyAttributeInfo[] Attributes { get; set; } = System.Array.Empty<GalaxyAttributeInfo>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyAttributeInfo
|
||||
{
|
||||
[Key(0)] public string AttributeName { get; set; } = string.Empty;
|
||||
[Key(1)] public int MxDataType { get; set; }
|
||||
[Key(2)] public bool IsArray { get; set; }
|
||||
[Key(3)] public uint? ArrayDim { get; set; }
|
||||
[Key(4)] public int SecurityClassification { get; set; }
|
||||
[Key(5)] public bool IsHistorized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the attribute has an AlarmExtension primitive in the Galaxy repository
|
||||
/// (<c>primitive_definition.primitive_name = 'AlarmExtension'</c>). The generic
|
||||
/// node-manager uses this to enrich the variable's OPC UA node with an
|
||||
/// <c>AlarmConditionState</c> during address-space build. Added in PR 9 as the
|
||||
/// discovery-side foundation for the alarm event wire-up that follows in PR 10+.
|
||||
/// </summary>
|
||||
[Key(6)] public bool IsAlarm { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DiscoverHierarchyResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyObjectInfo[] Objects { get; set; } = System.Array.Empty<GalaxyObjectInfo>();
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Length-prefixed framing per decision #28. Each IPC frame is:
|
||||
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
|
||||
/// Length is the body size only; the kind byte is not part of the prefixed length.
|
||||
/// </summary>
|
||||
public static class Framing
|
||||
{
|
||||
public const int LengthPrefixSize = 4;
|
||||
public const int KindByteSize = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
|
||||
/// misbehaving peer sending an oversized length prefix.
|
||||
/// </summary>
|
||||
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire identifier for each contract. Values are stable — new contracts append.
|
||||
/// </summary>
|
||||
public enum MessageKind : byte
|
||||
{
|
||||
Hello = 0x01,
|
||||
HelloAck = 0x02,
|
||||
Heartbeat = 0x03,
|
||||
HeartbeatAck = 0x04,
|
||||
|
||||
OpenSessionRequest = 0x10,
|
||||
OpenSessionResponse = 0x11,
|
||||
CloseSessionRequest = 0x12,
|
||||
|
||||
DiscoverHierarchyRequest = 0x20,
|
||||
DiscoverHierarchyResponse = 0x21,
|
||||
|
||||
ReadValuesRequest = 0x30,
|
||||
ReadValuesResponse = 0x31,
|
||||
WriteValuesRequest = 0x32,
|
||||
WriteValuesResponse = 0x33,
|
||||
|
||||
SubscribeRequest = 0x40,
|
||||
SubscribeResponse = 0x41,
|
||||
UnsubscribeRequest = 0x42,
|
||||
OnDataChangeNotification = 0x43,
|
||||
|
||||
AlarmSubscribeRequest = 0x50,
|
||||
AlarmEvent = 0x51,
|
||||
AlarmAckRequest = 0x52,
|
||||
|
||||
HistoryReadRequest = 0x60,
|
||||
HistoryReadResponse = 0x61,
|
||||
HistoryReadProcessedRequest = 0x62,
|
||||
HistoryReadProcessedResponse = 0x63,
|
||||
HistoryReadAtTimeRequest = 0x64,
|
||||
HistoryReadAtTimeResponse = 0x65,
|
||||
HistoryReadEventsRequest = 0x66,
|
||||
HistoryReadEventsResponse = 0x67,
|
||||
|
||||
HostConnectivityStatus = 0x70,
|
||||
RuntimeStatusChange = 0x71,
|
||||
|
||||
// Phase 7 Stream D — historian alarm sink. Main server → Galaxy.Host batched
|
||||
// writes into the Aveva Historian alarm schema via the already-loaded
|
||||
// aahClientManaged DLLs. HistorianConnectivityStatus fires proactively from the
|
||||
// Host when the SDK session transitions so diagnostics flip promptly.
|
||||
HistorianAlarmEventRequest = 0x80,
|
||||
HistorianAlarmEventResponse = 0x81,
|
||||
HistorianConnectivityStatus = 0x82,
|
||||
|
||||
RecycleHostRequest = 0xF0,
|
||||
RecycleStatusResponse = 0xF1,
|
||||
|
||||
ErrorResponse = 0xFE,
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// First frame of every connection. Advertises protocol major/minor and the peer's feature set.
|
||||
/// Major mismatch is fatal; minor is advisory. Per Task A.3.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class Hello
|
||||
{
|
||||
public const int CurrentMajor = 1;
|
||||
public const int CurrentMinor = 0;
|
||||
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
|
||||
[Key(2)] public string PeerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-process shared secret — verified on the Host side against the value passed by the supervisor at spawn time.</summary>
|
||||
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
|
||||
|
||||
[Key(4)] public string[] Features { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HelloAck
|
||||
{
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
|
||||
|
||||
/// <summary>True if the server accepted the hello; false + <see cref="RejectReason"/> filled if not.</summary>
|
||||
[Key(2)] public bool Accepted { get; set; }
|
||||
[Key(3)] public string? RejectReason { get; set; }
|
||||
|
||||
[Key(4)] public string HostName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using System;
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 Stream D — IPC contracts for routing Part 9 alarm transitions from the
|
||||
/// main .NET 10 server into Galaxy.Host's already-loaded <c>aahClientManaged</c>
|
||||
/// DLLs. Reuses the Tier-C isolation + licensing pathway rather than loading 32-bit
|
||||
/// native historian code into the main server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Batched on the wire to amortize IPC overhead — the main server's SqliteStoreAndForwardSink
|
||||
/// ships up to 100 events per request per Phase 7 plan Stream D.5.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per-event outcomes (Ack / RetryPlease / PermanentFail) let the drain worker
|
||||
/// dead-letter malformed events without blocking neighbors in the batch.
|
||||
/// <see cref="HistorianConnectivityStatusNotification"/> fires proactively from
|
||||
/// the Host when the SDK session drops so the /hosts + /alarms/historian Admin
|
||||
/// diagnostics pages flip to red promptly instead of waiting for the next
|
||||
/// drain cycle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAlarmEventRequest
|
||||
{
|
||||
[Key(0)] public HistorianAlarmEventDto[] Events { get; set; } = Array.Empty<HistorianAlarmEventDto>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAlarmEventResponse
|
||||
{
|
||||
/// <summary>Per-event outcome, same order as the request.</summary>
|
||||
[Key(0)] public HistorianAlarmEventOutcomeDto[] Outcomes { get; set; } = Array.Empty<HistorianAlarmEventOutcomeDto>();
|
||||
}
|
||||
|
||||
/// <summary>Outcome enum — bytes on the wire so it stays compact.</summary>
|
||||
public enum HistorianAlarmEventOutcomeDto : byte
|
||||
{
|
||||
/// <summary>Successfully persisted to the historian — remove from queue.</summary>
|
||||
Ack = 0,
|
||||
/// <summary>Transient failure (historian disconnected, timeout, busy) — retry after backoff.</summary>
|
||||
RetryPlease = 1,
|
||||
/// <summary>Permanent failure (malformed, unrecoverable SDK error) — move to dead-letter.</summary>
|
||||
PermanentFail = 2,
|
||||
}
|
||||
|
||||
/// <summary>One alarm-transition payload. Fields mirror <c>Core.AlarmHistorian.AlarmHistorianEvent</c>.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAlarmEventDto
|
||||
{
|
||||
[Key(0)] public string AlarmId { get; set; } = string.Empty;
|
||||
[Key(1)] public string EquipmentPath { get; set; } = string.Empty;
|
||||
[Key(2)] public string AlarmName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Concrete Part 9 subtype name — "LimitAlarm" / "OffNormalAlarm" / "AlarmCondition" / "DiscreteAlarm".</summary>
|
||||
[Key(3)] public string AlarmTypeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Numeric severity the Host maps to the historian's priority scale.</summary>
|
||||
[Key(4)] public int Severity { get; set; }
|
||||
|
||||
/// <summary>Which transition this event represents — "Activated" / "Cleared" / "Acknowledged" / etc.</summary>
|
||||
[Key(5)] public string EventKind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Pre-rendered message — template tokens resolved upstream.</summary>
|
||||
[Key(6)] public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Operator who triggered the transition. "system" for engine-driven events.</summary>
|
||||
[Key(7)] public string User { get; set; } = "system";
|
||||
|
||||
/// <summary>Operator-supplied free-form comment, if any.</summary>
|
||||
[Key(8)] public string? Comment { get; set; }
|
||||
|
||||
/// <summary>Source timestamp (UTC Unix milliseconds).</summary>
|
||||
[Key(9)] public long TimestampUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proactive notification — Galaxy.Host pushes this when the historian SDK session
|
||||
/// transitions (connected / disconnected / degraded). The main server reflects this
|
||||
/// into the historian sink status so Admin UI surfaces the problem without the
|
||||
/// operator having to scrutinize drain cadence.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianConnectivityStatusNotification
|
||||
{
|
||||
[Key(0)] public string Status { get; set; } = "unknown"; // connected | disconnected | degraded
|
||||
[Key(1)] public string? Detail { get; set; }
|
||||
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
|
||||
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||
[Key(4)] public uint MaxValuesPerTag { get; set; } = 1000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryTagValues
|
||||
{
|
||||
[Key(0)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty<HistoryTagValues>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processed (aggregated) historian read — OPC UA HistoryReadProcessed service. The
|
||||
/// aggregate column is a string (e.g. "Average", "Minimum") mapped by the Proxy from the
|
||||
/// OPC UA HistoryAggregateType enum so Galaxy.Host stays OPC-UA-free.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadProcessedRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||
[Key(4)] public long IntervalMs { get; set; }
|
||||
[Key(5)] public string AggregateColumn { get; set; } = "Average";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadProcessedResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At-time historian read — OPC UA HistoryReadAtTime service. Returns one sample per
|
||||
/// requested timestamp (interpolated when no exact match exists). The per-timestamp array
|
||||
/// is flow-encoded as Unix milliseconds to avoid MessagePack DateTime quirks.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadAtTimeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(2)] public long[] TimestampsUtcUnixMs { get; set; } = System.Array.Empty<long>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadAtTimeResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historical events read — OPC UA HistoryReadEvents service and Alarm & Condition
|
||||
/// history. <c>SourceName</c> null means "all sources". Distinct from the live
|
||||
/// <see cref="GalaxyAlarmEvent"/> stream because historical rows carry both
|
||||
/// <c>EventTime</c> (when the event occurred in the process) and <c>ReceivedTime</c>
|
||||
/// (when the Historian persisted it) and have no StateTransition — the Historian logs
|
||||
/// the instantaneous event, not the OPC UA alarm lifecycle.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadEventsRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string? SourceName { get; set; }
|
||||
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||
[Key(4)] public int MaxEvents { get; set; } = 1000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyHistoricalEvent
|
||||
{
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
[Key(1)] public string? SourceName { get; set; }
|
||||
[Key(2)] public long EventTimeUtcUnixMs { get; set; }
|
||||
[Key(3)] public long ReceivedTimeUtcUnixMs { get; set; }
|
||||
[Key(4)] public string? DisplayText { get; set; }
|
||||
[Key(5)] public ushort Severity { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadEventsResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyHistoricalEvent[] Events { get; set; } = System.Array.Empty<GalaxyHistoricalEvent>();
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OpenSessionRequest
|
||||
{
|
||||
[Key(0)] public string DriverInstanceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JSON blob sourced from <c>DriverInstance.DriverConfig</c>.</summary>
|
||||
[Key(1)] public string DriverConfigJson { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OpenSessionResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CloseSessionRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Heartbeat
|
||||
{
|
||||
[Key(0)] public long SequenceNumber { get; set; }
|
||||
[Key(1)] public long UtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HeartbeatAck
|
||||
{
|
||||
[Key(0)] public long SequenceNumber { get; set; }
|
||||
[Key(1)] public long UtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ErrorResponse
|
||||
{
|
||||
[Key(0)] public string Code { get; set; } = string.Empty;
|
||||
[Key(1)] public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>Per-host runtime status — per <c>driver-stability.md</c> Galaxy §"Connection Health Probe".</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HostConnectivityStatus
|
||||
{
|
||||
[Key(0)] public string HostName { get; set; } = string.Empty;
|
||||
[Key(1)] public string RuntimeStatus { get; set; } = string.Empty; // Running | Stopped | Unknown
|
||||
[Key(2)] public long LastObservedUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RuntimeStatusChangeNotification
|
||||
{
|
||||
[Key(0)] public HostConnectivityStatus Status { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RecycleHostRequest
|
||||
{
|
||||
/// <summary>One of: Soft, Hard.</summary>
|
||||
[Key(0)] public string Kind { get; set; } = "Soft";
|
||||
[Key(1)] public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RecycleStatusResponse
|
||||
{
|
||||
[Key(0)] public bool Accepted { get; set; }
|
||||
[Key(1)] public int GraceSeconds { get; set; } = 15;
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
|
||||
[Key(2)] public int RequestedIntervalMs { get; set; } = 1000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SubscribeResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public long SubscriptionId { get; set; }
|
||||
[Key(3)] public int ActualIntervalMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class UnsubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public long SubscriptionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OnDataChangeNotification
|
||||
{
|
||||
[Key(0)] public long SubscriptionId { get; set; }
|
||||
[Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
|
||||
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance.
|
||||
/// </summary>
|
||||
public sealed class FrameReader : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
public FrameReader(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
|
||||
return null; // clean EOF on frame boundary
|
||||
|
||||
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
|
||||
if (length < 0 || length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidDataException($"IPC frame length {length} out of range.");
|
||||
|
||||
var kindByte = _stream.ReadByte();
|
||||
if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
|
||||
|
||||
var body = new byte[length];
|
||||
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
|
||||
throw new EndOfStreamException("EOF mid-frame.");
|
||||
|
||||
return ((MessageKind)(byte)kindByte, body);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||
|
||||
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
if (offset == 0) return false;
|
||||
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
|
||||
/// <see cref="SemaphoreSlim"/> — multiple producers (e.g. heartbeat + data-plane sharing a stream)
|
||||
/// get serialized writes.
|
||||
/// </summary>
|
||||
public sealed class FrameWriter : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
public FrameWriter(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public async Task WriteAsync<T>(MessageKind kind, T message, CancellationToken ct)
|
||||
{
|
||||
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
|
||||
if (body.Length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
|
||||
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
// Big-endian — easy to read in hex dumps.
|
||||
lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
|
||||
lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
|
||||
lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
|
||||
lengthPrefix[3] = (byte)( body.Length & 0xFF);
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
|
||||
_stream.WriteByte((byte)kind);
|
||||
await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gate.Dispose();
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Decision #32: MessagePack for IPC. Netstandard 2.0 consumable by both .NET 10 (Proxy) + .NET 4.8 (Host). -->
|
||||
<PackageReference Include="MessagePack" Version="2.5.187"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -14,7 +14,6 @@ using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
@@ -110,12 +109,10 @@ builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
// Both Galaxy backends register side-by-side under distinct DriverType names
|
||||
// ("Galaxy" → legacy GalaxyProxyDriver, "GalaxyMxGateway" → in-process GalaxyDriver
|
||||
// over the gRPC mxaccessgw). The DriverInstance row's DriverType selects between
|
||||
// them at bootstrap time — see lmx_mxgw.md / PR 4.W. Phase 7 retires the legacy
|
||||
// factory once parity tests pin both.
|
||||
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
||||
// Galaxy access flows through the in-process GalaxyDriver (DriverType =
|
||||
// "GalaxyMxGateway") talking gRPC to the mxaccessgw worker. The legacy
|
||||
// out-of-process GalaxyProxyDriver retired in PR 7.2 once the parity matrix
|
||||
// (docs/v2/Galaxy.ParityMatrix.md) verified equivalence.
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry);
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
ModbusDriverFactoryExtensions.Register(registry);
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
|
||||
Reference in New Issue
Block a user