worker(alarms): subtag value-source seam + synthesis state machine

This commit is contained in:
Joseph Doherty
2026-06-13 08:57:28 -04:00
parent c16f016f0a
commit 348ab16456
3 changed files with 451 additions and 0 deletions
@@ -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);
}
}
}