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:
Joseph Doherty
2026-04-30 08:01:19 -04:00
parent 6bf147a113
commit fe91d42927
117 changed files with 115 additions and 11754 deletions

View File

@@ -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>&lt;alarmTag&gt;.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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>&lt;TagName&gt;.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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"/>