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; /// /// ViewModel for the alarms panel. /// public partial class AlarmsViewModel : ObservableObject { private static readonly ILogger Logger = Log.ForContext(); private readonly IUiDispatcher _dispatcher; private readonly IOpcUaClientService _service; /// /// 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). /// [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; /// Initializes a new instance of the AlarmsViewModel class. /// The OPC UA client service. /// The UI dispatcher for thread-safe operations. public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) { _service = service; _dispatcher = dispatcher; _service.AlarmEvent += OnAlarmEvent; } /// Received alarm events. public ObservableCollection 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."; } } /// /// Acknowledges an alarm and returns (success, message). /// /// The alarm event to acknowledge. /// Optional comment for the acknowledgment. /// A tuple with success flag and message. 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}"); } } /// /// Returns the monitored node ID for persistence, or null if not subscribed. /// public string? GetAlarmSourceNodeId() { return IsSubscribed ? MonitoredNodeIdText : null; } /// /// Restores an alarm subscription and requests a condition refresh. /// /// The source node ID to restore the subscription for. /// A task that completes when the restore operation finishes. 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}"; } } /// /// Clears alarm events and resets state. /// public void Clear() { AlarmEvents.Clear(); IsSubscribed = false; ActiveAlarmCount = 0; } /// /// 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). /// public void Reattach() { _service.AlarmEvent -= OnAlarmEvent; _service.AlarmEvent += OnAlarmEvent; } /// /// Unhooks event handlers from the service. /// public void Teardown() { _service.AlarmEvent -= OnAlarmEvent; } }