diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs index 0b61759..7567f7d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs @@ -33,6 +33,58 @@ public sealed class SubtagAlarmStateMachineTests Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State); Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState); Assert.Equal("Tank01.Level.HiHi", e.Record.TagName); + Assert.Equal("Galaxy", e.Record.ProviderName); + Assert.Equal("Area", e.Record.Group); + } + + [Fact] + public void ActiveFalseToTrue_NoProviderBang_UsesWholeReferenceAsTagName() + { + var target = new AlarmSubtagTarget + { + AlarmFullReference = "Tank01.Level.HiHi", + SourceObjectReference = string.Empty, + ActiveSubtag = "Tank01.Level.HiHi.active", + }; + 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("Tank01.Level.HiHi", e.Record.TagName); + Assert.Equal(string.Empty, e.Record.ProviderName); + Assert.Equal(string.Empty, e.Record.Group); + } + + [Fact] + public void OutOfOrderAckThenClear_StillEmitsAckRtn() + { + 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)); + // Out-of-order un-ack arrives before the active=false clear. + sm.Apply("Tank01.Level.HiHi.acked", false, ts.AddSeconds(3)); + 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 DuplicateActiveSubtag_Throws() + { + var first = new AlarmSubtagTarget + { + AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi", + SourceObjectReference = "Tank01", + ActiveSubtag = "Shared.active", + }; + var second = new AlarmSubtagTarget + { + AlarmFullReference = "Galaxy!Area.Tank02.Level.HiHi", + SourceObjectReference = "Tank02", + ActiveSubtag = "Shared.active", + }; + Assert.Throws(() => new SubtagAlarmStateMachine(new[] { first, second })); } [Fact] diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs index 7ad0576..75b4873 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs @@ -1,31 +1,21 @@ +using System; +using System.Collections.Generic; + 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 +public interface ISubtagAlarmSource : IDisposable { /// Raised when an advised subtag reports a new value. - event System.EventHandler? ValueChanged; + event EventHandler? ValueChanged; /// Begins advising the supplied MXAccess subtag item addresses. /// The subtag references to advise. - void Advise(System.Collections.Generic.IReadOnlyCollection itemAddresses); + void Advise(IReadOnlyCollection itemAddresses); /// Writes a value to the named MXAccess subtag (e.g. an ack request). /// The subtag reference to write. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs index 6ff12c9..80a0aa6 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs @@ -15,6 +15,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions. /// This type performs no COM work and is fully unit-testable. /// +/// +/// This type has STA affinity: it is mutated and read on the worker STA +/// thread only and is not thread-safe. The per-target +/// is a write-only ack +/// target and is deliberately not bound for observation. The synthesized +/// is left at its default +/// value: in subtag mode acks are correlated by name/comment rather than +/// by GUID, and a synthetic GUID is assigned by a later component. +/// public sealed class SubtagAlarmStateMachine { private static readonly IReadOnlyList NoEvents = Array.Empty(); @@ -107,20 +116,32 @@ public sealed class SubtagAlarmStateMachine MxAlarmStateKind previous = state.DerivedState(); state.Active = true; state.Acked = false; + state.AckedDuringEpisode = false; state.FirstRaiseUtc = timestampUtc; return Single(state, MxAlarmStateKind.UnackAlm, previous, timestampUtc); } else { - // true -> false: alarm returned to normal. State depends on ack. + // true -> false: alarm returned to normal. The return kind reflects + // whether the alarm was acknowledged at any point during the active + // episode, not just its instantaneous Acked flag (see remarks). MxAlarmStateKind previous = state.DerivedState(); - MxAlarmStateKind kind = state.Acked ? MxAlarmStateKind.AckRtn : MxAlarmStateKind.UnackRtn; + MxAlarmStateKind kind = state.AckedDuringEpisode ? MxAlarmStateKind.AckRtn : MxAlarmStateKind.UnackRtn; state.Active = false; state.Acked = false; + state.AckedDuringEpisode = false; return Single(state, kind, previous, timestampUtc); } } + /// + /// latches that the alarm was + /// acknowledged at some point while active. MXAccess does not guarantee + /// that the acked subtag update arrives before the active=false update on + /// clear, so an out-of-order un-ack (acked true->false) must not lose the + /// fact that the episode was acknowledged; the latch ensures the clear + /// still emits ACK_RTN rather than UNACK_RTN. + /// private IReadOnlyList ApplyAcked(AlarmState state, bool newAcked, DateTime timestampUtc) { if (newAcked == state.Acked) @@ -130,12 +151,16 @@ public sealed class SubtagAlarmStateMachine state.Acked = newAcked; - // Only a true ack of a currently-active alarm produces an ACK_ALM transition. + // Only a true ack of a currently-active alarm produces an ACK_ALM + // transition; the latch records that the episode was acked at some point. if (newAcked && state.Active) { + state.AckedDuringEpisode = true; return Single(state, MxAlarmStateKind.AckAlm, MxAlarmStateKind.UnackAlm, timestampUtc); } + // acked true->false while active: do NOT clear the latch (the episode was + // already acked); emit nothing. return NoEvents; } @@ -159,6 +184,13 @@ public sealed class SubtagAlarmStateMachine { if (!string.IsNullOrEmpty(address)) { + if (_bindingsByAddress.ContainsKey(address!)) + { + throw new ArgumentException( + $"Duplicate subtag item address '{address}' is bound to more than one alarm target.", + nameof(address)); + } + _bindingsByAddress[address!] = new SubtagBinding(state, role); } } @@ -254,6 +286,13 @@ public sealed class SubtagAlarmStateMachine public bool Acked { get; set; } + /// + /// Latches that the alarm was acknowledged at some point during the + /// current active episode, so a clear emits ACK_RTN even when the + /// un-ack subtag arrives before the active=false update. + /// + public bool AckedDuringEpisode { get; set; } + public int Priority { get; set; } public DateTime FirstRaiseUtc { get; set; } @@ -307,15 +346,31 @@ public sealed class SubtagAlarmStateMachine string group = string.Empty; if (source.Length > 0) { - int objIndex = afterBang.IndexOf(source, StringComparison.OrdinalIgnoreCase); - if (objIndex > 0) + // Match the source on dot-delimited segment boundaries so a source + // such as "Tank" does not match mid-token inside "Tank01" and a + // source "Area" does not match inside "TankArea". + string[] segments = afterBang.Split('.'); + int segmentIndex = -1; + for (int i = 0; i < segments.Length; i++) { - // Everything before the object (minus a trailing '.') is the area/group. - group = afterBang.Substring(0, objIndex).TrimEnd('.'); - tagName = afterBang.Substring(objIndex); + if (string.Equals(segments[i], source, StringComparison.OrdinalIgnoreCase)) + { + segmentIndex = i; + break; + } } - else if (objIndex == 0) + + if (segmentIndex > 0) { + // Everything before the object is the area/group; the object + // and everything after it is the object-rooted tag name. + group = string.Join(".", segments, 0, segmentIndex); + tagName = string.Join(".", segments, segmentIndex, segments.Length - segmentIndex); + } + else + { + // segmentIndex == 0 (source is the first segment) or not found: + // no leading area to strip, so the tag name is the whole afterBang. tagName = afterBang; } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagValueChange.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagValueChange.cs new file mode 100644 index 0000000..1811357 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagValueChange.cs @@ -0,0 +1,16 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// A change in one advised subtag value, normalized off the COM boundary. +public sealed class SubtagValueChange +{ + /// Gets or sets the MXAccess item address (subtag reference) whose value changed. + public string ItemAddress { get; set; } = string.Empty; + + /// Gets or sets the new value, as delivered by MXAccess (boxed COM variant or test value). + public object? Value { get; set; } + + /// Gets or sets the UTC timestamp the change was observed. + public DateTime TimestampUtc { get; set; } +}