using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Pcie8586Probe.Acquisition; using Pcie8586Probe.Hardware; using Pcie8586Probe.Models; namespace Pcie8586Probe.ViewModels; public sealed partial class MainViewModel : ObservableObject, IDisposable { private readonly IDigitizer _simulatedDigitizer = new SimulatedDigitizer(); private readonly IDigitizer _hardwareDigitizer = new Pcie8586Digitizer(); private readonly Recorder _recorder = new(); private readonly object _frameGate = new(); private readonly SynchronizationContext? _uiContext; private CancellationTokenSource? _acquisitionCts; private Task? _acquisitionTask; private IDigitizer? _activeDigitizer; private RingHistoryBuffer? _history; private SampleBlock? _lastCapturedBlock; private SampleBlock? _latestFrame; private SampleBlock? _latestRenderFrame; private SampleBlock? _pendingPlotBlock; private bool _plotClearRequested; private InputRange _configuredRange = InputRange.PlusMinus5V; public MainViewModel() { _uiContext = SynchronizationContext.Current; OutputDirectory = Path.Combine(AppContext.BaseDirectory, "Output"); ChannelOptions = [1, 2, 4, 8]; ClockDividerOptions = [1, 2, 5, 10, 20, 50, 100, 1_000, 10_000, 100_000]; RangeOptions = [new InputRangeOption(InputRange.PlusMinus5V, "\u00B15 V"), new InputRangeOption(InputRange.PlusMinus1V, "\u00B11 V")]; SelectedRangeOption = RangeOptions[0]; _ = RefreshDevicesAsync(); } public ObservableCollection Devices { get; } = []; public ObservableCollection ChannelRows { get; } = []; public IReadOnlyList ChannelOptions { get; } public IReadOnlyList ClockDividerOptions { get; } public IReadOnlyList RangeOptions { get; } [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(OpenCommand))] private DeviceInfo? selectedDevice; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(StartCommand))] [NotifyCanExecuteChangedFor(nameof(CloseCommand))] private bool isOpen; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(StartCommand))] [NotifyCanExecuteChangedFor(nameof(StopCommand))] [NotifyCanExecuteChangedFor(nameof(RecordCommand))] private bool isRunning; [ObservableProperty] private bool includeSimulatedDevice; [ObservableProperty] private string statusText = "\u5C31\u7EEA"; [ObservableProperty] private int selectedChannelCount = 1; [ObservableProperty] private InputRangeOption selectedRangeOption; [ObservableProperty] private int selectedClockDivider = 100; [ObservableProperty] private long finiteSamplesPerChannel = 10_000; [ObservableProperty] private bool useFiniteMode; [ObservableProperty] private string outputDirectory; [ObservableProperty] private int clipStartSample; [ObservableProperty] private int clipEndSample; [ObservableProperty] private double recordingProgress; [ObservableProperty] private string lastRecordingPath = string.Empty; [ObservableProperty] private string lastOutputFolder = string.Empty; public double ActualSampleRateHz => AcquisitionConfig.BaseSampleRateHz / SelectedClockDivider; public InputRange ConfiguredRange => _configuredRange; partial void OnSelectedClockDividerChanged(int value) { OnPropertyChanged(nameof(ActualSampleRateHz)); } partial void OnSelectedChannelCountChanged(int value) { ResetChannelRows(value); } partial void OnIncludeSimulatedDeviceChanged(bool value) { _ = RefreshDevicesAsync(); } [RelayCommand] private async Task RefreshDevicesAsync() { Devices.Clear(); SelectedDevice = null; foreach (var device in await _hardwareDigitizer.EnumerateDevicesAsync(CancellationToken.None)) { Devices.Add(device); } if (IncludeSimulatedDevice) { foreach (var device in await _simulatedDigitizer.EnumerateDevicesAsync(CancellationToken.None)) { Devices.Add(device); } } SelectedDevice = Devices.FirstOrDefault(); StatusText = string.Format("\u5DF2\u627E\u5230 {0} \u4E2A\u8BBE\u5907", Devices.Count); } private bool CanOpen() => SelectedDevice is not null && !IsOpen; [RelayCommand(CanExecute = nameof(CanOpen))] private async Task OpenAsync() { if (SelectedDevice is null) { return; } _activeDigitizer = SelectedDevice.IsSimulated ? _simulatedDigitizer : _hardwareDigitizer; await _activeDigitizer.OpenAsync(SelectedDevice, CancellationToken.None); IsOpen = true; StatusText = string.Format("\u5DF2\u6253\u5F00 {0}", SelectedDevice.Description); } private bool CanClose() => IsOpen && !IsRunning; [RelayCommand(CanExecute = nameof(CanClose))] private async Task CloseAsync() { if (_activeDigitizer is not null) { await _activeDigitizer.CloseAsync(); } IsOpen = false; StatusText = "\u5DF2\u5173\u95ED"; } private bool CanStart() => IsOpen && !IsRunning; [RelayCommand(CanExecute = nameof(CanStart))] private async Task StartAsync() { if (_activeDigitizer is null) { return; } var config = CurrentConfig(); await _activeDigitizer.ConfigureAsync(config, CancellationToken.None); _configuredRange = config.InputRange; _history = new RingHistoryBuffer(config.SampleRateHz, seconds: 10); _lastCapturedBlock = null; _pendingPlotBlock = null; _acquisitionCts = new CancellationTokenSource(); ChannelRows.Clear(); _latestRenderFrame = null; IsRunning = true; StatusText = "\u6B63\u5728\u91C7\u96C6"; _acquisitionTask = Task.Run(async () => { try { await foreach (var block in _activeDigitizer.StartAcquisitionAsync(_acquisitionCts.Token)) { lock (_frameGate) { _latestFrame = block; } _history.Add(block); var completed = _recorder.Add(block); if (completed is not null) { var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var result = await WaveformWriter.WriteAsync(completed, OutputDirectory, $"record_{stamp}", _configuredRange, _acquisitionCts.Token); RunOnUi(() => { _lastCapturedBlock = completed; _pendingPlotBlock = completed; LastRecordingPath = result.BasePath; LastOutputFolder = Path.GetDirectoryName(result.BasePath) ?? OutputDirectory; StatusText = string.Format("\u5F55\u5236\u5B8C\u6210\uFF1A\u6BCF\u901A\u9053 {0} \u70B9", completed.SamplesPerChannel); }); } } } catch (OperationCanceledException) { } catch (Exception ex) { RunOnUi(() => StatusText = ex.Message); } finally { RunOnUi(() => IsRunning = false); } }); } private bool CanStop() => IsRunning; [RelayCommand(CanExecute = nameof(CanStop))] private async Task StopAsync() { _acquisitionCts?.Cancel(); if (_acquisitionTask is not null) { try { await _acquisitionTask; } catch (OperationCanceledException) { } } if (_activeDigitizer is not null) { await _activeDigitizer.StopAsync(); } IsRunning = false; StatusText = "\u5DF2\u505C\u6B62"; } private bool CanRecord() => IsRunning; [RelayCommand(CanExecute = nameof(CanRecord))] private void Record(string durationMs) { if (!int.TryParse(durationMs, out var milliseconds)) { StatusText = "\u5F55\u5236\u65F6\u957F\u65E0\u6548"; return; } _recorder.Start(TimeSpan.FromMilliseconds(milliseconds), ActualSampleRateHz); RecordingProgress = 0; StatusText = string.Format("\u6B63\u5728\u5F55\u5236 {0} ms", milliseconds); } [RelayCommand] private async Task SaveClipAsync() { if (_history is null) { StatusText = "\u6682\u65E0\u5386\u53F2\u6570\u636E"; return; } var block = _history.Slice(ClipStartSample, ClipEndSample); if (block is null) { StatusText = "\u526A\u8F91\u8303\u56F4\u65E0\u6548"; return; } var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var result = await WaveformWriter.WriteAsync(block, OutputDirectory, $"clip_{stamp}", _configuredRange, CancellationToken.None); _lastCapturedBlock = block; _pendingPlotBlock = block; LastRecordingPath = result.BasePath; LastOutputFolder = Path.GetDirectoryName(result.BasePath) ?? OutputDirectory; StatusText = string.Format("\u526A\u8F91\u5DF2\u4FDD\u5B58\uFF1A\u6BCF\u901A\u9053 {0} \u70B9", block.SamplesPerChannel); } [RelayCommand] private void OpenLastFolder() { var folder = Directory.Exists(LastOutputFolder) ? LastOutputFolder : OutputDirectory; Directory.CreateDirectory(folder); Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true }); } [RelayCommand] private void Reset() { ChannelRows.Clear(); RecordingProgress = 0; ClipStartSample = 0; ClipEndSample = 0; LastRecordingPath = string.Empty; LastOutputFolder = string.Empty; _lastCapturedBlock = null; _pendingPlotBlock = null; _latestFrame = null; _latestRenderFrame = null; _plotClearRequested = true; StatusText = IsRunning ? "\u6B63\u5728\u91C7\u96C6" : "\u5DF2\u91CD\u7F6E"; } public SampleBlock? ConsumeLatestFrame() { lock (_frameGate) { var frame = _latestFrame; _latestFrame = null; _latestRenderFrame = frame ?? _latestRenderFrame; RecordingProgress = _recorder.Progress; return frame; } } public SampleBlock? ConsumePendingPlotBlock() { var block = _pendingPlotBlock; _pendingPlotBlock = null; return block; } public bool ConsumePlotClearRequest() { var requested = _plotClearRequested; _plotClearRequested = false; return requested; } public void AppendTableRows(SampleBlock block, int maxRows) { if (maxRows <= 0 || block.SamplesPerChannel == 0) { return; } var rowsToAdd = new List(); var startSample = Math.Max(0, block.SamplesPerChannel - Math.Max(1, maxRows / Math.Max(1, block.ChannelCount))); for (var sample = startSample; sample < block.SamplesPerChannel; sample++) { for (var channel = 0; channel < block.ChannelCount; channel++) { rowsToAdd.Add(new ChannelRow(block.StartSampleIndex + sample, channel, block.Channels[channel][sample])); } } rowsToAdd.Sort(static (left, right) => { var sampleCompare = right.SampleIndex.CompareTo(left.SampleIndex); return sampleCompare != 0 ? sampleCompare : string.CompareOrdinal(left.Channel, right.Channel); }); for (var index = rowsToAdd.Count - 1; index >= 0; index--) { ChannelRows.Insert(0, rowsToAdd[index]); } while (ChannelRows.Count > maxRows) { ChannelRows.RemoveAt(ChannelRows.Count - 1); } ClipEndSample = Math.Max(ClipEndSample, (int)Math.Min(int.MaxValue, _history?.SamplesPerChannel ?? block.SamplesPerChannel)); } public void Dispose() { _acquisitionCts?.Cancel(); _simulatedDigitizer.Dispose(); _hardwareDigitizer.Dispose(); } private AcquisitionConfig CurrentConfig() { return new AcquisitionConfig( SelectedChannelCount, SelectedRangeOption.Value, SelectedClockDivider, UseFiniteMode ? AcquisitionMode.Finite : AcquisitionMode.Continuous, FiniteSamplesPerChannel); } private void ResetChannelRows(int count) { ChannelRows.Clear(); } private void RunOnUi(Action action) { if (_uiContext is null || SynchronizationContext.Current == _uiContext) { action(); return; } _uiContext.Post(_ => action(), null); } } public sealed record InputRangeOption(InputRange Value, string DisplayName) { public override string ToString() => DisplayName; }