diff --git a/Daqifi.Desktop.Test/ViewModels/DaqifiViewModelLoggingStateTests.cs b/Daqifi.Desktop.Test/ViewModels/DaqifiViewModelLoggingStateTests.cs new file mode 100644 index 00000000..3e93467b --- /dev/null +++ b/Daqifi.Desktop.Test/ViewModels/DaqifiViewModelLoggingStateTests.cs @@ -0,0 +1,131 @@ +using System.ComponentModel; +using Daqifi.Desktop.Device; +using Daqifi.Desktop.DialogService; +using Daqifi.Desktop.ViewModels; +using Moq; + +namespace Daqifi.Desktop.Test.ViewModels; + +[TestClass] +public class DaqifiViewModelLoggingStateTests +{ + [TestMethod] + public void IsLogging_ReturnsTrue_WhenConnectedDeviceReportsSdCardLogging() + { + var viewModel = CreateViewModel(); + var device = new Mock(); + device.SetupGet(d => d.IsLoggingToSdCard).Returns(true); + + viewModel.ConnectedDevices.Add(device.Object); + + Assert.IsTrue(viewModel.IsLogging, + "IsLogging should reflect device-reported SD logging even when the toggle was never flipped."); + } + + [TestMethod] + public void IsSdCardLoggingActive_FollowsDevicePropertyChanged() + { + var viewModel = CreateViewModel(); + var device = new Mock(); + var isLoggingToSd = false; + device.SetupGet(d => d.IsLoggingToSdCard).Returns(() => isLoggingToSd); + + viewModel.ConnectedDevices.Add(device.Object); + Assert.IsFalse(viewModel.IsSdCardLoggingActive); + + var raisedProperties = new List(); + viewModel.PropertyChanged += (_, args) => raisedProperties.Add(args.PropertyName); + + isLoggingToSd = true; + device.Raise(d => d.PropertyChanged += null, + new PropertyChangedEventArgs(nameof(IStreamingDevice.IsLoggingToSdCard))); + + Assert.IsTrue(viewModel.IsSdCardLoggingActive, + "IsSdCardLoggingActive should turn true once the device raises PropertyChanged for IsLoggingToSdCard."); + CollectionAssert.Contains(raisedProperties, nameof(DaqifiViewModel.IsSdCardLoggingActive)); + CollectionAssert.Contains(raisedProperties, nameof(DaqifiViewModel.IsLogging)); + } + + [TestMethod] + public void SdLoggingElapsed_ResetsToZero_WhenSdLoggingBegins() + { + var viewModel = CreateViewModel(); + var device = new Mock(); + var isLoggingToSd = false; + device.SetupGet(d => d.IsLoggingToSdCard).Returns(() => isLoggingToSd); + + viewModel.ConnectedDevices.Add(device.Object); + + isLoggingToSd = true; + device.Raise(d => d.PropertyChanged += null, + new PropertyChangedEventArgs(nameof(IStreamingDevice.IsLoggingToSdCard))); + + Assert.AreEqual("00:00:00", viewModel.SdLoggingElapsed, + "Elapsed time should reset to 00:00:00 the moment SD logging begins."); + } + + [TestMethod] + public void RemovingTheLastLoggingDevice_ClearsSdCardLoggingActive() + { + var viewModel = CreateViewModel(); + var device = new Mock(); + device.SetupGet(d => d.IsLoggingToSdCard).Returns(true); + + viewModel.ConnectedDevices.Add(device.Object); + Assert.IsTrue(viewModel.IsSdCardLoggingActive); + + viewModel.ConnectedDevices.Remove(device.Object); + + Assert.IsFalse(viewModel.IsSdCardLoggingActive, + "Once no devices are reporting SD logging, the active flag should clear."); + } + + [TestMethod] + public void DevicePropertyChangedForUnrelatedProperty_DoesNotRaiseLoggingState() + { + var viewModel = CreateViewModel(); + var device = new Mock(); + device.SetupGet(d => d.IsLoggingToSdCard).Returns(false); + + viewModel.ConnectedDevices.Add(device.Object); + + var raised = new List(); + viewModel.PropertyChanged += (_, args) => raised.Add(args.PropertyName); + + device.Raise(d => d.PropertyChanged += null, + new PropertyChangedEventArgs(nameof(IStreamingDevice.MacAddress))); + + CollectionAssert.DoesNotContain(raised, nameof(DaqifiViewModel.IsLogging)); + CollectionAssert.DoesNotContain(raised, nameof(DaqifiViewModel.IsSdCardLoggingActive)); + } + + [TestMethod] + public void Clear_UnsubscribesPropertyChanged_AndDoesNotLeak() + { + // ObservableCollection.Clear() raises a Reset event with OldItems == null; + // a naive subscribe/unsubscribe handler would leak the original subscription. + var viewModel = CreateViewModel(); + var device = new Mock(); + device.SetupGet(d => d.IsLoggingToSdCard).Returns(false); + + viewModel.ConnectedDevices.Add(device.Object); + viewModel.ConnectedDevices.Clear(); + + var raised = new List(); + viewModel.PropertyChanged += (_, args) => raised.Add(args.PropertyName); + + // After Clear(), the device should be unsubscribed — its PropertyChanged + // events must not bubble up to the view model. + device.Raise(d => d.PropertyChanged += null, + new PropertyChangedEventArgs(nameof(IStreamingDevice.IsLoggingToSdCard))); + + CollectionAssert.DoesNotContain(raised, nameof(DaqifiViewModel.IsLogging)); + CollectionAssert.DoesNotContain(raised, nameof(DaqifiViewModel.IsSdCardLoggingActive)); + } + + private static DaqifiViewModel CreateViewModel() + { + var dialogService = new Mock(); + return new DaqifiViewModel(dialogService.Object); + } +} diff --git a/Daqifi.Desktop/View/Prototype/LiveGraphPane.xaml b/Daqifi.Desktop/View/Prototype/LiveGraphPane.xaml index 8a174ea0..e1307ec5 100644 --- a/Daqifi.Desktop/View/Prototype/LiveGraphPane.xaml +++ b/Daqifi.Desktop/View/Prototype/LiveGraphPane.xaml @@ -188,19 +188,40 @@ Foreground="{StaticResource TextTertiary}" VerticalAlignment="Center"/> - - - - - + + + + + + + + + + + + + @@ -328,7 +349,8 @@ + TabIndex="0" + Visibility="{Binding IsSdCardLoggingActive, Converter={StaticResource InvertedBoolToVis}}"> - + - + + + + + - + @@ -386,6 +414,72 @@ Margin="0,4,0,0"/> + + + + + + + + + + + + + + + + + + + + + + diff --git a/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs b/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs index 3074c471..693726c6 100644 --- a/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs +++ b/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs @@ -27,6 +27,7 @@ using System.Net.Http; using System.Windows; using System.Windows.Input; +using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using Daqifi.Desktop.Device.SerialDevice; using System.IO.Ports; @@ -170,6 +171,15 @@ public partial class DaqifiViewModel : ObservableObject private IDiskSpaceMonitor? _diskSpaceMonitor; private ObservableCollection? _observedLoggingSessions; private CancellationTokenSource? _networkSettingsAppliedCts; + private DispatcherTimer? _sdLoggingElapsedTimer; + private DateTime? _sdLoggingStartedAt; + + /// + /// Elapsed time since SD-card logging started in this session, formatted as HH:mm:ss. + /// Driven by a 1Hz DispatcherTimer that runs only while . + /// + [ObservableProperty] + private string _sdLoggingElapsed = "00:00:00"; #endregion #region Properties @@ -190,9 +200,19 @@ public partial class DaqifiViewModel : ObservableObject public DatabaseLogger DbLogger { get; private set; } public SummaryLogger SummaryLogger { get; private set; } + /// + /// True if the user has toggled logging on, OR if any connected device reports + /// it is actively logging to its SD card. Reading from device state ensures the + /// toggle reflects reality even when SD-card logging was started in a prior + /// session and the device kept logging across a desktop reconnect. Streaming-mode + /// state is not tracked here because IsStreaming is not on the + /// interface; the streaming path updates state + /// synchronously through the setter via the toggle, so this getter only needs to + /// supplement that with the SD-card signal. + /// public bool IsLogging { - get => _isLogging; + get => _isLogging || AnyDeviceActivelyLogging(); set { var preSessionWarningShown = false; @@ -256,9 +276,22 @@ public bool IsLogging } } } + + OnPropertyChanged(nameof(IsLogging)); + OnPropertyChanged(nameof(IsSdCardLoggingActive)); } } + /// + /// True when at least one connected device reports it is actively logging to its SD card. + /// Used by the Live Graph to show a "Logging to Device" status panel in place of the + /// (necessarily empty) plot, since SD-mode samples never reach the desktop. + /// + public bool IsSdCardLoggingActive => ConnectedDevices.Any(d => d.IsLoggingToSdCard); + + private bool AnyDeviceActivelyLogging() + => ConnectedDevices.Any(d => d.IsLoggingToSdCard); + public bool CanToggleLogging { get => _canToggleLogging; @@ -297,7 +330,11 @@ public int SelectedStreamingFrequency { if (value < 1) { return; } - if (LoggingManager.Instance.Active) + // Use IsLogging (not LoggingManager.Active) so the guard also blocks + // changes when a connected device is reporting SD-card logging without + // the local toggle ever having been flipped — e.g. on reconnect to a + // device that's still logging from a previous desktop session. + if (IsLogging) { var errorDialogViewModel = new ErrorDialogViewModel("Cannot change sampling frequency while logging."); _dialogService.ShowDialog(this, errorDialogViewModel); @@ -496,6 +533,12 @@ public DaqifiViewModel( ConfirmAffirmativeCommand = new RelayCommand(() => CompleteConfirm(true)); ConfirmNegativeCommand = new RelayCommand(() => CompleteConfirm(false)); + // Track device-side logging/streaming state so the toggle and the Live Graph + // overlay reflect what's *actually* happening, not just what the user clicked. + // This catches cases like reconnecting to a device that's still SD-logging from + // a previous desktop session, where _isLogging would otherwise stay false. + ConnectedDevices.CollectionChanged += OnConnectedDevicesCollectionChanged; + var app = Application.Current as App; if (app != null) { @@ -1796,6 +1839,78 @@ public Task UpdateConnectedDeviceUI() return Task.CompletedTask; } + private readonly HashSet _loggingStateSubscribedDevices = []; + + private void OnConnectedDevicesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // ObservableCollection.Clear() raises a Reset with OldItems == null, so we + // can't rely on the args alone — diff against our tracked-subscription set + // to handle Reset, Replace, and per-item adds/removes uniformly. + var current = new HashSet(ConnectedDevices); + + foreach (var stale in _loggingStateSubscribedDevices.Except(current).ToList()) + { + stale.PropertyChanged -= OnDeviceLoggingStateChanged; + _loggingStateSubscribedDevices.Remove(stale); + } + + foreach (var added in current.Except(_loggingStateSubscribedDevices).ToList()) + { + added.PropertyChanged += OnDeviceLoggingStateChanged; + _loggingStateSubscribedDevices.Add(added); + } + + RaiseLoggingStateChanged(); + } + + private void OnDeviceLoggingStateChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IStreamingDevice.IsLoggingToSdCard)) + { + RaiseLoggingStateChanged(); + } + } + + private void RaiseLoggingStateChanged() + { + OnPropertyChanged(nameof(IsLogging)); + OnPropertyChanged(nameof(IsSdCardLoggingActive)); + UpdateSdLoggingTimer(); + } + + private void UpdateSdLoggingTimer() + { + var active = IsSdCardLoggingActive; + if (active && _sdLoggingElapsedTimer == null) + { + _sdLoggingStartedAt = DateTime.UtcNow; + SdLoggingElapsed = "00:00:00"; + _sdLoggingElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _sdLoggingElapsedTimer.Tick += OnSdLoggingTimerTick; + _sdLoggingElapsedTimer.Start(); + } + else if (!active && _sdLoggingElapsedTimer != null) + { + _sdLoggingElapsedTimer.Stop(); + _sdLoggingElapsedTimer.Tick -= OnSdLoggingTimerTick; + _sdLoggingElapsedTimer = null; + _sdLoggingStartedAt = null; + } + } + + private void OnSdLoggingTimerTick(object? sender, EventArgs e) + { + if (_sdLoggingStartedAt is { } start) + { + var elapsed = DateTime.UtcNow - start; + // TimeSpan's "hh" specifier is the Hours component (0-23), so it wraps + // at 24h. SD-card logging sessions can run arbitrarily long; format off + // TotalHours instead so multi-day sessions display correctly. + var totalHours = (int)elapsed.TotalHours; + SdLoggingElapsed = $"{totalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"; + } + } + public async void UpdateUi(object sender, PropertyChangedEventArgs args) {