Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs
T
Joseph Doherty 01bcca992c feat(alarms): thread an additive AckTime through the native-alarm mirror
MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror
carried acked-vs-unacked but never WHEN. AckTime now rides the whole path:
DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite.

Stamping rule, identical on both protocols: non-null ONLY while the condition
is active AND acknowledged. That single predicate yields all three required
behaviours -- null while unacked, cleared on re-raise (a re-raise arrives
unacknowledged), and no phantom ack on a return-to-normal. The last one is
load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without
the active check every clear would claim an ack the system never observed.

Provenance is honest, never fabricated:
  - OPC UA A&C supplies a TRUE ack instant, so we now select it:
    AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause
    index 18, APPENDED so the positional reads at 0-17 keep their meaning.
    Servers that omit the field fall back to the event's own Time.
  - MxAccess Gateway supplies none, so the ack transition's own timestamp is
    used -- accurate to when the system SAW the ack. An ACTIVE_ACKED
    re-subscribe snapshot restores one from LastTransitionTimestamp rather
    than dropping it.
The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is
unit-tested with no live server or gateway.

Additive-only throughout: init-only property on AlarmStateChanged, trailing
optional positional on NativeAlarmTransition (all 14-arg call sites untouched),
proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh
sitestream with the csproj diff verified empty.

Persistence rides native_alarm_state's existing metadata_json blob rather than
a new column -- deliberately. That table is RegisterReplicated in
SiteLocalDbSetup and LocalDb builds its CDC triggers from the column list at
registration time, so an additive JSON property changes no schema, no triggers
and no replication contract; metadata_json is exactly the extension point UA4
introduced for this. Rows written before the field deserialize it as null.

Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit,
failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case,
4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves
18 -> 19 with an index-18 assertion -- intended, the clause is appended, which
is precisely what that guard exists to make visible.
2026-08-01 13:12:04 -04:00

111 lines
5.1 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
public record AlarmStateChanged(
string InstanceUniqueName,
string AlarmName,
AlarmState State,
int Priority,
DateTimeOffset Timestamp) : ISiteStreamEvent
{
/// <summary>
/// Severity level when <see cref="State"/> is <see cref="AlarmState.Active"/>.
/// Always <see cref="AlarmLevel.None"/> for binary trigger types
/// (ValueMatch, RangeViolation, RateOfChange); set by the HiLo trigger
/// type to one of Low/LowLow/High/HighHigh based on the crossed setpoint.
/// Added as an init-property so existing positional constructors still
/// work — message contract evolves additively.
/// </summary>
public AlarmLevel Level { get; init; } = AlarmLevel.None;
/// <summary>
/// Optional per-band operator message (e.g., "Coolant critically low —
/// shut down"). Set by HiLo triggers when the per-setpoint message is
/// configured; otherwise empty. Notification routing and UI tooltips may
/// surface this to operators.
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// Whether this alarm is computed at the site or mirrored from a native
/// source. Defaults to <see cref="AlarmKind.Computed"/>.
/// </summary>
public AlarmKind Kind { get; init; } = AlarmKind.Computed;
private AlarmConditionState? _condition;
/// <summary>
/// Unified A&amp;C-style condition (active/acked/shelved/suppressed + severity).
/// When not explicitly set, defaults to a computed mapping of
/// <see cref="State"/> + <see cref="Priority"/> so existing callers and
/// computed alarms carry a correct condition without extra work.
/// </summary>
public AlarmConditionState Condition
{
get => _condition ?? AlarmConditionStateFactory.ForComputed(State, Priority);
init => _condition = value;
}
/// <summary>Native per-condition key (e.g. "Tank01.Level.HiHi"); empty for computed alarms.</summary>
public string SourceReference { get; init; } = string.Empty;
/// <summary>Native alarm type name (e.g. "AnalogLimitAlarm.HiHi"); empty for computed alarms.</summary>
public string AlarmTypeName { get; init; } = string.Empty;
/// <summary>Native alarm category/taxonomy; empty for computed alarms.</summary>
public string Category { get; init; } = string.Empty;
/// <summary>Operator who acknowledged at the source (display-only); empty otherwise.</summary>
public string OperatorUser { get; init; } = string.Empty;
/// <summary>Operator comment captured at the source (display-only); empty otherwise.</summary>
public string OperatorComment { get; init; } = string.Empty;
/// <summary>When the native condition originally became active, if known.</summary>
public DateTimeOffset? OriginalRaiseTime { get; init; }
/// <summary>
/// When the condition was acknowledged, or <c>null</c> while it is unacknowledged.
/// Additive native-mirror enrichment (MES alarm-status API §6.4) — the ack timestamp
/// the MES <c>AlarmInfo.AckDT</c> field reports and the <c>Alarms.CurrentAsync()</c>
/// script accessor surfaces as <c>ScriptAlarm.AckTime</c>.
///
/// <para>
/// Provenance: the DCL stamps the SOURCE's own ack instant where the protocol supplies
/// one (OPC UA A&amp;C exposes <c>AckedState/TransitionTime</c>); where it does not
/// (MxAccess Gateway alarm events), the DCL stamps the transition time it OBSERVED the
/// ack at. It is therefore never fabricated, but for MxGateway sources it is accurate
/// to "when the system saw the ack", not necessarily to the operator's click.
/// </para>
///
/// <para>
/// Null for computed alarms (they are auto-acked and have no operator ack event) and
/// null while a native condition is unacknowledged; cleared on re-raise, because a
/// re-raise transition carries <c>Acknowledged = false</c>.
/// </para>
/// </summary>
public DateTimeOffset? AckTime { get; init; }
/// <summary>Current source value (display-only); empty for computed alarms.</summary>
public string CurrentValue { get; init; } = string.Empty;
/// <summary>Limit/threshold value for native limit alarms (display-only); empty otherwise.</summary>
public string LimitValue { get; init; } = string.Empty;
/// <summary>
/// Canonical name of the native alarm SOURCE BINDING this condition belongs to
/// (e.g. "Motor1.MotorAlarms"). Lets the Debug View nest live native conditions
/// under their configured binding node. Empty for computed alarms. Additive.
/// </summary>
public string NativeSourceCanonicalName { get; init; } = string.Empty;
/// <summary>
/// True when this row is a placeholder emitted for a CONFIGURED native source
/// binding that currently has no active conditions, so the Debug View tree can
/// show the binding node as "no active conditions". Additive; default false.
/// </summary>
public bool IsConfiguredPlaceholder { get; init; }
}