438 lines
13 KiB
C#
438 lines
13 KiB
C#
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<DeviceInfo> Devices { get; } = [];
|
|
|
|
public ObservableCollection<ChannelRow> ChannelRows { get; } = [];
|
|
|
|
public IReadOnlyList<int> ChannelOptions { get; }
|
|
|
|
public IReadOnlyList<int> ClockDividerOptions { get; }
|
|
|
|
public IReadOnlyList<InputRangeOption> 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<ChannelRow>();
|
|
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;
|
|
}
|