worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Unit-test coverage for <see cref="LmxSubtagAlarmSource"/>'s advise/write
|
||||
/// sequencing and its <c>OnDataChange</c> normalization. The actual
|
||||
/// <c>LMXProxyServerClass</c> COM event subscription cannot be exercised
|
||||
/// without a live MXAccess install, so these tests drive the source through
|
||||
/// its internal <see cref="IMxAccessServer"/> seam and call
|
||||
/// <c>HandleDataChange</c> directly to simulate a COM callback — exactly the
|
||||
/// boundary <c>MxAccessBaseEventSink.OnDataChange</c> uses for the
|
||||
/// per-session pipeline. End-to-end COM delivery is covered by the
|
||||
/// Skip-gated alarm live smoke tests.
|
||||
/// </summary>
|
||||
public sealed class LmxSubtagAlarmSourceTests
|
||||
{
|
||||
private const int FakeServerHandle = 7;
|
||||
|
||||
/// <summary>Verifies the production constructor rejects a null factory.</summary>
|
||||
[Fact]
|
||||
public void Constructor_NullFactory_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new LmxSubtagAlarmSource(factory: null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="LmxSubtagAlarmSource.Advise"/> calls AddItem then
|
||||
/// Advise once per distinct address, and is idempotent on a repeated
|
||||
/// address.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Advise_AddsAndAdvisesEachAddressOnce()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
|
||||
// Re-advising an already-advised address is a no-op.
|
||||
source.Advise(new[] { "Tank1.Alarm.Subtag" });
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" },
|
||||
server.AddedItems);
|
||||
Assert.Equal(2, server.AdviseCount);
|
||||
// Every advise targeted the supplied server handle.
|
||||
Assert.All(server.AdvisedServerHandles, h => Assert.Equal(FakeServerHandle, h));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a simulated <c>OnDataChange</c> for an advised item handle
|
||||
/// raises <see cref="LmxSubtagAlarmSource.ValueChanged"/> with the
|
||||
/// address that was advised and the delivered value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HandleDataChange_RaisesValueChangedWithAdvisedAddress()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
source.Advise(new[] { "Tank1.Alarm.Subtag" });
|
||||
int itemHandle = server.LastItemHandleFor("Tank1.Alarm.Subtag");
|
||||
|
||||
SubtagValueChange? received = null;
|
||||
source.ValueChanged += (_, change) => received = change;
|
||||
|
||||
source.HandleDataChange(itemHandle, pvItemValue: 42, pftItemTimeStamp: null);
|
||||
|
||||
Assert.NotNull(received);
|
||||
Assert.Equal("Tank1.Alarm.Subtag", received!.ItemAddress);
|
||||
Assert.Equal(42, received.Value);
|
||||
Assert.Equal(DateTimeKind.Utc, received.TimestampUtc.Kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <c>OnDataChange</c> for an unknown item handle is ignored
|
||||
/// (no <see cref="LmxSubtagAlarmSource.ValueChanged"/> raised).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HandleDataChange_UnknownHandle_DoesNotRaise()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
bool raised = false;
|
||||
source.ValueChanged += (_, _) => raised = true;
|
||||
|
||||
source.HandleDataChange(phItemHandle: 999, pvItemValue: 1, pftItemTimeStamp: null);
|
||||
|
||||
Assert.False(raised);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when
|
||||
/// it was not previously advised and writes with user id 0.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Write_AddsItemWhenUnknownAndWrites()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
source.Write("Tank1.Alarm.AckComment", "acknowledged");
|
||||
|
||||
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
|
||||
Assert.Single(server.Writes);
|
||||
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
|
||||
Assert.Equal(FakeServerHandle, write.ServerHandle);
|
||||
Assert.Equal("acknowledged", write.Value);
|
||||
Assert.Equal(0, write.UserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
|
||||
/// item handle (no duplicate AddItem) when the address was already
|
||||
/// advised.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Write_ReusesHandleForAdvisedAddress()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
source.Advise(new[] { "Tank1.Alarm.Subtag" });
|
||||
int adviseAddCount = server.AddedItems.Count;
|
||||
|
||||
source.Write("Tank1.Alarm.Subtag", true);
|
||||
|
||||
// No second AddItem for the already-advised address.
|
||||
Assert.Equal(adviseAddCount, server.AddedItems.Count);
|
||||
Assert.Single(server.Writes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recording <see cref="IMxAccessServer"/> test double that captures the
|
||||
/// AddItem/Advise/Write/UnAdvise/RemoveItem/Unregister calls
|
||||
/// <see cref="LmxSubtagAlarmSource"/> makes and hands out monotonically
|
||||
/// increasing item handles.
|
||||
/// </summary>
|
||||
private sealed class RecordingMxAccessServer : IMxAccessServer
|
||||
{
|
||||
private readonly Dictionary<string, int> handlesByAddress = new(StringComparer.Ordinal);
|
||||
private int nextItemHandle = 100;
|
||||
|
||||
public List<string> AddedItems { get; } = new();
|
||||
|
||||
public int AdviseCount { get; private set; }
|
||||
|
||||
public List<int> AdvisedServerHandles { get; } = new();
|
||||
|
||||
public List<WriteRecord> Writes { get; } = new();
|
||||
|
||||
public int LastItemHandleFor(string itemAddress) => handlesByAddress[itemAddress];
|
||||
|
||||
public int Register(string clientName) => FakeServerHandle;
|
||||
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
}
|
||||
|
||||
public int AddItem(int serverHandle, string itemDefinition)
|
||||
{
|
||||
AddedItems.Add(itemDefinition);
|
||||
int handle = nextItemHandle++;
|
||||
handlesByAddress[itemDefinition] = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
|
||||
=> AddItem(serverHandle, itemDefinition);
|
||||
|
||||
public void RemoveItem(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
public void Advise(int serverHandle, int itemHandle)
|
||||
{
|
||||
AdviseCount++;
|
||||
AdvisedServerHandles.Add(serverHandle);
|
||||
}
|
||||
|
||||
public void UnAdvise(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
public void Write(int serverHandle, int itemHandle, object? value, int userId)
|
||||
=> Writes.Add(new WriteRecord(serverHandle, itemHandle, value, userId));
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
internal sealed record WriteRecord(int ServerHandle, int ItemHandle, object? Value, int UserId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess
|
||||
/// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag
|
||||
/// item addresses, normalizes each <c>OnDataChange</c> COM callback into a
|
||||
/// <see cref="SubtagValueChange"/>, and supports writing a value (e.g. an
|
||||
/// ack-comment) to a subtag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Like <see cref="WnWrapAlarmConsumer"/>, this type owns its
|
||||
/// <em>own</em> MXAccess proxy-server COM object rather than sharing the
|
||||
/// per-session item pipeline's <see cref="MxAccessSession"/>. That keeps
|
||||
/// the subtag-fallback subscription isolated from the session's own
|
||||
/// <c>Register</c>/<c>AddItem</c>/<c>Advise</c> bookkeeping: advising or
|
||||
/// tearing down alarm subtags never perturbs the client's subscriptions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is
|
||||
/// apartment-threaded and delivers <c>OnDataChange</c> via the STA
|
||||
/// message pump. Every method on this type — the lazy <see cref="Advise"/>
|
||||
/// creation, <see cref="Write"/>, and <see cref="Dispose"/> — must be
|
||||
/// invoked on the worker's STA that created the source, exactly as
|
||||
/// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/>
|
||||
/// require. The <c>OnDataChange</c> COM event also arrives on that STA.
|
||||
/// Because every call is confined to the single owning STA, no lock is
|
||||
/// taken around the COM object or the handle maps: a lock here would risk
|
||||
/// deadlocking the STA against a re-entrant pump callback, and the STA
|
||||
/// affinity already serializes access.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
|
||||
{
|
||||
private const string DefaultClientName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker.SubtagAlarms";
|
||||
|
||||
private readonly IMxAccessComObjectFactory? factory;
|
||||
private readonly string clientName;
|
||||
private readonly Dictionary<string, int> itemHandlesByAddress =
|
||||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<int, string> addressesByItemHandle =
|
||||
new Dictionary<int, string>();
|
||||
|
||||
private object? mxAccessComObject;
|
||||
private IMxAccessServer? server;
|
||||
private LMXProxyServerClass? comEventSource;
|
||||
private int serverHandle;
|
||||
private bool registered;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor. The MXAccess COM object is created lazily on
|
||||
/// the first <see cref="Advise"/> call (which must run on the worker's
|
||||
/// STA) via the supplied <paramref name="factory"/>.
|
||||
/// </summary>
|
||||
/// <param name="factory">Factory that creates the MXAccess proxy-server COM object.</param>
|
||||
/// <param name="clientName">
|
||||
/// Optional MXAccess client/registration name used for
|
||||
/// <see cref="IMxAccessServer.Register"/>; a worker-specific default is
|
||||
/// used when null or whitespace. Mirrors the registration name
|
||||
/// <see cref="MxAccessSession.Register"/> accepts.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="factory"/> is null.</exception>
|
||||
public LmxSubtagAlarmSource(
|
||||
IMxAccessComObjectFactory factory,
|
||||
string? clientName = null)
|
||||
{
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only seam: constructs a source over a caller-supplied
|
||||
/// <see cref="IMxAccessServer"/> with a pre-registered server handle,
|
||||
/// bypassing the live COM factory. Exposed via
|
||||
/// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can
|
||||
/// exercise <see cref="Advise"/>/<see cref="Write"/> and
|
||||
/// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>.
|
||||
/// No COM event source is wired in this mode — tests drive
|
||||
/// <see cref="HandleDataChange"/> directly to simulate an
|
||||
/// <c>OnDataChange</c> callback.
|
||||
/// </summary>
|
||||
/// <param name="server">The server abstraction to drive.</param>
|
||||
/// <param name="serverHandle">The already-registered server handle to use.</param>
|
||||
internal LmxSubtagAlarmSource(
|
||||
IMxAccessServer server,
|
||||
int serverHandle)
|
||||
{
|
||||
this.factory = null;
|
||||
this.clientName = DefaultClientName;
|
||||
this.server = server ?? throw new ArgumentNullException(nameof(server));
|
||||
this.serverHandle = serverHandle;
|
||||
this.registered = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<SubtagValueChange>? ValueChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Idempotent per address: an address already advised is skipped. The
|
||||
/// MXAccess COM object is created and the client registered on the first
|
||||
/// call. Must be invoked on the worker's STA.
|
||||
/// </remarks>
|
||||
public void Advise(IReadOnlyCollection<string> itemAddresses)
|
||||
{
|
||||
if (itemAddresses is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(itemAddresses));
|
||||
}
|
||||
|
||||
ThrowIfDisposed();
|
||||
EnsureRegistered();
|
||||
|
||||
IMxAccessServer mxServer = server!;
|
||||
foreach (string? itemAddress in itemAddresses)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(itemAddress))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (itemHandlesByAddress.ContainsKey(itemAddress!))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int itemHandle = mxServer.AddItem(serverHandle, itemAddress!);
|
||||
mxServer.Advise(serverHandle, itemHandle);
|
||||
itemHandlesByAddress[itemAddress!] = itemHandle;
|
||||
addressesByItemHandle[itemHandle] = itemAddress!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Adds the item if it has not been added yet — the write may target a
|
||||
/// subtag (e.g. the ack-comment) that was never advised — then writes
|
||||
/// with MXAccess user id 0 (unsecured). Must be invoked on the worker's
|
||||
/// STA.
|
||||
/// </remarks>
|
||||
public void Write(string itemAddress, object? value)
|
||||
{
|
||||
if (itemAddress is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(itemAddress));
|
||||
}
|
||||
|
||||
ThrowIfDisposed();
|
||||
EnsureRegistered();
|
||||
|
||||
IMxAccessServer mxServer = server!;
|
||||
if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle))
|
||||
{
|
||||
itemHandle = mxServer.AddItem(serverHandle, itemAddress);
|
||||
itemHandlesByAddress[itemAddress] = itemHandle;
|
||||
addressesByItemHandle[itemHandle] = itemAddress;
|
||||
}
|
||||
|
||||
mxServer.Write(serverHandle, itemHandle, value, userId: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes an MXAccess <c>OnDataChange</c> callback into a
|
||||
/// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/>
|
||||
/// when the item handle maps to an advised address. Unknown handles are
|
||||
/// ignored. Exposed <c>internal</c> as a unit-test seam so the
|
||||
/// normalization can be exercised without a live <c>LMXProxyServerClass</c>,
|
||||
/// mirroring <c>MxAccessBaseEventSink.OnDataChange</c>.
|
||||
/// </summary>
|
||||
/// <param name="phItemHandle">The MXAccess item handle that changed.</param>
|
||||
/// <param name="pvItemValue">The new item value (left as a boxed COM variant; the state machine coerces it).</param>
|
||||
/// <param name="pftItemTimeStamp">The item timestamp as delivered by MXAccess (a culture-formatted string).</param>
|
||||
internal void HandleDataChange(
|
||||
int phItemHandle,
|
||||
object? pvItemValue,
|
||||
object? pftItemTimeStamp)
|
||||
{
|
||||
if (!addressesByItemHandle.TryGetValue(phItemHandle, out string? itemAddress))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// MXAccess delivers OnDataChange timestamps as a culture-formatted
|
||||
// string (VT_BSTR), not a FILETIME or VT_DATE — see
|
||||
// MxAccessEventMapper.TryParseSourceTimestamp, which is the single
|
||||
// conversion helper the per-session pipeline already uses. Reuse it so
|
||||
// the subtag path interprets the host-local string the same way; fall
|
||||
// back to capture time when the string is absent or unparsable.
|
||||
DateTime timestampUtc = ConvertTimestampUtc(pftItemTimeStamp);
|
||||
|
||||
EventHandler<SubtagValueChange>? handler = ValueChanged;
|
||||
handler?.Invoke(this, new SubtagValueChange
|
||||
{
|
||||
ItemAddress = itemAddress,
|
||||
Value = pvItemValue,
|
||||
TimestampUtc = timestampUtc,
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
// Unsubscribe the COM event before tearing handles down so a late pump
|
||||
// callback cannot re-enter HandleDataChange mid-teardown.
|
||||
if (comEventSource is not null)
|
||||
{
|
||||
try { comEventSource.OnDataChange -= OnDataChange; } catch { /* swallow — best effort */ }
|
||||
}
|
||||
|
||||
IMxAccessServer? mxServer = server;
|
||||
if (mxServer is not null && registered)
|
||||
{
|
||||
foreach (KeyValuePair<int, string> entry in addressesByItemHandle)
|
||||
{
|
||||
try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
|
||||
try { mxServer.RemoveItem(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
|
||||
}
|
||||
|
||||
try { mxServer.Unregister(serverHandle); } catch { /* swallow — best effort */ }
|
||||
}
|
||||
|
||||
itemHandlesByAddress.Clear();
|
||||
addressesByItemHandle.Clear();
|
||||
|
||||
object? comToRelease = mxAccessComObject;
|
||||
mxAccessComObject = null;
|
||||
comEventSource = null;
|
||||
server = null;
|
||||
registered = false;
|
||||
|
||||
if (comToRelease is not null && Marshal.IsComObject(comToRelease))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(comToRelease); } catch { /* swallow — best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRegistered()
|
||||
{
|
||||
if (registered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// factory is only null in the test-only constructor, which sets
|
||||
// registered = true, so this path is never hit there.
|
||||
object created = factory!.Create()
|
||||
?? throw new InvalidOperationException("MXAccess COM factory returned null.");
|
||||
|
||||
try
|
||||
{
|
||||
mxAccessComObject = created;
|
||||
|
||||
// Wire OnDataChange the same way MxAccessBaseEventSink.Attach does:
|
||||
// cast to the concrete LMXProxyServerClass RCW and subscribe. Test
|
||||
// doubles cannot be cast to LMXProxyServerClass, so the COM event
|
||||
// subscription is exercised only on the live path; unit tests drive
|
||||
// HandleDataChange directly via the internal constructor seam.
|
||||
if (created is LMXProxyServerClass comObject)
|
||||
{
|
||||
comEventSource = comObject;
|
||||
comObject.OnDataChange += OnDataChange;
|
||||
}
|
||||
|
||||
server = new MxAccessComServer(created);
|
||||
serverHandle = server.Register(clientName);
|
||||
registered = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (comEventSource is not null)
|
||||
{
|
||||
try { comEventSource.OnDataChange -= OnDataChange; } catch { /* swallow */ }
|
||||
comEventSource = null;
|
||||
}
|
||||
|
||||
if (Marshal.IsComObject(created))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(created); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
mxAccessComObject = null;
|
||||
server = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// COM <c>OnDataChange</c> delegate target. Forwards to
|
||||
/// <see cref="HandleDataChange"/>; the quality, status array, and server
|
||||
/// handle carry no information the subtag fallback needs.
|
||||
/// </summary>
|
||||
private void OnDataChange(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
object pvItemValue,
|
||||
int pwItemQuality,
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
HandleDataChange(phItemHandle, pvItemValue, pftItemTimeStamp);
|
||||
}
|
||||
|
||||
private static DateTime ConvertTimestampUtc(object? pftItemTimeStamp)
|
||||
{
|
||||
if (pftItemTimeStamp is not null
|
||||
&& MxAccessEventMapper.TryParseSourceTimestamp(pftItemTimeStamp.ToString(), out DateTime parsedUtc))
|
||||
{
|
||||
return parsedUtc;
|
||||
}
|
||||
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(LmxSubtagAlarmSource));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user