01bcca992c
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.
146 lines
6.3 KiB
C#
146 lines
6.3 KiB
C#
using System.Threading.Channels;
|
|
using Akka.Actor;
|
|
using Akka.Event;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
using AlarmState = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState;
|
|
using AlarmLevel = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmLevel;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
|
|
|
/// <summary>
|
|
/// Lightweight relay actor that bridges Akka domain events (AttributeValueChanged,
|
|
/// AlarmStateChanged) to a System.Threading.Channels.Channel of protobuf SiteStreamEvent
|
|
/// messages. The gRPC server method reads from the channel's reader side.
|
|
/// </summary>
|
|
public class StreamRelayActor : ReceiveActor
|
|
{
|
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
|
private readonly string _correlationId;
|
|
private readonly ChannelWriter<SiteStreamEvent> _channelWriter;
|
|
|
|
/// <summary>
|
|
/// Initializes a new <see cref="StreamRelayActor"/> for the given gRPC stream correlation.
|
|
/// </summary>
|
|
/// <param name="correlationId">Correlation id stamped on every relayed <see cref="SiteStreamEvent"/>.</param>
|
|
/// <param name="channelWriter">Channel writer to which converted events are written.</param>
|
|
public StreamRelayActor(string correlationId, ChannelWriter<SiteStreamEvent> channelWriter)
|
|
{
|
|
_correlationId = correlationId;
|
|
_channelWriter = channelWriter;
|
|
|
|
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
|
|
Receive<AlarmStateChanged>(HandleAlarmStateChanged);
|
|
}
|
|
|
|
private void HandleAttributeValueChanged(AttributeValueChanged msg)
|
|
{
|
|
var protoEvent = new SiteStreamEvent
|
|
{
|
|
CorrelationId = _correlationId,
|
|
AttributeChanged = new AttributeValueUpdate
|
|
{
|
|
InstanceUniqueName = msg.InstanceUniqueName,
|
|
AttributePath = msg.AttributePath,
|
|
AttributeName = msg.AttributeName,
|
|
Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty,
|
|
Quality = MapQuality(msg.Quality),
|
|
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp)
|
|
}
|
|
};
|
|
|
|
WriteToChannel(protoEvent);
|
|
}
|
|
|
|
private void HandleAlarmStateChanged(AlarmStateChanged msg)
|
|
{
|
|
// Placeholder rows (IsConfiguredPlaceholder) are a Debug View snapshot-only
|
|
// concept emitted by InstanceActor.BuildAlarmStatesSnapshot — they are never a
|
|
// real alarm transition and must not be relayed to the live gRPC stream (their
|
|
// timestamp may be DateTimeOffset.MinValue, the Protobuf Timestamp boundary).
|
|
if (msg.IsConfiguredPlaceholder)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var protoEvent = new SiteStreamEvent
|
|
{
|
|
CorrelationId = _correlationId,
|
|
AlarmChanged = new AlarmStateUpdate
|
|
{
|
|
InstanceUniqueName = msg.InstanceUniqueName,
|
|
AlarmName = msg.AlarmName,
|
|
State = MapAlarmState(msg.State),
|
|
Priority = msg.Priority,
|
|
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp),
|
|
Level = MapAlarmLevel(msg.Level),
|
|
Message = msg.Message ?? string.Empty,
|
|
// Native alarm enrichment (additive — computed alarms map their default condition).
|
|
Kind = msg.Kind.ToString(),
|
|
Active = msg.Condition.Active,
|
|
Acknowledged = msg.Condition.Acknowledged,
|
|
Confirmed = msg.Condition.Confirmed ?? false,
|
|
ShelveState = AlarmShelveStateCodec.ToWire(msg.Condition.Shelve),
|
|
Suppressed = msg.Condition.Suppressed,
|
|
SourceReference = msg.SourceReference ?? string.Empty,
|
|
AlarmTypeName = msg.AlarmTypeName ?? string.Empty,
|
|
Category = msg.Category ?? string.Empty,
|
|
OperatorUser = msg.OperatorUser ?? string.Empty,
|
|
OperatorComment = msg.OperatorComment ?? string.Empty,
|
|
OriginalRaiseTime = msg.OriginalRaiseTime.HasValue
|
|
? Timestamp.FromDateTimeOffset(msg.OriginalRaiseTime.Value)
|
|
: null,
|
|
CurrentValue = msg.CurrentValue ?? string.Empty,
|
|
LimitValue = msg.LimitValue ?? string.Empty,
|
|
NativeSourceCanonicalName = msg.NativeSourceCanonicalName ?? string.Empty,
|
|
IsConfiguredPlaceholder = msg.IsConfiguredPlaceholder,
|
|
// MES alarm-status API §6.4: ack instant, null while unacknowledged and on
|
|
// computed alarms — a null proto Timestamp round-trips back to null.
|
|
AckTime = msg.AckTime.HasValue
|
|
? Timestamp.FromDateTimeOffset(msg.AckTime.Value)
|
|
: null
|
|
}
|
|
};
|
|
|
|
WriteToChannel(protoEvent);
|
|
}
|
|
|
|
private void WriteToChannel(SiteStreamEvent protoEvent)
|
|
{
|
|
// TryWrite on a DropOldest bounded channel never returns false for a "full"
|
|
// channel — eviction of the oldest item is what happens instead, and it is
|
|
// counted by the channel's itemDropped callback (SiteStreamGrpcServer). This
|
|
// branch therefore guards only a completed/closed channel, not backpressure.
|
|
if (!_channelWriter.TryWrite(protoEvent))
|
|
{
|
|
_log.Debug("Channel closed, dropping event for correlation {0}", _correlationId);
|
|
}
|
|
}
|
|
|
|
private static Quality MapQuality(string quality) => quality switch
|
|
{
|
|
"Good" => Quality.Good,
|
|
"Uncertain" => Quality.Uncertain,
|
|
"Bad" => Quality.Bad,
|
|
_ => Quality.Unspecified
|
|
};
|
|
|
|
private static AlarmStateEnum MapAlarmState(AlarmState state) => state switch
|
|
{
|
|
AlarmState.Normal => AlarmStateEnum.AlarmStateNormal,
|
|
AlarmState.Active => AlarmStateEnum.AlarmStateActive,
|
|
_ => AlarmStateEnum.AlarmStateUnspecified
|
|
};
|
|
|
|
private static AlarmLevelEnum MapAlarmLevel(AlarmLevel level) => level switch
|
|
{
|
|
AlarmLevel.Low => AlarmLevelEnum.AlarmLevelLow,
|
|
AlarmLevel.LowLow => AlarmLevelEnum.AlarmLevelLowLow,
|
|
AlarmLevel.High => AlarmLevelEnum.AlarmLevelHigh,
|
|
AlarmLevel.HighHigh => AlarmLevelEnum.AlarmLevelHighHigh,
|
|
_ => AlarmLevelEnum.AlarmLevelNone
|
|
};
|
|
}
|