feat(worker): acknowledge alarms via authenticated AckMsg write
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 56s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
Joseph Doherty
2026-10-19 19:21:55 -04:00
parent 64dc1215fd
commit 76db822df3
7 changed files with 566 additions and 0 deletions
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Operator identity an alarm acknowledgement authenticates as. The password
/// is a raw credential: it is passed to MXAccess <c>AuthenticateUser</c> only
/// and must never be logged or echoed.
/// </summary>
public sealed class AlarmAckOperator
{
/// <summary>Gets or sets the operator user name.</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>Gets or sets the optional domain for OS-authenticated galaxies.</summary>
public string Domain { get; set; } = string.Empty;
/// <summary>Gets or sets the operator password (credential-sensitive).</summary>
public string Password { get; set; } = string.Empty;
/// <summary>Gets the user string passed to <c>AuthenticateUser</c>: <c>Domain\UserName</c> when a domain is set.</summary>
public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName;
/// <inheritdoc />
public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")";
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Outcome of an alarm acknowledgement attempt.</summary>
public enum AlarmAckOutcome
{
/// <summary>The AckMsg write completed with an OK status.</summary>
Applied,
/// <summary>The write was sent but no OnWriteComplete arrived before the timeout.</summary>
Unconfirmed,
/// <summary>The alarm is not in the consumer's current view.</summary>
AlarmNotFound,
/// <summary>The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag).</summary>
NotAckable,
/// <summary>AuthenticateUser rejected the operator credentials.</summary>
AuthenticationFailed,
/// <summary>The AckMsg write completed with a non-OK status.</summary>
WriteRejected,
}
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Result of an alarm acknowledgement.</summary>
public sealed class AlarmAckResult
{
private static readonly IReadOnlyList<MxStatusProxy> NoStatuses = new MxStatusProxy[0];
private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList<MxStatusProxy>? statuses)
{
Outcome = outcome;
NativeStatus = nativeStatus;
Diagnostic = diagnostic;
Statuses = statuses ?? NoStatuses;
}
/// <summary>Gets the outcome.</summary>
public AlarmAckOutcome Outcome { get; }
/// <summary>Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail.</summary>
public int NativeStatus { get; }
/// <summary>Gets a diagnostic message. Never contains credentials.</summary>
public string Diagnostic { get; }
/// <summary>Gets the OnWriteComplete statuses, empty when none was observed.</summary>
public IReadOnlyList<MxStatusProxy> Statuses { get; }
/// <summary>Creates a result.</summary>
/// <param name="outcome">The outcome.</param>
/// <param name="nativeStatus">The native status.</param>
/// <param name="diagnostic">A credential-free diagnostic.</param>
/// <param name="statuses">Observed write statuses.</param>
/// <returns>The result.</returns>
public static AlarmAckResult Create(
AlarmAckOutcome outcome,
int nativeStatus,
string diagnostic,
IReadOnlyList<MxStatusProxy>? statuses = null) =>
new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses);
/// <summary>Creates the result for an unresolved ack target.</summary>
/// <param name="target">The unresolved target.</param>
/// <returns>The result.</returns>
public static AlarmAckResult FromTarget(AlarmAckTarget target) =>
new AlarmAckResult(target.Failure, -1, target.Reason, null);
}
@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>The MXAccess item an acknowledgement writes, or why none exists.</summary>
public sealed class AlarmAckTarget
{
private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason)
{
ItemAddress = itemAddress;
Failure = failure;
Reason = reason;
}
/// <summary>Gets a value indicating whether an ack item was resolved.</summary>
public bool Found => ItemAddress.Length > 0;
/// <summary>Gets the ack item address (e.g. <c>TestMachine_001.TestAlarm001.AckMsg</c>), empty when not found.</summary>
public string ItemAddress { get; }
/// <summary>Gets the failure outcome when <see cref="Found"/> is false.</summary>
public AlarmAckOutcome Failure { get; }
/// <summary>Gets a human-readable reason when <see cref="Found"/> is false.</summary>
public string Reason { get; }
/// <summary>Creates a resolved target.</summary>
/// <param name="itemAddress">The ack item address.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget For(string itemAddress) =>
new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty);
/// <summary>Creates a not-found result.</summary>
/// <param name="reason">Why the alarm was not found.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotFound(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason);
/// <summary>Creates a not-ackable result.</summary>
/// <param name="reason">Why the alarm cannot be acknowledged over MXAccess.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotAckable(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason);
}
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ArchestrA.MxAccess;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges alarms the way the AVEVA runtime records operator identity:
/// AuthenticateUser as the operator, then write the comment to the alarm's
/// <c>AckMsg</c> attribute with that user id. Owns its own
/// <c>LMXProxyServerClass</c> registered under the configured client name,
/// which the Historian records as the acknowledgement's OperatorNode.
/// Apartment-threaded: every call must run on the worker STA. The password
/// is passed to MXAccess only; it never reaches diagnostics.
/// </summary>
public sealed class AlarmAckWriter : IAlarmAckWriter
{
/// <summary>The ArchestrA alarm-primitive attribute whose write acknowledges the alarm.</summary>
public const string AckCommentAttribute = "AckMsg";
/// <summary>Default MXAccess client name when none is configured.</summary>
public const string DefaultClientName = "MxGateway";
private readonly IMxAccessComObjectFactory? factory;
private readonly MxAccessWriteCompletionCache completionCache;
private readonly Action pumpStep;
private readonly TimeSpan completionTimeout;
private readonly string clientName;
private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter();
private readonly Dictionary<string, int> itemHandles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
private object? comObject;
private LMXProxyServerClass? comEventSource;
private IMxAccessServer? server;
private int serverHandle;
private bool disposed;
/// <summary>Creates a writer that registers its own MXAccess server on first use.</summary>
/// <param name="factory">Creates the <c>LMXProxyServerClass</c>.</param>
/// <param name="pumpStep">Drains the STA message queue (OnWriteComplete is delivered by pumping).</param>
/// <param name="completionTimeout">Bounded wait for OnWriteComplete.</param>
/// <param name="clientName">MXAccess client name (Historian OperatorNode).</param>
public AlarmAckWriter(
IMxAccessComObjectFactory factory,
Action pumpStep,
TimeSpan completionTimeout,
string? clientName)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
this.completionTimeout = completionTimeout;
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
completionCache = new MxAccessWriteCompletionCache();
}
/// <summary>Test seam: uses an already-constructed server and completion cache.</summary>
internal AlarmAckWriter(
IMxAccessServer server,
MxAccessWriteCompletionCache completionCache,
Action pumpStep,
TimeSpan completionTimeout,
string clientName)
{
this.server = server ?? throw new ArgumentNullException(nameof(server));
this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache));
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
this.completionTimeout = completionTimeout;
this.clientName = clientName;
serverHandle = server.Register(clientName);
}
/// <summary>Resolves the configured client name from the launcher environment.</summary>
/// <returns>The client name, or <see cref="DefaultClientName"/>.</returns>
public static string ResolveClientName()
{
string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName);
return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim();
}
/// <inheritdoc />
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter));
if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress));
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
IMxAccessServer mx = EnsureRegistered();
int userId;
try
{
userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty);
}
catch (COMException ex)
{
return AlarmAckResult.Create(
AlarmAckOutcome.AuthenticationFailed,
ex.HResult,
$"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8}).");
}
if (userId == 0)
{
return AlarmAckResult.Create(
AlarmAckOutcome.AuthenticationFailed,
-1,
$"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned).");
}
if (!itemHandles.TryGetValue(itemAddress, out int itemHandle))
{
itemHandle = mx.AddItem(serverHandle, itemAddress);
// MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
mx.Advise(serverHandle, itemHandle);
itemHandles[itemAddress] = itemHandle;
}
pumpStep();
ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle);
mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId);
if (!completionCache.TryWaitForCompletion(
serverHandle,
itemHandle,
baseline,
DateTime.UtcNow + completionTimeout,
pumpStep,
out RepeatedField<MxStatusProxy> statuses))
{
return AlarmAckResult.Create(
AlarmAckOutcome.Unconfirmed,
0,
$"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms.");
}
foreach (MxStatusProxy status in statuses)
{
if (status.Category != MxStatusCategory.Ok)
{
return AlarmAckResult.Create(
AlarmAckOutcome.WriteRejected,
status.Detail != 0 ? status.Detail : -1,
$"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.",
statuses);
}
}
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
}
IMxAccessServer? mx = server;
if (mx is not null)
{
foreach (int itemHandle in itemHandles.Values)
{
try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
}
try { mx.Unregister(serverHandle); } catch { /* best effort */ }
}
itemHandles.Clear();
server = null;
comEventSource = null;
object? toRelease = comObject;
comObject = null;
if (toRelease is not null && Marshal.IsComObject(toRelease))
{
try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ }
}
}
private IMxAccessServer EnsureRegistered()
{
if (server is not null) return server;
object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null.");
try
{
comObject = created;
if (created is LMXProxyServerClass comServer)
{
comEventSource = comServer;
comServer.OnWriteComplete += OnWriteComplete;
}
IMxAccessServer mx = new MxAccessComServer(created);
serverHandle = mx.Register(clientName);
server = mx;
return mx;
}
catch
{
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
comEventSource = null;
}
if (Marshal.IsComObject(created))
{
try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ }
}
comObject = null;
throw;
}
}
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars)
{
RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
rows.AddRange(statusConverter.ConvertMany(pVars));
completionCache.Record(hLMXServerHandle, phItemHandle, rows);
}
}
@@ -0,0 +1,17 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges an alarm by writing its MXAccess ack item (<c>AckMsg</c>)
/// after authenticating as the operator. Must be called on the worker STA.
/// </summary>
public interface IAlarmAckWriter : IDisposable
{
/// <summary>Authenticates <paramref name="ackOperator"/> and writes <paramref name="comment"/> to <paramref name="itemAddress"/>.</summary>
/// <param name="itemAddress">The ack item, e.g. <c>Obj.Alarm.AckMsg</c>.</param>
/// <param name="comment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator);
}