Files
lmxopcua/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs
T
Joseph Doherty 64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs: backfill XML documentation across 756 files
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
2026-05-28 08:10:17 -04:00

268 lines
9.5 KiB
C#

using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
/// <summary>
/// ViewModel for the alarms panel.
/// </summary>
public partial class AlarmsViewModel : ObservableObject
{
private static readonly ILogger Logger = Log.ForContext<AlarmsViewModel>();
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
/// <summary>
/// Last user-visible status message — set when an alarm subscribe / unsubscribe / refresh
/// operation fails so the shell can surface the diagnostic instead of silently dropping it.
/// Genuine failures are distinguished from "feature not supported" (condition refresh).
/// </summary>
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private int _interval = 1000;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
[NotifyCanExecuteChangedFor(nameof(UnsubscribeCommand))]
[NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
private bool _isConnected;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
[NotifyCanExecuteChangedFor(nameof(UnsubscribeCommand))]
[NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
private bool _isSubscribed;
[ObservableProperty] private string? _monitoredNodeIdText;
[ObservableProperty] private int _activeAlarmCount;
/// <summary>Initializes a new instance of the AlarmsViewModel class.</summary>
/// <param name="service">The OPC UA client service.</param>
/// <param name="dispatcher">The UI dispatcher for thread-safe operations.</param>
public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
_dispatcher = dispatcher;
_service.AlarmEvent += OnAlarmEvent;
}
/// <summary>Received alarm events.</summary>
public ObservableCollection<AlarmEventViewModel> AlarmEvents { get; } = [];
private void OnAlarmEvent(object? sender, AlarmEventArgs e)
{
// Only display alarm/condition events (those with a ConditionName), not generic events
if (string.IsNullOrEmpty(e.ConditionName)) return;
_dispatcher.Post(() =>
{
// Find existing row by source + condition and update it, or add new
var existing = AlarmEvents.FirstOrDefault(a =>
a.SourceName == e.SourceName && a.ConditionName == e.ConditionName);
if (existing != null)
{
var index = AlarmEvents.IndexOf(existing);
AlarmEvents[index] = new AlarmEventViewModel(
e.SourceName, e.ConditionName, e.Severity, e.Message,
e.Retain, e.ActiveState, e.AckedState, e.Time,
e.EventId, e.ConditionNodeId);
// Remove alarms that are no longer retained
if (!e.Retain)
AlarmEvents.RemoveAt(index);
}
else if (e.Retain)
{
AlarmEvents.Add(new AlarmEventViewModel(
e.SourceName, e.ConditionName, e.Severity, e.Message,
e.Retain, e.ActiveState, e.AckedState, e.Time,
e.EventId, e.ConditionNodeId));
}
ActiveAlarmCount = AlarmEvents.Count(a => a.ActiveState && !a.AckedState);
});
}
private bool CanSubscribe()
{
return IsConnected && !IsSubscribed;
}
[RelayCommand(CanExecute = nameof(CanSubscribe))]
private async Task SubscribeAsync()
{
try
{
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
? null
: NodeId.Parse(MonitoredNodeIdText);
await _service.SubscribeAlarmsAsync(sourceNodeId, Interval);
IsSubscribed = true;
StatusMessage = null;
try
{
await _service.RequestConditionRefreshAsync();
}
catch (Exception refreshEx)
{
// Condition refresh is optional on the server side — log at info level and surface
// a soft notice rather than a hard failure so the operator can tell apart "server
// does not advertise refresh" from a genuine subscribe failure.
Logger.Information(refreshEx, "RequestConditionRefresh not supported by server");
StatusMessage = "Condition refresh not supported by server (subscribed).";
}
}
catch (Exception ex)
{
Logger.Warning(ex, "SubscribeAlarms failed for {Source}", MonitoredNodeIdText ?? "(all)");
StatusMessage = $"Subscribe to alarms failed: {ex.Message}";
}
}
private bool CanUnsubscribe()
{
return IsConnected && IsSubscribed;
}
[RelayCommand(CanExecute = nameof(CanUnsubscribe))]
private async Task UnsubscribeAsync()
{
try
{
await _service.UnsubscribeAlarmsAsync();
IsSubscribed = false;
StatusMessage = null;
}
catch (Exception ex)
{
Logger.Warning(ex, "UnsubscribeAlarms failed");
StatusMessage = $"Unsubscribe alarms failed: {ex.Message}";
}
}
[RelayCommand(CanExecute = nameof(CanUnsubscribe))]
private async Task RefreshAsync()
{
try
{
await _service.RequestConditionRefreshAsync();
StatusMessage = null;
}
catch (Exception ex)
{
// Same as the subscribe-time fallback: refresh is server-side optional. Information-
// level log + soft status so the operator sees why an explicit refresh did nothing.
Logger.Information(ex, "RequestConditionRefresh not supported by server");
StatusMessage = "Condition refresh not supported by server.";
}
}
/// <summary>
/// Acknowledges an alarm and returns (success, message).
/// </summary>
/// <param name="alarm">The alarm event to acknowledge.</param>
/// <param name="comment">Optional comment for the acknowledgment.</param>
/// <returns>A tuple with success flag and message.</returns>
public async Task<(bool Success, string Message)> AcknowledgeAlarmAsync(AlarmEventViewModel alarm, string comment)
{
if (!IsConnected || alarm.EventId == null || alarm.ConditionNodeId == null)
return (false, "Alarm cannot be acknowledged (missing EventId or ConditionId).");
try
{
var result = await _service.AcknowledgeAlarmAsync(alarm.ConditionNodeId, alarm.EventId, comment);
if (Opc.Ua.StatusCode.IsGood(result))
return (true, "Alarm acknowledged successfully.");
return (false, $"Acknowledge failed: {Helpers.StatusCodeFormatter.Format(result)}");
}
catch (Exception ex)
{
return (false, $"Error: {ex.Message}");
}
}
/// <summary>
/// Returns the monitored node ID for persistence, or null if not subscribed.
/// </summary>
public string? GetAlarmSourceNodeId()
{
return IsSubscribed ? MonitoredNodeIdText : null;
}
/// <summary>
/// Restores an alarm subscription and requests a condition refresh.
/// </summary>
/// <param name="sourceNodeId">The source node ID to restore the subscription for.</param>
/// <returns>A task that completes when the restore operation finishes.</returns>
public async Task RestoreAlarmSubscriptionAsync(string? sourceNodeId)
{
if (!IsConnected || string.IsNullOrWhiteSpace(sourceNodeId)) return;
MonitoredNodeIdText = sourceNodeId;
try
{
var nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
? null
: NodeId.Parse(sourceNodeId);
await _service.SubscribeAlarmsAsync(nodeId, Interval);
IsSubscribed = true;
StatusMessage = null;
try
{
await _service.RequestConditionRefreshAsync();
}
catch (Exception refreshEx)
{
Logger.Information(refreshEx, "RequestConditionRefresh not supported by server (restore path)");
StatusMessage = "Condition refresh not supported by server (restored subscription).";
}
}
catch (Exception ex)
{
Logger.Warning(ex, "RestoreAlarmSubscription failed for {Source}", sourceNodeId);
StatusMessage = $"Restore alarm subscription failed: {ex.Message}";
}
}
/// <summary>
/// Clears alarm events and resets state.
/// </summary>
public void Clear()
{
AlarmEvents.Clear();
IsSubscribed = false;
ActiveAlarmCount = 0;
}
/// <summary>
/// Re-hooks event handlers to the service after a server-side reconnect.
/// Safe to call when already attached (duplicate += is a no-op in .NET multicast delegates).
/// </summary>
public void Reattach()
{
_service.AlarmEvent -= OnAlarmEvent;
_service.AlarmEvent += OnAlarmEvent;
}
/// <summary>
/// Unhooks event handlers from the service.
/// </summary>
public void Teardown()
{
_service.AlarmEvent -= OnAlarmEvent;
}
}