worker(alarms): subtag value-source seam + synthesis state machine
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the subtag-fallback synthesis state machine. The machine
|
||||
/// consumes normalized subtag value changes (active/acked/priority) and
|
||||
/// emits <see cref="MxAlarmTransitionEvent"/> records mirroring the wnwrap
|
||||
/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions. No COM
|
||||
/// or AVEVA install is required.
|
||||
/// </summary>
|
||||
public sealed class SubtagAlarmStateMachineTests
|
||||
{
|
||||
private static AlarmSubtagTarget Target() => new()
|
||||
{
|
||||
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||
SourceObjectReference = "Tank01",
|
||||
ActiveSubtag = "Tank01.Level.HiHi.active",
|
||||
AckedSubtag = "Tank01.Level.HiHi.acked",
|
||||
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ActiveFalseToTrue_EmitsRaise()
|
||||
{
|
||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
|
||||
Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
|
||||
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AckedTrueWhileActive_EmitsAck()
|
||||
{
|
||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||
var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
|
||||
Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
|
||||
{
|
||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveTrueToFalse_WhileAcked_EmitsAckRtn()
|
||||
{
|
||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||
sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(2));
|
||||
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal(MxAlarmStateKind.AckRtn, e.Record.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReflectsActiveAndAckedState()
|
||||
{
|
||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||
sm.Apply("Tank01.Level.HiHi.acked", true, ts);
|
||||
var snap = Assert.Single(sm.SnapshotActive());
|
||||
Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownAddress_NoEvents()
|
||||
{
|
||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||
var events = sm.Apply("Some.Other.Tag.active", true, DateTime.UtcNow);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
|
||||
public sealed class SubtagValueChange
|
||||
{
|
||||
/// <summary>Gets the MXAccess item address (subtag reference) whose value changed.</summary>
|
||||
public string ItemAddress { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the new value, as delivered by MXAccess (boxed COM variant or test value).</summary>
|
||||
public object? Value { get; init; }
|
||||
|
||||
/// <summary>Gets the UTC timestamp the change was observed.</summary>
|
||||
public DateTime TimestampUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advises a set of MXAccess subtag addresses and surfaces value changes.
|
||||
/// The production implementation owns its own LMXProxyServerClass (later task);
|
||||
/// tests substitute a fake that pushes SubtagValueChange values.
|
||||
/// </summary>
|
||||
public interface ISubtagAlarmSource : System.IDisposable
|
||||
{
|
||||
/// <summary>Raised when an advised subtag reports a new value.</summary>
|
||||
event System.EventHandler<SubtagValueChange>? ValueChanged;
|
||||
|
||||
/// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary>
|
||||
/// <param name="itemAddresses">The subtag references to advise.</param>
|
||||
void Advise(System.Collections.Generic.IReadOnlyCollection<string> itemAddresses);
|
||||
|
||||
/// <summary>Writes a value to the named MXAccess subtag (e.g. an ack request).</summary>
|
||||
/// <param name="itemAddress">The subtag reference to write.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
void Write(string itemAddress, object? value);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Synthesizes alarm transitions from raw subtag value changes when the
|
||||
/// native alarmmgr (wnwrap) consumer is unavailable. Each configured
|
||||
/// <see cref="AlarmSubtagTarget"/> contributes an <c>.active</c>,
|
||||
/// <c>.acked</c>, optional <c>.priority</c>, and optional ack-comment
|
||||
/// subtag; the machine tracks per-alarm state and emits
|
||||
/// <see cref="MxAlarmTransitionEvent"/> records mirroring the wnwrap
|
||||
/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions.
|
||||
/// This type performs no COM work and is fully unit-testable.
|
||||
/// </summary>
|
||||
public sealed class SubtagAlarmStateMachine
|
||||
{
|
||||
private static readonly IReadOnlyList<MxAlarmTransitionEvent> NoEvents = Array.Empty<MxAlarmTransitionEvent>();
|
||||
|
||||
private readonly Dictionary<string, AlarmState> _statesByReference = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, SubtagBinding> _bindingsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Initializes the machine from the configured alarm subtag targets.</summary>
|
||||
/// <param name="targets">The alarm targets whose subtags will be observed.</param>
|
||||
public SubtagAlarmStateMachine(IEnumerable<AlarmSubtagTarget> targets)
|
||||
{
|
||||
if (targets is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(targets));
|
||||
}
|
||||
|
||||
foreach (AlarmSubtagTarget target in targets)
|
||||
{
|
||||
var state = new AlarmState(target);
|
||||
_statesByReference[target.AlarmFullReference] = state;
|
||||
|
||||
Bind(target.ActiveSubtag, state, SubtagRole.Active);
|
||||
Bind(target.AckedSubtag, state, SubtagRole.Acked);
|
||||
Bind(target.PrioritySubtag, state, SubtagRole.Priority);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a single subtag value change and returns any synthesized
|
||||
/// transitions. Unknown addresses and no-op changes return an empty list.
|
||||
/// </summary>
|
||||
/// <param name="itemAddress">The subtag address whose value changed.</param>
|
||||
/// <param name="value">The new value (bool, numeric, or string).</param>
|
||||
/// <param name="timestampUtc">The UTC timestamp of the change.</param>
|
||||
/// <returns>The synthesized alarm transitions, if any.</returns>
|
||||
public IReadOnlyList<MxAlarmTransitionEvent> Apply(string itemAddress, object? value, DateTime timestampUtc)
|
||||
{
|
||||
if (itemAddress is null || !_bindingsByAddress.TryGetValue(itemAddress, out SubtagBinding binding))
|
||||
{
|
||||
return NoEvents;
|
||||
}
|
||||
|
||||
AlarmState state = binding.State;
|
||||
switch (binding.Role)
|
||||
{
|
||||
case SubtagRole.Active:
|
||||
return ApplyActive(state, CoerceBool(value), timestampUtc);
|
||||
case SubtagRole.Acked:
|
||||
return ApplyAcked(state, CoerceBool(value), timestampUtc);
|
||||
case SubtagRole.Priority:
|
||||
state.Priority = CoerceInt(value, state.Priority);
|
||||
return NoEvents;
|
||||
default:
|
||||
return NoEvents;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns one snapshot record per currently-active alarm, with state
|
||||
/// reflecting whether the alarm has been acknowledged.
|
||||
/// </summary>
|
||||
/// <returns>The active alarm snapshot records.</returns>
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActive()
|
||||
{
|
||||
var records = new List<MxAlarmSnapshotRecord>();
|
||||
foreach (AlarmState state in _statesByReference.Values)
|
||||
{
|
||||
if (!state.Active)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MxAlarmStateKind kind = state.Acked ? MxAlarmStateKind.AckAlm : MxAlarmStateKind.UnackAlm;
|
||||
records.Add(state.BuildRecord(kind, state.FirstRaiseUtc));
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private IReadOnlyList<MxAlarmTransitionEvent> ApplyActive(AlarmState state, bool newActive, DateTime timestampUtc)
|
||||
{
|
||||
if (newActive == state.Active)
|
||||
{
|
||||
return NoEvents;
|
||||
}
|
||||
|
||||
if (newActive)
|
||||
{
|
||||
// false -> true: alarm raised, initially unacknowledged.
|
||||
MxAlarmStateKind previous = state.DerivedState();
|
||||
state.Active = true;
|
||||
state.Acked = false;
|
||||
state.FirstRaiseUtc = timestampUtc;
|
||||
return Single(state, MxAlarmStateKind.UnackAlm, previous, timestampUtc);
|
||||
}
|
||||
else
|
||||
{
|
||||
// true -> false: alarm returned to normal. State depends on ack.
|
||||
MxAlarmStateKind previous = state.DerivedState();
|
||||
MxAlarmStateKind kind = state.Acked ? MxAlarmStateKind.AckRtn : MxAlarmStateKind.UnackRtn;
|
||||
state.Active = false;
|
||||
state.Acked = false;
|
||||
return Single(state, kind, previous, timestampUtc);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<MxAlarmTransitionEvent> ApplyAcked(AlarmState state, bool newAcked, DateTime timestampUtc)
|
||||
{
|
||||
if (newAcked == state.Acked)
|
||||
{
|
||||
return NoEvents;
|
||||
}
|
||||
|
||||
state.Acked = newAcked;
|
||||
|
||||
// Only a true ack of a currently-active alarm produces an ACK_ALM transition.
|
||||
if (newAcked && state.Active)
|
||||
{
|
||||
return Single(state, MxAlarmStateKind.AckAlm, MxAlarmStateKind.UnackAlm, timestampUtc);
|
||||
}
|
||||
|
||||
return NoEvents;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MxAlarmTransitionEvent> Single(
|
||||
AlarmState state,
|
||||
MxAlarmStateKind kind,
|
||||
MxAlarmStateKind previous,
|
||||
DateTime timestampUtc)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new MxAlarmTransitionEvent
|
||||
{
|
||||
Record = state.BuildRecord(kind, timestampUtc),
|
||||
PreviousState = previous,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private void Bind(string? address, AlarmState state, SubtagRole role)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(address))
|
||||
{
|
||||
_bindingsByAddress[address!] = new SubtagBinding(state, role);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CoerceBool(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
return false;
|
||||
case bool b:
|
||||
return b;
|
||||
case string s:
|
||||
if (bool.TryParse(s, out bool parsedBool))
|
||||
{
|
||||
return parsedBool;
|
||||
}
|
||||
|
||||
if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out double parsedNum))
|
||||
{
|
||||
return parsedNum != 0d;
|
||||
}
|
||||
|
||||
return false;
|
||||
default:
|
||||
try
|
||||
{
|
||||
return Convert.ToDouble(value, CultureInfo.InvariantCulture) != 0d;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CoerceInt(object? value, int fallback)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
return fallback;
|
||||
case int i:
|
||||
return i;
|
||||
case string s when int.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out int parsed):
|
||||
return parsed;
|
||||
case string:
|
||||
return fallback;
|
||||
default:
|
||||
try
|
||||
{
|
||||
return Convert.ToInt32(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum SubtagRole
|
||||
{
|
||||
Active,
|
||||
Acked,
|
||||
Priority,
|
||||
}
|
||||
|
||||
private readonly struct SubtagBinding
|
||||
{
|
||||
public SubtagBinding(AlarmState state, SubtagRole role)
|
||||
{
|
||||
State = state;
|
||||
Role = role;
|
||||
}
|
||||
|
||||
public AlarmState State { get; }
|
||||
|
||||
public SubtagRole Role { get; }
|
||||
}
|
||||
|
||||
private sealed class AlarmState
|
||||
{
|
||||
private readonly string _providerName;
|
||||
private readonly string _group;
|
||||
private readonly string _tagName;
|
||||
|
||||
public AlarmState(AlarmSubtagTarget target)
|
||||
{
|
||||
(_providerName, _group, _tagName) = DeriveNameParts(target);
|
||||
}
|
||||
|
||||
public bool Active { get; set; }
|
||||
|
||||
public bool Acked { get; set; }
|
||||
|
||||
public int Priority { get; set; }
|
||||
|
||||
public DateTime FirstRaiseUtc { get; set; }
|
||||
|
||||
/// <summary>Derives the current alarm state from the tracked flags, before a transition is applied.</summary>
|
||||
public MxAlarmStateKind DerivedState()
|
||||
{
|
||||
if (!Active)
|
||||
{
|
||||
return MxAlarmStateKind.Unspecified;
|
||||
}
|
||||
|
||||
return Acked ? MxAlarmStateKind.AckAlm : MxAlarmStateKind.UnackAlm;
|
||||
}
|
||||
|
||||
public MxAlarmSnapshotRecord BuildRecord(MxAlarmStateKind kind, DateTime timestampUtc)
|
||||
{
|
||||
return new MxAlarmSnapshotRecord
|
||||
{
|
||||
ProviderName = _providerName,
|
||||
Group = _group,
|
||||
TagName = _tagName,
|
||||
Priority = Priority,
|
||||
State = kind,
|
||||
TransitionTimestampUtc = timestampUtc,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits an alarm full reference such as <c>Galaxy!Area.Tank01.Level.HiHi</c>
|
||||
/// into provider (<c>Galaxy</c>), group/area (<c>Area</c>), and the object-rooted
|
||||
/// tag name (<c>Tank01.Level.HiHi</c>). The tag name is anchored on the target's
|
||||
/// <see cref="AlarmSubtagTarget.SourceObjectReference"/> when present, so any
|
||||
/// leading provider and area segments are stripped.
|
||||
/// </summary>
|
||||
private static (string ProviderName, string Group, string TagName) DeriveNameParts(AlarmSubtagTarget target)
|
||||
{
|
||||
string reference = target.AlarmFullReference ?? string.Empty;
|
||||
|
||||
string providerName = string.Empty;
|
||||
string afterBang = reference;
|
||||
int bang = reference.IndexOf('!');
|
||||
if (bang >= 0)
|
||||
{
|
||||
providerName = reference.Substring(0, bang);
|
||||
afterBang = reference.Substring(bang + 1);
|
||||
}
|
||||
|
||||
string tagName = afterBang;
|
||||
string source = target.SourceObjectReference ?? string.Empty;
|
||||
string group = string.Empty;
|
||||
if (source.Length > 0)
|
||||
{
|
||||
int objIndex = afterBang.IndexOf(source, StringComparison.OrdinalIgnoreCase);
|
||||
if (objIndex > 0)
|
||||
{
|
||||
// Everything before the object (minus a trailing '.') is the area/group.
|
||||
group = afterBang.Substring(0, objIndex).TrimEnd('.');
|
||||
tagName = afterBang.Substring(objIndex);
|
||||
}
|
||||
else if (objIndex == 0)
|
||||
{
|
||||
tagName = afterBang;
|
||||
}
|
||||
}
|
||||
|
||||
return (providerName, group, tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user