diff --git a/Settings.XamlStyler b/Settings.XamlStyler new file mode 100644 index 0000000..8dcd894 --- /dev/null +++ b/Settings.XamlStyler @@ -0,0 +1,47 @@ +{ + "IndentSize": 4, + "IndentWithTabs": null, + "AttributesTolerance": 5, + "KeepFirstAttributeOnSameLine": false, + "MaxAttributeCharactersPerLine": 80, + "MaxAttributesPerLine": 0, + "NewlineExemptionElements": "RadialGradientBrush, GradientStop, LinearGradientBrush, ScaleTransform, SkewTransform, RotateTransform, TranslateTransform, Trigger, Condition, Setter", + "SeparateByGroups": true, + "AttributeIndentation": 0, + "AttributeIndentationStyle": "Spaces", + "RemoveDesignTimeReferences": false, + "EnableAttributeReordering": true, + "AttributeOrderingRuleGroups": [ + "x:Class", + "xmlns, xmlns:x", + "xmlns:*", + "x:Key, Key, x:Name, Name, x:Uid, Uid, Title", + "Grid.Row, Grid.RowSpan, Grid.Column, Grid.ColumnSpan, Canvas.Left, Canvas.Top, Canvas.Right, Canvas.Bottom", + "Width, Height, MinWidth, MinHeight, MaxWidth, MaxHeight", + "Classes, Theme, Styles", + "Margin, Padding, HorizontalAlignment, VerticalAlignment, HorizontalContentAlignment, VerticalContentAlignment, Panel.ZIndex", + "*:*, *", + "PageSource, PageIndex, Offset, Color, TargetName, Property, Value, StartPoint, EndPoint", + "mc:Ignorable, d:IsDataSource, d:LayoutOverrides, d:IsStaticText", + "Storyboard.*, From, To, Duration" + ], + "FirstLineAttributes": "", + "OrderAttributesByName": true, + "IgnoreDesignTimeReferencePrefix": false, + "PutEndingBracketOnNewLine": false, + "RemoveEndingTagOfEmptyElement": true, + "SpaceBeforeClosingSlash": false, + "RootElementLineBreakRule": "Default", + "ReorderVSM": "Last", + "ReorderGridChildren": false, + "ReorderCanvasChildren": false, + "ReorderSetters": "None", + "FormatMarkupExtension": false, + "NoNewLineMarkupExtensions": "x:Bind, Binding", + "ThicknessSeparator": "Comma", + "ThicknessAttributes": "Margin, Padding, BorderThickness, ThumbnailClipMargin", + "FormatOnSave": true, + "SaveAndCloseOnFormat": true, + "CommentPadding": 2, + "SuppressProcessing": false +} diff --git a/src/Filter.Ello/Filter.Ello.csproj b/src/Filter.Ello/Filter.Ello.csproj index 24435cc..32ff870 100644 --- a/src/Filter.Ello/Filter.Ello.csproj +++ b/src/Filter.Ello/Filter.Ello.csproj @@ -1,7 +1,7 @@  - net10.0 + net9.0 enable enable diff --git a/src/RamanApp/MainWindow.axaml b/src/RamanApp/MainWindow.axaml index 60283e1..37958c6 100644 --- a/src/RamanApp/MainWindow.axaml +++ b/src/RamanApp/MainWindow.axaml @@ -27,6 +27,10 @@ + + + + @@ -280,7 +284,11 @@ - + + + + + diff --git a/src/RamanApp/MainWindow.axaml.cs b/src/RamanApp/MainWindow.axaml.cs index 4c22849..4780484 100644 --- a/src/RamanApp/MainWindow.axaml.cs +++ b/src/RamanApp/MainWindow.axaml.cs @@ -24,6 +24,24 @@ public partial class MainWindow : Window private void OnExitClick(object? sender, RoutedEventArgs e) => Close(); + private async void OnEasyTtlSettingsClick(object? sender, RoutedEventArgs e) + { + var dialog = new Views.EasyTtlSettingsDialog + { + DataContext = Vm.EasyTtl + }; + await dialog.ShowDialog(this); + } + + private async void OnElloSettingsClick(object? sender, RoutedEventArgs e) + { + var dialog = new Views.ElloSettingsDialog + { + DataContext = Vm.Ello + }; + await dialog.ShowDialog(this); + } + private void OnYAxisUnitClick(object? sender, RoutedEventArgs e) { if (sender is MenuItem mi && mi.Header is string unit) @@ -282,4 +300,3 @@ public partial class MainWindow : Window .FirstOrDefault(); } } - diff --git a/src/RamanApp/RamanApp.csproj b/src/RamanApp/RamanApp.csproj index 0f20d48..b0eb3c9 100644 --- a/src/RamanApp/RamanApp.csproj +++ b/src/RamanApp/RamanApp.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/RamanApp/Services/EasyTtlSettingsStore.cs b/src/RamanApp/Services/EasyTtlSettingsStore.cs new file mode 100644 index 0000000..b262dd8 --- /dev/null +++ b/src/RamanApp/Services/EasyTtlSettingsStore.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using Serilog; + +namespace RamanApp.Services; + +public sealed class EasyTtlSettingsStore +{ + private readonly string _path; + + public EasyTtlSettingsStore(string? path = null) + { + _path = path ?? System.IO.Path.Combine(AppContext.BaseDirectory, "settings", "easyttl.json"); + } + + public string Path => _path; + + public EasyTtlSettingsFile Load() + { + if (!File.Exists(_path)) + { + Log.Information("EasyTTL settings file not found path={Path}; using defaults", _path); + return new EasyTtlSettingsFile(); + } + + try + { + var json = File.ReadAllText(_path); + var settings = JsonSerializer.Deserialize(json, EasyTtlJsonContext.Default.EasyTtlSettingsFile); + Log.Information("EasyTTL settings loaded path={Path} channelCount={Count}", + _path, settings?.Channels.Count ?? 0); + return settings ?? new EasyTtlSettingsFile(); + } + catch (Exception ex) + { + Log.Warning(ex, "EasyTTL settings load failed path={Path}; using defaults", _path); + return new EasyTtlSettingsFile(); + } + } + + public void Save(EasyTtlSettingsFile settings) + { + string tmpPath = _path + ".tmp"; + try + { + var dir = System.IO.Path.GetDirectoryName(_path); + if (!string.IsNullOrWhiteSpace(dir)) + Directory.CreateDirectory(dir); + + var json = JsonSerializer.Serialize(settings, EasyTtlJsonContext.Default.EasyTtlSettingsFile); + File.WriteAllText(tmpPath, json); + File.Move(tmpPath, _path, true); + Log.Information("EasyTTL settings saved path={Path} channelCount={Count}", + _path, settings.Channels.Count); + } + catch (Exception ex) + { + TryDelete(tmpPath); + Log.Warning(ex, "EasyTTL settings save failed path={Path}", _path); + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Best effort cleanup only. + } + } +} + +public sealed class EasyTtlSettingsFile +{ + public int Version { get; set; } = 1; + + public List Channels { get; set; } = []; +} + +public sealed class EasyTtlChannelSettings +{ + public int Channel { get; set; } + + public string? Alias { get; set; } + + public bool? ShowInLaserPanel { get; set; } +} + +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(EasyTtlSettingsFile))] +internal sealed partial class EasyTtlJsonContext : JsonSerializerContext; diff --git a/src/RamanApp/Services/ElloSettingsStore.cs b/src/RamanApp/Services/ElloSettingsStore.cs new file mode 100644 index 0000000..b9512ea --- /dev/null +++ b/src/RamanApp/Services/ElloSettingsStore.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using Serilog; + +namespace RamanApp.Services; + +public sealed class ElloSettingsStore +{ + private readonly string _path; + + public ElloSettingsStore(string? path = null) + { + _path = path ?? System.IO.Path.Combine(AppContext.BaseDirectory, "settings", "ello.json"); + } + + public ElloSettingsFile Load() + { + if (!File.Exists(_path)) + return new ElloSettingsFile(); + + try + { + var json = File.ReadAllText(_path); + return JsonSerializer.Deserialize(json, ElloJsonContext.Default.ElloSettingsFile) + ?? new ElloSettingsFile(); + } + catch (Exception ex) + { + Log.Warning(ex, "ELLO settings load failed path={Path}; using defaults", _path); + return new ElloSettingsFile(); + } + } + + public void Save(ElloSettingsFile settings) + { + string tmpPath = _path + ".tmp"; + try + { + var dir = System.IO.Path.GetDirectoryName(_path); + if (!string.IsNullOrWhiteSpace(dir)) + Directory.CreateDirectory(dir); + + var json = JsonSerializer.Serialize(settings, ElloJsonContext.Default.ElloSettingsFile); + File.WriteAllText(tmpPath, json); + File.Move(tmpPath, _path, true); + Log.Information("ELLO settings saved path={Path} deviceCount={DeviceCount}", + _path, settings.Devices.Count); + } + catch (Exception ex) + { + TryDelete(tmpPath); + Log.Warning(ex, "ELLO settings save failed path={Path}", _path); + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Best effort cleanup only. + } + } +} + +public sealed class ElloSettingsFile +{ + public int Version { get; set; } = 2; + + public List Devices { get; set; } = []; + + public string? PortName { get; set; } + + public string? Address { get; set; } + + public int? SlotCount { get; set; } + + public List? Slots { get; set; } +} + +public sealed class ElloDeviceSettings +{ + public string? Name { get; set; } + + public string? PortName { get; set; } + + public string Address { get; set; } = "0"; + + public int SlotCount { get; set; } = 6; + + public bool ShowInLaserPanel { get; set; } = true; + + public List Slots { get; set; } = []; +} + +public sealed class ElloSlotSettings +{ + public int Slot { get; set; } + + public string? Alias { get; set; } + + public bool? ShowInLaserPanel { get; set; } +} + +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(ElloSettingsFile))] +internal sealed partial class ElloJsonContext : JsonSerializerContext; diff --git a/src/RamanApp/ViewModels/EasyTtlViewModel.cs b/src/RamanApp/ViewModels/EasyTtlViewModel.cs new file mode 100644 index 0000000..7726f70 --- /dev/null +++ b/src/RamanApp/ViewModels/EasyTtlViewModel.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.ObjectModel; +using System.IO.Ports; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using EasyTTL.Lib; +using RamanApp.Services; +using Serilog; + +namespace RamanApp.ViewModels; + +public partial class EasyTtlViewModel : ObservableObject, IDisposable +{ + private const int FixedFrequencyHz = 1; + private const double FixedDutyPercent = 100; + private const string SimulatedPortName = "SIM-EASYTTL"; + + private EasyTtlDevice? _device; + private readonly bool _simulate; + private readonly EasyTtlSettingsStore _settingsStore; + private bool _suppressHardwareUpdates; + + [ObservableProperty] + public partial ObservableCollection PortNames { get; set; } = []; + + [ObservableProperty] + public partial string? SelectedPortName { get; set; } + + [ObservableProperty] + public partial bool IsConnected { get; set; } + + [ObservableProperty] + public partial bool OutputEnabled { get; set; } + + [ObservableProperty] + public partial ObservableCollection Channels { get; set; } = + [ + new(1, "CH1"), + new(2, "CH2"), + new(3, "CH3"), + new(4, "CH4"), + new(5, "CH5"), + new(6, "CH6"), + ]; + + [ObservableProperty] + public partial string StatusText { get; set; } = "未连接"; + + public EasyTtlViewModel(bool simulate = false, EasyTtlSettingsStore? settingsStore = null) + { + _simulate = simulate; + _settingsStore = settingsStore ?? new EasyTtlSettingsStore(); + LoadSavedSettings(); + RefreshPorts(); + foreach (var channel in Channels) + channel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(EasyTtlChannelViewModel.IsEnabled)) + { + Log.Information("EasyTTL logical channel state changed channel={Channel} enabled={Enabled}", + channel.Channel, channel.IsEnabled); + } + else if (e.PropertyName == nameof(EasyTtlChannelViewModel.HardwareEnabled)) + { + ApplyHardwareChannel(channel); + } + else if (e.PropertyName == nameof(EasyTtlChannelViewModel.Alias)) + { + SaveSettings(); + Log.Information("EasyTTL channel alias changed channel={Channel} alias={Alias}", + channel.Channel, channel.Alias); + } + else if (e.PropertyName == nameof(EasyTtlChannelViewModel.ShowInLaserPanel)) + { + SaveSettings(); + Log.Information("EasyTTL channel laser panel visibility changed channel={Channel} show={Show}", + channel.Channel, channel.ShowInLaserPanel); + } + }; + + if (_simulate) + StatusText = "模拟模式"; + Log.Information("EasyTTL initialized simulate={Simulate} selectedPort={SelectedPort}", _simulate, SelectedPortName); + } + + private void LoadSavedSettings() + { + var settings = _settingsStore.Load(); + foreach (var saved in settings.Channels) + { + var channel = Channels.FirstOrDefault(c => c.Channel == saved.Channel); + if (channel == null) + continue; + + channel.Alias = string.IsNullOrWhiteSpace(saved.Alias) + ? $"CH{channel.Channel}" + : saved.Alias.Trim(); + channel.ShowInLaserPanel = saved.ShowInLaserPanel ?? true; + } + } + + private void SaveSettings() + { + var settings = new EasyTtlSettingsFile + { + Channels = Channels + .Select(channel => new EasyTtlChannelSettings + { + Channel = channel.Channel, + Alias = channel.Alias, + ShowInLaserPanel = channel.ShowInLaserPanel + }) + .ToList() + }; + + _settingsStore.Save(settings); + } + + [RelayCommand] + private void RefreshPorts() + { + var previous = SelectedPortName; + PortNames.Clear(); + if (_simulate) + { + PortNames.Add(SimulatedPortName); + } + else + { + foreach (var port in SerialPort.GetPortNames()) + PortNames.Add(port); + } + + SelectedPortName = previous != null && PortNames.Contains(previous) + ? previous + : PortNames.Count > 0 ? PortNames[0] : null; + + Log.Information("EasyTTL ports refreshed simulate={Simulate} count={Count} selected={SelectedPort}", + _simulate, PortNames.Count, SelectedPortName ?? "-"); + } + + [RelayCommand] + private void Connect() + { + if (IsConnected || string.IsNullOrWhiteSpace(SelectedPortName)) + return; + + try + { + Log.Information("EasyTTL connecting port={PortName} simulate={Simulate}", SelectedPortName, _simulate); + if (!_simulate) + { + _device = new EasyTtlDevice(SelectedPortName); + _device.Open(); + Log.Information("EasyTTL serial opened port={PortName}", SelectedPortName); + } + + IsConnected = true; + SetHardwareUiOff(); + + if (!_simulate) + { + Log.Information("EasyTTL safe init start: DISABLE then ALL OFF"); + _device!.Disable(); + _device.AllOff(); + for (int channel = 1; channel <= 6; channel++) + _device.SetChannel(channel, FixedFrequencyHz, (float)FixedDutyPercent); + Log.Information("EasyTTL safe init completed frequency={FrequencyHz} duty={DutyPercent:0.##}", + FixedFrequencyHz, FixedDutyPercent); + } + else + { + Log.Information("EasyTTL simulated safe init completed"); + } + + StatusText = _simulate ? "模拟已连接" : $"已连接 {SelectedPortName}"; + Log.Information("EasyTTL connected port={PortName} simulate={Simulate}; output disabled and all channels off", + SelectedPortName, _simulate); + } + catch (Exception ex) + { + _device?.Dispose(); + _device = null; + IsConnected = false; + StatusText = $"连接失败: {ex.Message}"; + Log.Warning(ex, "EasyTTL connect failed port={PortName}", SelectedPortName); + } + } + + [RelayCommand] + private void Disconnect() + { + try + { + if (!_simulate && _device != null) + { + try + { + Log.Information("EasyTTL disconnect safe shutdown start: DISABLE then ALL OFF"); + _device.Disable(); + _device.AllOff(); + Log.Information("EasyTTL disconnect safe shutdown completed"); + } + catch (Exception ex) + { + Log.Warning(ex, "EasyTTL disconnect safe shutdown failed"); + } + } + + _device?.Dispose(); + } + finally + { + _device = null; + IsConnected = false; + SetHardwareUiOff(); + StatusText = "未连接"; + Log.Information("EasyTTL disconnected"); + } + } + + private void ApplyHardwareChannel(EasyTtlChannelViewModel channel) + { + if (_suppressHardwareUpdates) return; + if (!_simulate && _device == null) + { + RevertHardwareChannel(channel); + Log.Warning("EasyTTL channel change ignored because device is not connected channel={Channel} requestedOn={On}", + channel.Channel, channel.HardwareEnabled); + return; + } + + try + { + Log.Information("EasyTTL channel command start channel={Channel} requestedOn={On} frequency={FrequencyHz} duty={DutyPercent:0.##} simulate={Simulate}", + channel.Channel, channel.HardwareEnabled, FixedFrequencyHz, FixedDutyPercent, _simulate); + + if (!_simulate) + { + _device!.SetChannel(channel.Channel, FixedFrequencyHz, (float)FixedDutyPercent); + if (channel.HardwareEnabled) + _device.ChannelOn(channel.Channel); + else + _device.ChannelOff(channel.Channel); + } + + var onChannels = Channels.Where(c => c.HardwareEnabled).Select(c => c.Channel).ToArray(); + StatusText = onChannels.Length == 0 + ? "全部通道 OFF" + : $"ON: CH{string.Join(", CH", onChannels)}"; + Log.Information("EasyTTL hardware channel changed channel={Channel} on={On} frequency={FrequencyHz} duty={DutyPercent:0.##} simulate={Simulate}", + channel.Channel, channel.HardwareEnabled, FixedFrequencyHz, FixedDutyPercent, _simulate); + } + catch (Exception ex) + { + StatusText = $"设置失败: {ex.Message}"; + RevertHardwareChannel(channel); + Log.Warning(ex, "EasyTTL apply channel failed"); + } + } + + partial void OnOutputEnabledChanged(bool value) + { + if (_suppressHardwareUpdates) return; + if (!_simulate && _device == null) + { + RevertOutputEnabled(value); + Log.Warning("EasyTTL output change ignored because device is not connected requestedEnabled={Enabled}", value); + return; + } + + try + { + Log.Information("EasyTTL output command start enabled={Enabled} simulate={Simulate}", value, _simulate); + if (!_simulate) + { + if (value) + _device!.Enable(); + else + _device!.Disable(); + } + + StatusText = _simulate + ? value ? "模拟输出已使能" : "模拟输出已禁用" + : value ? "输出已使能" : "输出已禁用"; + Log.Information("EasyTTL output enabled={Enabled} simulate={Simulate}", value, _simulate); + } + catch (Exception ex) + { + StatusText = $"输出设置失败: {ex.Message}"; + RevertOutputEnabled(value); + Log.Warning(ex, "EasyTTL output toggle failed"); + } + } + + private void SetHardwareUiOff() + { + _suppressHardwareUpdates = true; + try + { + OutputEnabled = false; + foreach (var channel in Channels) + channel.HardwareEnabled = false; + } + finally + { + _suppressHardwareUpdates = false; + } + } + + private void RevertHardwareChannel(EasyTtlChannelViewModel channel) + { + _suppressHardwareUpdates = true; + try + { + channel.HardwareEnabled = !channel.HardwareEnabled; + } + finally + { + _suppressHardwareUpdates = false; + } + } + + private void RevertOutputEnabled(bool attemptedValue) + { + _suppressHardwareUpdates = true; + try + { + OutputEnabled = !attemptedValue; + } + finally + { + _suppressHardwareUpdates = false; + } + } + + public void Dispose() + { + Disconnect(); + GC.SuppressFinalize(this); + } +} + +public partial class EasyTtlChannelViewModel(int channel, string alias) : ObservableObject +{ + public int Channel { get; } = channel; + + [ObservableProperty] + public partial string Alias { get; set; } = alias; + + [ObservableProperty] + public partial bool ShowInLaserPanel { get; set; } = true; + + [ObservableProperty] + public partial bool IsEnabled { get; set; } + + [ObservableProperty] + public partial bool HardwareEnabled { get; set; } + + public string DisplayName => string.IsNullOrWhiteSpace(Alias) + ? $"CH{Channel}" + : Alias; + + partial void OnAliasChanged(string value) + { + OnPropertyChanged(nameof(DisplayName)); + } +} diff --git a/src/RamanApp/ViewModels/ElloFilterViewModel.cs b/src/RamanApp/ViewModels/ElloFilterViewModel.cs new file mode 100644 index 0000000..feebeb4 --- /dev/null +++ b/src/RamanApp/ViewModels/ElloFilterViewModel.cs @@ -0,0 +1,659 @@ +using System; +using System.Collections.ObjectModel; +using System.IO.Ports; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filter.Ello; +using Raman.Core.Filters; +using RamanApp.Services; +using Serilog; + +namespace RamanApp.ViewModels; + +public partial class ElloFilterViewModel : ObservableObject, IAsyncDisposable +{ + internal const string SimulatedPortName = "SIM-ELLO"; + + private readonly bool _simulate; + private readonly ElloSettingsStore _settingsStore; + private bool _suppressSave; + + [ObservableProperty] + public partial ObservableCollection Devices { get; set; } = []; + + public ElloFilterViewModel(bool simulate = false, ElloSettingsStore? settingsStore = null) + { + _simulate = simulate; + _settingsStore = settingsStore ?? new ElloSettingsStore(); + + bool hasSavedConnections = LoadSavedSettings(); + if (Devices.Count == 0) + { + AddDevice(new ElloDeviceViewModel(_simulate, SaveSettings, CreateDefaultDevice(1))); + SaveSettings(); + } + + Log.Information("ELLO initialized simulate={Simulate} deviceCount={DeviceCount}", + _simulate, Devices.Count); + + if (hasSavedConnections) + StartAutoConnect(); + } + + [RelayCommand] + private void AddDevice() + { + AddDevice(new ElloDeviceViewModel(_simulate, SaveSettings, CreateDefaultDevice(Devices.Count + 1))); + SaveSettings(); + } + + [RelayCommand] + private async Task RemoveDeviceAsync(ElloDeviceViewModel? device, CancellationToken cancellationToken) + { + if (device == null) + return; + + await device.DisconnectAsync(cancellationToken); + await device.DisposeAsync(); + Devices.Remove(device); + SaveSettings(); + Log.Information("ELLO device removed name={Name}", device.Name); + } + + private bool LoadSavedSettings() + { + var settings = _settingsStore.Load(); + var devices = settings.Devices.Count > 0 + ? settings.Devices + : LoadLegacySingleDevice(settings); + bool hasSavedConnections = devices.Any(device => !string.IsNullOrWhiteSpace(device.PortName)); + + _suppressSave = true; + try + { + foreach (var device in devices) + AddDevice(new ElloDeviceViewModel(_simulate, SaveSettings, device)); + } + finally + { + _suppressSave = false; + } + + return hasSavedConnections; + } + + private static System.Collections.Generic.List LoadLegacySingleDevice(ElloSettingsFile settings) + { + if (settings.PortName == null && settings.Address == null && settings.SlotCount == null && settings.Slots == null) + return []; + + return + [ + new ElloDeviceSettings + { + Name = "ELLO1", + PortName = settings.PortName, + Address = string.IsNullOrWhiteSpace(settings.Address) ? "0" : settings.Address, + SlotCount = settings.SlotCount is 4 or 6 ? settings.SlotCount.Value : 6, + ShowInLaserPanel = true, + Slots = settings.Slots ?? [] + } + ]; + } + + private ElloDeviceSettings CreateDefaultDevice(int index) + { + var ports = GetAvailablePorts(_simulate); + return new ElloDeviceSettings + { + Name = $"ELLO{index}", + PortName = ports.FirstOrDefault(), + Address = Math.Clamp(index - 1, 0, 15).ToString("X"), + SlotCount = 6, + ShowInLaserPanel = true, + Slots = Enumerable.Range(1, 6) + .Select(slot => new ElloSlotSettings + { + Slot = slot, + Alias = $"轮{slot}", + ShowInLaserPanel = true + }) + .ToList() + }; + } + + private void AddDevice(ElloDeviceViewModel device) + { + Devices.Add(device); + } + + private void StartAutoConnect() + { + foreach (var device in Devices.Where(device => !string.IsNullOrWhiteSpace(device.SelectedPortName))) + _ = AutoConnectAsync(device); + } + + private static async Task AutoConnectAsync(ElloDeviceViewModel device) + { + try + { + Log.Information("ELLO auto connect queued name={Name} port={PortName} address={Address}", + device.Name, device.SelectedPortName, device.Address); + await device.ConnectAsync(CancellationToken.None); + } + catch (Exception ex) + { + Log.Warning(ex, "ELLO auto connect failed name={Name} port={PortName} address={Address}", + device.Name, device.SelectedPortName, device.Address); + } + } + + internal static string[] GetAvailablePorts(bool simulate) + { + if (simulate) + return [SimulatedPortName]; + + return SerialPort.GetPortNames(); + } + + private void SaveSettings() + { + if (_suppressSave) + return; + + _settingsStore.Save(new ElloSettingsFile + { + Version = 2, + Devices = Devices.Select(device => device.ToSettings()).ToList() + }); + } + + public async ValueTask DisposeAsync() + { + foreach (var device in Devices.ToArray()) + await device.DisposeAsync(); + + GC.SuppressFinalize(this); + } +} + +public partial class ElloDeviceViewModel : ObservableObject, IAsyncDisposable +{ + private readonly bool _simulate; + private readonly Action _save; + private bool _suppressSave; + private IFilterWheelDevice? _device; + + [ObservableProperty] + public partial ObservableCollection PortNames { get; set; } = []; + + [ObservableProperty] + public partial string Name { get; set; } + + [ObservableProperty] + public partial string? SelectedPortName { get; set; } + + [ObservableProperty] + public partial string Address { get; set; } + + [ObservableProperty] + public partial ObservableCollection SlotCountOptions { get; set; } = [4, 6]; + + [ObservableProperty] + public partial int SlotCount { get; set; } + + [ObservableProperty] + public partial bool ShowInLaserPanel { get; set; } + + [ObservableProperty] + public partial ObservableCollection Slots { get; set; } = []; + + [ObservableProperty] + public partial ObservableCollection PanelSlots { get; set; } = []; + + [ObservableProperty] + public partial bool IsConnected { get; set; } + + [ObservableProperty] + public partial bool IsMoving { get; set; } + + [ObservableProperty] + public partial int? CurrentSlot { get; set; } + + [ObservableProperty] + public partial string CurrentSlotText { get; set; } = "未连接"; + + [ObservableProperty] + public partial bool ShowCurrentSlotText { get; set; } + + [ObservableProperty] + public partial string CurrentSlotCountText { get; set; } = "-"; + + [ObservableProperty] + public partial string StatusText { get; set; } = "未连接"; + + [ObservableProperty] + public partial string ConnectionHintText { get; set; } = "未连接"; + + [ObservableProperty] + public partial bool ShowConnectionHint { get; set; } = true; + + public ElloDeviceViewModel(bool simulate, Action save, ElloDeviceSettings settings) + { + _simulate = simulate; + _save = save; + + _suppressSave = true; + try + { + Name = string.IsNullOrWhiteSpace(settings.Name) ? "ELLO" : settings.Name.Trim(); + SelectedPortName = settings.PortName; + Address = string.IsNullOrWhiteSpace(settings.Address) ? "0" : settings.Address.Trim().ToUpperInvariant(); + SlotCount = settings.SlotCount is 4 or 6 ? settings.SlotCount : 6; + ShowInLaserPanel = settings.ShowInLaserPanel; + + RebuildSlots(settings.Slots); + RefreshPorts(); + } + finally + { + _suppressSave = false; + } + + if (_simulate) + StatusText = "模拟模式"; + + UpdateConnectionHint(); + } + + [RelayCommand] + private void RefreshPorts() + { + var previous = SelectedPortName; + PortNames.Clear(); + foreach (var port in ElloFilterViewModel.GetAvailablePorts(_simulate)) + PortNames.Add(port); + + SelectedPortName = previous != null && PortNames.Contains(previous) + ? previous + : PortNames.Count > 0 ? PortNames[0] : null; + + Log.Information("ELLO ports refreshed name={Name} simulate={Simulate} count={Count} selected={SelectedPort}", + Name, _simulate, PortNames.Count, SelectedPortName ?? "-"); + } + + [RelayCommand] + public async Task ConnectAsync(CancellationToken cancellationToken) + { + if (IsConnected) + return; + if (string.IsNullOrWhiteSpace(SelectedPortName)) + { + StatusText = "未选择串口"; + return; + } + + try + { + var normalizedAddress = NormalizeAddress(Address); + Log.Information("ELLO connecting name={Name} port={PortName} address={Address} slotCount={SlotCount} simulate={Simulate}", + Name, SelectedPortName, normalizedAddress, SlotCount, _simulate); + + if (!_simulate) + { + _device = new SerialFilterWheelDevice( + new SerialFilterOptions { PortName = SelectedPortName }, + CreateProfile(normalizedAddress)); + + await _device.ConnectAsync(cancellationToken); + await UpdateStatusFromDeviceAsync(cancellationToken); + } + else + { + CurrentSlot = CurrentSlot ?? 1; + CurrentSlotText = GetCurrentSlotText(); + } + + Address = normalizedAddress; + IsConnected = true; + StatusText = _simulate ? "模拟已连接" : $"已连接 {SelectedPortName}:{Address}"; + UpdateConnectionHint(); + Log.Information("ELLO connected name={Name} port={PortName} address={Address} simulate={Simulate}", + Name, SelectedPortName, Address, _simulate); + } + catch (Exception ex) + { + await DisposeDeviceAsync(); + IsConnected = false; + CurrentSlot = null; + CurrentSlotText = "未连接"; + StatusText = $"连接失败: {ex.Message}"; + UpdateConnectionHint(); + Log.Warning(ex, "ELLO connect failed name={Name} port={PortName} address={Address}", + Name, SelectedPortName, Address); + } + } + + [RelayCommand] + public async Task DisconnectAsync(CancellationToken cancellationToken) + { + try + { + if (_device != null) + await _device.DisconnectAsync(cancellationToken); + } + catch (Exception ex) + { + Log.Warning(ex, "ELLO disconnect command failed name={Name}", Name); + } + finally + { + await DisposeDeviceAsync(); + IsConnected = false; + CurrentSlot = null; + CurrentSlotText = "未连接"; + StatusText = "未连接"; + UpdateConnectionHint(); + Log.Information("ELLO disconnected name={Name}", Name); + } + } + + public async Task MoveToSlotAsync(int slot, CancellationToken cancellationToken) + { + if (slot < 1 || slot > SlotCount) + return; + if (IsMoving) + return; + if (!IsConnected) + { + StatusText = "滤光轮未连接"; + UpdateConnectionHint(); + return; + } + + try + { + IsMoving = true; + Log.Information("ELLO move start name={Name} slot={Slot} alias={Alias} simulate={Simulate}", + Name, slot, Slots.FirstOrDefault(s => s.Slot == slot)?.DisplayName ?? "-", _simulate); + StatusText = $"切换到轮{slot}"; + + if (_simulate) + { + await Task.Delay(250, cancellationToken); + CurrentSlot = slot; + CurrentSlotText = GetCurrentSlotText(); + } + else + { + if (_device == null) + throw new InvalidOperationException("ELLO device is not connected."); + + await _device.MoveToAsync(_device.Profile.CreatePosition(slot), cancellationToken); + await UpdateStatusFromDeviceAsync(cancellationToken); + } + + StatusText = $"当前: {CurrentSlotText}"; + Log.Information("ELLO move completed name={Name} slot={Slot} current={CurrentSlotText} simulate={Simulate}", + Name, slot, CurrentSlotText, _simulate); + } + catch (Exception ex) + { + StatusText = $"切换失败: {ex.Message}"; + UpdateConnectionHint(); + Log.Warning(ex, "ELLO move failed name={Name} slot={Slot}", Name, slot); + } + finally + { + IsMoving = false; + } + } + + internal ElloDeviceSettings ToSettings() + { + return new ElloDeviceSettings + { + Name = Name, + PortName = SelectedPortName, + Address = string.IsNullOrWhiteSpace(Address) ? "0" : Address.Trim().ToUpperInvariant(), + SlotCount = SlotCount, + ShowInLaserPanel = ShowInLaserPanel, + Slots = Slots.Select(slot => new ElloSlotSettings + { + Slot = slot.Slot, + Alias = slot.Alias, + ShowInLaserPanel = slot.ShowInLaserPanel + }).ToList() + }; + } + + private void RebuildSlots(System.Collections.Generic.IReadOnlyList? savedSlots = null) + { + var previous = Slots.ToDictionary(slot => slot.Slot, slot => slot); + Slots.Clear(); + + for (int slot = 1; slot <= SlotCount; slot++) + { + var saved = savedSlots?.FirstOrDefault(s => s.Slot == slot); + var alias = saved?.Alias + ?? (previous.TryGetValue(slot, out var old) ? old.Alias : null) + ?? $"轮{slot}"; + var show = saved?.ShowInLaserPanel + ?? (previous.TryGetValue(slot, out var oldShow) ? oldShow.ShowInLaserPanel : true); + + var vm = new ElloSlotViewModel(this, slot, alias) + { + ShowInLaserPanel = show + }; + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ElloSlotViewModel.Alias) + || e.PropertyName == nameof(ElloSlotViewModel.ShowInLaserPanel)) + { + CurrentSlotText = GetCurrentSlotText(); + if (e.PropertyName == nameof(ElloSlotViewModel.ShowInLaserPanel)) + RefreshPanelSlots(); + Save(); + Log.Information("ELLO slot setting changed name={Name} slot={Slot} alias={Alias} show={Show}", + Name, vm.Slot, vm.Alias, vm.ShowInLaserPanel); + } + }; + Slots.Add(vm); + } + + RefreshPanelSlots(); + CurrentSlotText = GetCurrentSlotText(); + CurrentSlotCountText = CurrentSlot is int current ? $"{current}/{SlotCount}" : $"-/{SlotCount}"; + ShowCurrentSlotText = IsConnected && CurrentSlot is not null; + UpdateSlotSelection(); + } + + private void RefreshPanelSlots() + { + PanelSlots.Clear(); + foreach (var slot in Slots.Where(slot => slot.ShowInLaserPanel)) + PanelSlots.Add(slot); + } + + partial void OnNameChanged(string value) => Save(); + + partial void OnSelectedPortNameChanged(string? value) => Save(); + + partial void OnAddressChanged(string value) => Save(); + + partial void OnShowInLaserPanelChanged(bool value) => Save(); + + partial void OnIsConnectedChanged(bool value) + { + foreach (var slot in Slots) + slot.RefreshOwnerState(); + ShowCurrentSlotText = IsConnected && CurrentSlot is not null; + UpdateConnectionHint(); + } + + partial void OnIsMovingChanged(bool value) + { + foreach (var slot in Slots) + slot.RefreshOwnerState(); + } + + partial void OnStatusTextChanged(string value) + { + UpdateConnectionHint(); + } + + partial void OnSlotCountChanged(int value) + { + if (value is not (4 or 6)) + return; + + _suppressSave = true; + try + { + RebuildSlots(); + if (CurrentSlot > value) + CurrentSlot = null; + } + finally + { + _suppressSave = false; + } + + Save(); + CurrentSlotCountText = CurrentSlot is int current ? $"{current}/{SlotCount}" : $"-/{SlotCount}"; + } + + partial void OnCurrentSlotChanged(int? value) + { + CurrentSlotText = GetCurrentSlotText(); + CurrentSlotCountText = value is int slot ? $"{slot}/{SlotCount}" : $"-/{SlotCount}"; + ShowCurrentSlotText = IsConnected && value is not null; + UpdateSlotSelection(); + } + + private void Save() + { + if (!_suppressSave) + _save(); + } + + private FilterWheelProfile CreateProfile(string address) + { + return new FilterWheelProfile( + $"{SelectedPortName}:{address}", + FilterWheelAddress.Create(address), + SlotCount, + Slots.ToDictionary(slot => slot.Slot, slot => slot.DisplayName)); + } + + private async Task UpdateStatusFromDeviceAsync(CancellationToken cancellationToken) + { + if (_device == null) + return; + + var status = await _device.GetFilterStatusAsync(cancellationToken); + CurrentSlot = status.CurrentPosition?.Value; + CurrentSlotText = GetCurrentSlotText(); + CurrentSlotCountText = CurrentSlot is int slot ? $"{slot}/{SlotCount}" : $"-/{SlotCount}"; + ShowCurrentSlotText = IsConnected && CurrentSlot is not null; + UpdateSlotSelection(); + } + + private string GetCurrentSlotText() + { + if (CurrentSlot is not int slot) + return IsConnected ? "未知" : "未连接"; + + var alias = Slots.FirstOrDefault(s => s.Slot == slot)?.DisplayName; + return string.IsNullOrWhiteSpace(alias) ? $"轮{slot}" : alias; + } + + private void UpdateSlotSelection() + { + foreach (var slot in Slots) + slot.IsSelected = slot.Slot == CurrentSlot; + } + + private void UpdateConnectionHint() + { + ShowConnectionHint = !IsConnected; + if (IsConnected) + { + ConnectionHintText = ""; + return; + } + + ConnectionHintText = StatusText.StartsWith("连接失败", StringComparison.Ordinal) + || StatusText.StartsWith("未选择", StringComparison.Ordinal) + ? StatusText + : "未连接"; + } + + private static string NormalizeAddress(string value) + { + return FilterWheelAddress.Create(value).Value.ToString(); + } + + private async Task DisposeDeviceAsync() + { + if (_device == null) + return; + + try + { + await _device.DisposeAsync(); + } + finally + { + _device = null; + } + } + + public async ValueTask DisposeAsync() + { + await DisposeDeviceAsync(); + GC.SuppressFinalize(this); + } +} + +public partial class ElloSlotViewModel(ElloDeviceViewModel owner, int slot, string alias) : ObservableObject +{ + private readonly ElloDeviceViewModel _owner = owner; + + public int Slot { get; } = slot; + + [ObservableProperty] + public partial string Alias { get; set; } = alias; + + [ObservableProperty] + public partial bool ShowInLaserPanel { get; set; } = true; + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + public string DisplayName => string.IsNullOrWhiteSpace(Alias) + ? $"轮{Slot}" + : Alias; + + public bool CanMove => _owner.IsConnected && !_owner.IsMoving; + + [RelayCommand(CanExecute = nameof(CanMove))] + private Task MoveAsync(CancellationToken cancellationToken) + { + return _owner.MoveToSlotAsync(Slot, cancellationToken); + } + + partial void OnAliasChanged(string value) + { + OnPropertyChanged(nameof(DisplayName)); + } + + internal void RefreshOwnerState() + { + OnPropertyChanged(nameof(CanMove)); + MoveCommand.NotifyCanExecuteChanged(); + } +} diff --git a/src/RamanApp/ViewModels/MainWindowViewModel.cs b/src/RamanApp/ViewModels/MainWindowViewModel.cs index 1cf8046..e9599d4 100644 --- a/src/RamanApp/ViewModels/MainWindowViewModel.cs +++ b/src/RamanApp/ViewModels/MainWindowViewModel.cs @@ -17,6 +17,12 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] public partial LaserSettingsViewModel LaserSettings { get; set; } + [ObservableProperty] + public partial EasyTtlViewModel EasyTtl { get; set; } + + [ObservableProperty] + public partial ElloFilterViewModel Ello { get; set; } + [ObservableProperty] public partial AcquisitionViewModel Acquisition { get; set; } @@ -82,6 +88,8 @@ public partial class MainWindowViewModel : ObservableObject DeviceSettings = new DeviceSettingsViewModel(); LaserSettings = new LaserSettingsViewModel(); + EasyTtl = new EasyTtlViewModel(simulateIndex >= 1); + Ello = new ElloFilterViewModel(simulateIndex >= 1); Acquisition = new AcquisitionViewModel(); PeakDetection = new PeakDetectionViewModel(); Coordinate = new CoordinateViewModel(); diff --git a/src/RamanApp/Views/EasyTtlSettingsDialog.axaml b/src/RamanApp/Views/EasyTtlSettingsDialog.axaml new file mode 100644 index 0000000..3d604e6 --- /dev/null +++ b/src/RamanApp/Views/EasyTtlSettingsDialog.axaml @@ -0,0 +1,57 @@ + + + + + + +