diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs new file mode 100644 index 0000000..0b61759 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs @@ -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; + +/// +/// Unit tests for the subtag-fallback synthesis state machine. The machine +/// consumes normalized subtag value changes (active/acked/priority) and +/// emits records mirroring the wnwrap +/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions. No COM +/// or AVEVA install is required. +/// +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); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs new file mode 100644 index 0000000..7ad0576 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs @@ -0,0 +1,34 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// A change in one advised subtag value, normalized off the COM boundary. +public sealed class SubtagValueChange +{ + /// Gets the MXAccess item address (subtag reference) whose value changed. + public string ItemAddress { get; init; } = string.Empty; + + /// Gets the new value, as delivered by MXAccess (boxed COM variant or test value). + public object? Value { get; init; } + + /// Gets the UTC timestamp the change was observed. + public DateTime TimestampUtc { get; init; } +} + +/// +/// 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. +/// +public interface ISubtagAlarmSource : System.IDisposable +{ + /// Raised when an advised subtag reports a new value. + event System.EventHandler? ValueChanged; + + /// Begins advising the supplied MXAccess subtag item addresses. + /// The subtag references to advise. + void Advise(System.Collections.Generic.IReadOnlyCollection itemAddresses); + + /// Writes a value to the named MXAccess subtag (e.g. an ack request). + /// The subtag reference to write. + /// The value to write. + void Write(string itemAddress, object? value); +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs new file mode 100644 index 0000000..6ff12c9 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs @@ -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; + +/// +/// Synthesizes alarm transitions from raw subtag value changes when the +/// native alarmmgr (wnwrap) consumer is unavailable. Each configured +/// contributes an .active, +/// .acked, optional .priority, and optional ack-comment +/// subtag; the machine tracks per-alarm state and emits +/// 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. +/// +public sealed class SubtagAlarmStateMachine +{ + private static readonly IReadOnlyList NoEvents = Array.Empty(); + + private readonly Dictionary _statesByReference = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _bindingsByAddress = new(StringComparer.OrdinalIgnoreCase); + + /// Initializes the machine from the configured alarm subtag targets. + /// The alarm targets whose subtags will be observed. + public SubtagAlarmStateMachine(IEnumerable 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); + } + } + + /// + /// Applies a single subtag value change and returns any synthesized + /// transitions. Unknown addresses and no-op changes return an empty list. + /// + /// The subtag address whose value changed. + /// The new value (bool, numeric, or string). + /// The UTC timestamp of the change. + /// The synthesized alarm transitions, if any. + public IReadOnlyList 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; + } + } + + /// + /// Returns one snapshot record per currently-active alarm, with state + /// reflecting whether the alarm has been acknowledged. + /// + /// The active alarm snapshot records. + public IReadOnlyList SnapshotActive() + { + var records = new List(); + 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 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 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 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; } + + /// Derives the current alarm state from the tracked flags, before a transition is applied. + 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, + }; + } + + /// + /// Splits an alarm full reference such as Galaxy!Area.Tank01.Level.HiHi + /// into provider (Galaxy), group/area (Area), and the object-rooted + /// tag name (Tank01.Level.HiHi). The tag name is anchored on the target's + /// when present, so any + /// leading provider and area segments are stripped. + /// + 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); + } + } +}