feat(worker): acknowledge alarms via authenticated AckMsg write
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using Google.Protobuf.Collections;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
public sealed class AlarmAckWriterTests
|
||||
{
|
||||
private static AlarmAckOperator Alice() =>
|
||||
new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" };
|
||||
|
||||
private static RepeatedField<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
|
||||
new RepeatedField<MxStatusProxy> { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } };
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
||||
Assert.Equal(0, result.NativeStatus);
|
||||
Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations);
|
||||
Assert.Equal("pw-secret", server.LastPassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008));
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
||||
Assert.Equal(1008, result.NativeStatus);
|
||||
Assert.Single(result.Statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome);
|
||||
Assert.Equal(0, result.NativeStatus);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException)
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 0,
|
||||
AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null,
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
|
||||
Assert.NotEqual(0, result.NativeStatus);
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_Twice_ReusesItemHandle()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
|
||||
Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmAckOperator_ToString_OmitsPassword()
|
||||
{
|
||||
Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingAckServer : IMxAccessServer
|
||||
{
|
||||
private int nextItemHandle = 100;
|
||||
|
||||
public List<string> Operations { get; } = new();
|
||||
|
||||
public int AuthenticateResult { get; set; }
|
||||
|
||||
public Exception? AuthenticateException { get; set; }
|
||||
|
||||
public string? LastPassword { get; private set; }
|
||||
|
||||
public Action<int, int>? OnWrite { get; set; }
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
Operations.Add("Register:" + clientName);
|
||||
return 7;
|
||||
}
|
||||
|
||||
public void Unregister(int serverHandle) => Operations.Add("Unregister");
|
||||
|
||||
public int AddItem(int serverHandle, string itemDefinition)
|
||||
{
|
||||
Operations.Add("AddItem:" + itemDefinition);
|
||||
return nextItemHandle++;
|
||||
}
|
||||
|
||||
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
|
||||
|
||||
public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem");
|
||||
|
||||
public void Advise(int serverHandle, int itemHandle) => Operations.Add("Advise");
|
||||
|
||||
public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise");
|
||||
|
||||
public void AdviseSupervisory(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
public void Write(int serverHandle, int itemHandle, object? value, int userId)
|
||||
{
|
||||
Operations.Add("Write:" + value + ":" + userId);
|
||||
OnWrite?.Invoke(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
|
||||
{
|
||||
}
|
||||
|
||||
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
|
||||
{
|
||||
}
|
||||
|
||||
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
|
||||
{
|
||||
}
|
||||
|
||||
public object Suspend(int serverHandle, int itemHandle) => new object();
|
||||
|
||||
public object Activate(int serverHandle, int itemHandle) => new object();
|
||||
|
||||
public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword)
|
||||
{
|
||||
Operations.Add("AuthenticateUser:" + verifyUser);
|
||||
LastPassword = verifyUserPassword;
|
||||
if (AuthenticateException is not null)
|
||||
{
|
||||
throw AuthenticateException;
|
||||
}
|
||||
|
||||
return AuthenticateResult;
|
||||
}
|
||||
|
||||
public int ArchestrAUserToId(int serverHandle, string userIdGuid) => 0;
|
||||
|
||||
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
|
||||
|
||||
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user