From acc34d9543b8208aef7b642f7d260ed362706cfa Mon Sep 17 00:00:00 2001 From: fristyxd Date: Sat, 5 Sep 2026 00:22:09 +0300 Subject: [PATCH] v0.3.0: CI dotnet 8.0.424 pinned with isolated install - Pin .NET SDK to 8.0.424 in all CI jobs to avoid floating channel drift - Install SDK to runner temp via DOTNET_INSTALL_DIR to bypass C:\\Program Files\\dotnet overwrite/extract conflicts on Gitea Windows runners - Simplify release job to dotnet publish + upload-artifact - Exclude memory-bank/ via .gitignore (internal dev memory stays local) --- .config/dotnet-tools.json | 12 + .gitea/workflows/ci.yml | 126 +++ .gitignore | 47 + NuGet.Config | 7 + fristy-rgb-led-strip-controller.sln | 29 + src/RgbLedStripController/App.xaml | 433 +++++++++ src/RgbLedStripController/App.xaml.cs | 17 + src/RgbLedStripController/Assets/AppIcon.ico | Bin 0 -> 21793 bytes src/RgbLedStripController/Assets/AppIcon.png | Bin 0 -> 21771 bytes src/RgbLedStripController/Core/Models.cs | 491 ++++++++++ src/RgbLedStripController/MainWindow.xaml | 632 +++++++++++++ src/RgbLedStripController/MainWindow.xaml.cs | 860 ++++++++++++++++++ .../RgbLedStripController.csproj | 41 + .../Services/BleDeviceService.cs | 427 +++++++++ .../Services/ElkBledomProtocol.cs | 113 +++ .../Services/IBleTransport.cs | 18 + .../Services/MultiDeviceController.cs | 729 +++++++++++++++ .../Services/SettingsStore.cs | 140 +++ 18 files changed, 4122 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 NuGet.Config create mode 100644 fristy-rgb-led-strip-controller.sln create mode 100644 src/RgbLedStripController/App.xaml create mode 100644 src/RgbLedStripController/App.xaml.cs create mode 100644 src/RgbLedStripController/Assets/AppIcon.ico create mode 100644 src/RgbLedStripController/Assets/AppIcon.png create mode 100644 src/RgbLedStripController/Core/Models.cs create mode 100644 src/RgbLedStripController/MainWindow.xaml create mode 100644 src/RgbLedStripController/MainWindow.xaml.cs create mode 100644 src/RgbLedStripController/RgbLedStripController.csproj create mode 100644 src/RgbLedStripController/Services/BleDeviceService.cs create mode 100644 src/RgbLedStripController/Services/ElkBledomProtocol.cs create mode 100644 src/RgbLedStripController/Services/IBleTransport.cs create mode 100644 src/RgbLedStripController/Services/MultiDeviceController.cs create mode 100644 src/RgbLedStripController/Services/SettingsStore.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..e38494d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "vpk": { + "version": "0.0.1298", + "commands": [ + "vpk" + ] + } + } +} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7254f8a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + branches: + - main + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup .NET 8 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.424 + env: + DOTNET_INSTALL_DIR: ${{ runner.temp }}\dotnet + + - name: Cache NuGet packages + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '.config/dotnet-tools.json') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore tools + run: dotnet tool restore + + - name: Restore + run: dotnet restore src/RgbLedStripController/RgbLedStripController.csproj --configfile NuGet.Config + + - name: Build (Release) + run: dotnet build src/RgbLedStripController/RgbLedStripController.csproj -c Release --no-restore + + test: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup .NET 8 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.424 + env: + DOTNET_INSTALL_DIR: ${{ runner.temp }}\dotnet + + - name: Cache NuGet packages + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '.config/dotnet-tools.json') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Run tests (if any test projects exist) + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $tests = @(Get-ChildItem -Recurse -Filter "*Tests.csproj" | Where-Object { $_.FullName -notlike "*\obj\*" -and $_.FullName -notlike "*\bin\*" }) + if ($tests.Count -eq 0) { + Write-Host "No test projects found yet; skipping dotnet test." + exit 0 + } + foreach ($t in $tests) { + dotnet test $t.FullName -c Release + if ($LASTEXITCODE -ne 0) { throw "dotnet test failed for $($t.FullName)" } + } + + release: + needs: + - build + - test + if: startsWith(gitea.ref, 'refs/tags/v') + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup .NET 8 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.424 + env: + DOTNET_INSTALL_DIR: ${{ runner.temp }}\dotnet + + - name: Cache NuGet packages + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '.config/dotnet-tools.json') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Restore tools + run: dotnet tool restore + + - name: Publish (Release, self-contained, single file) + run: dotnet publish src/RgbLedStripController/RgbLedStripController.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true --configfile NuGet.Config + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: rgb-led-strip-controller-${{ gitea.ref_name }}-win-x64 + path: | + src/RgbLedStripController/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/RgbLedStripController.exe + src/RgbLedStripController/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/RgbLedStripController.pdb + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8a0dd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# .NET build outputs +bin/ +obj/ +artifacts/ +publish/ +Releases/ +TestResults/ + +# NuGet packages and local caches +*.nupkg +*.snupkg +.nuget/ + +# Local sandbox dirs created by scripts/release/*.ps1 +.dotnet/ +.appdata/ + +# Code signing material - NEVER commit private keys +*.pfx +*.p12 +*.key +*.pem +*.cer + +# Secrets / local env +.env +*.env.local +appsettings.*.local.json + +# OS +Thumbs.db +Desktop.ini +.DS_Store + +# IDE +.vs/ +.vscode/ +*.user +*.suo +*.userosscache + +# Local agent/tooling state (regenerable) +.opencode/ +.codegraph/ + +# Internal dev memory (stays local, not published) +memory-bank/ diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fristy-rgb-led-strip-controller.sln b/fristy-rgb-led-strip-controller.sln new file mode 100644 index 0000000..9339d27 --- /dev/null +++ b/fristy-rgb-led-strip-controller.sln @@ -0,0 +1,29 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RgbLedStripController", "src\RgbLedStripController\RgbLedStripController.csproj", "{A703B02A-BF19-5053-22FA-A79628B40F49}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A703B02A-BF19-5053-22FA-A79628B40F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A703B02A-BF19-5053-22FA-A79628B40F49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A703B02A-BF19-5053-22FA-A79628B40F49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A703B02A-BF19-5053-22FA-A79628B40F49}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A703B02A-BF19-5053-22FA-A79628B40F49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {09247BEF-9950-43C7-91FF-2FCFA0740DA0} + EndGlobalSection +EndGlobal diff --git a/src/RgbLedStripController/App.xaml b/src/RgbLedStripController/App.xaml new file mode 100644 index 0000000..ac8497d --- /dev/null +++ b/src/RgbLedStripController/App.xaml @@ -0,0 +1,433 @@ + + + #FF090D12 + #FF101721 + #FF121923 + #FF161F2B + #1FFFFFFF + #FFF4F7FB + #FF9AA8BA + #FF4CC2FF + #FF198BB7 + #FFFF7A59 + #FF3DD598 + #FFF6C453 + #FFFF6B7A + #FF222E3F + #FF0E151F + #FF121B28 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RgbLedStripController/App.xaml.cs b/src/RgbLedStripController/App.xaml.cs new file mode 100644 index 0000000..6737081 --- /dev/null +++ b/src/RgbLedStripController/App.xaml.cs @@ -0,0 +1,17 @@ +using System; +using Velopack; + +namespace RgbLedStripController; + +public partial class App : System.Windows.Application +{ + [STAThread] + public static void Main(string[] args) + { + VelopackApp.Build().Run(); + + var app = new App(); + app.InitializeComponent(); + app.Run(); + } +} diff --git a/src/RgbLedStripController/Assets/AppIcon.ico b/src/RgbLedStripController/Assets/AppIcon.ico new file mode 100644 index 0000000000000000000000000000000000000000..928efba514b28bf47a89b3c0c5ebf69bce8cfd70 GIT binary patch literal 21793 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 literal 0 HcmV?d00001 diff --git a/src/RgbLedStripController/Assets/AppIcon.png b/src/RgbLedStripController/Assets/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..930729f85a5bed40e5e7e076f6b9cbce8148b268 GIT binary patch literal 21771 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 b>&8wfGc~mvq5TCQFx_EkcEPmT7#jB{t%pym literal 0 HcmV?d00001 diff --git a/src/RgbLedStripController/Core/Models.cs b/src/RgbLedStripController/Core/Models.cs new file mode 100644 index 0000000..f043845 --- /dev/null +++ b/src/RgbLedStripController/Core/Models.cs @@ -0,0 +1,491 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using MediaBrush = System.Windows.Media.Brush; +using MediaColor = System.Windows.Media.Color; +using SolidColorBrush = System.Windows.Media.SolidColorBrush; + +namespace RgbLedStripController.Core; + +public enum DeviceConnectionStatus +{ + Disconnected, + Scanning, + Connecting, + Connected, + Faulted +} + +public enum LightingMode +{ + Rgb, + Effect, + Grayscale, + Temperature +} + +public sealed class BleScanResult : ObservableObject +{ + private string _alias = string.Empty; + private bool _isKnownCandidate; + private bool _isLastConnected; + + public required string DeviceId { get; init; } + + public required string Name { get; init; } + + public required string BluetoothAddress { get; init; } + + public required ulong AddressValue { get; init; } + + public short SignalStrength { get; init; } + + public string Alias + { + get => _alias; + set + { + if (!SetProperty(ref _alias, value ?? string.Empty)) + { + return; + } + + OnPropertyChanged(nameof(DisplayName)); + OnPropertyChanged(nameof(SecondaryLabel)); + } + } + + public bool IsKnownCandidate + { + get => _isKnownCandidate; + set + { + if (!SetProperty(ref _isKnownCandidate, value)) + { + return; + } + + OnPropertyChanged(nameof(Badge)); + } + } + + public bool IsLastConnected + { + get => _isLastConnected; + set + { + if (!SetProperty(ref _isLastConnected, value)) + { + return; + } + + OnPropertyChanged(nameof(Badge)); + } + } + + public string NativeName => string.IsNullOrWhiteSpace(Name) + ? "İsimsiz BLE cihazı" + : Name; + + public string DisplayName => string.IsNullOrWhiteSpace(Alias) + ? NativeName + : Alias; + + public string SecondaryLabel => string.IsNullOrWhiteSpace(Alias) + ? BluetoothAddress + : $"{NativeName} - {BluetoothAddress}"; + + public string Badge => IsLastConnected + ? "Son cihaz" + : IsKnownCandidate + ? "Aday" + : "BLE"; + + public string SignalLabel => SignalStrength == short.MinValue + ? "RSSI bilinmiyor" + : $"RSSI {SignalStrength} dBm"; +} + +public sealed record DeviceProfile( + string LocalAlias, + string DeviceId, + string BluetoothAddress, + DateTimeOffset LastSeenUtc); + +public sealed record EffectOption(byte PatternId, string Label, bool SupportsSpeed) +{ + public string Code => $"{PatternId + 1:00}"; +} + +public sealed record ColorPreset(string Label, string Hex) +{ + public string ToolTipLabel => $"{Label} · {Hex}"; +} + +public sealed record SettingsSnapshot( + IReadOnlyList KnownDevices, + IReadOnlyDictionary DeviceAliases, + MediaColor SelectedColor, + byte Brightness, + byte EffectSpeed, + LightingMode CurrentMode, + byte GrayscaleLevel, + byte TemperatureLevel, + byte SelectedEffectId, + bool IsPoweredOn); + +public abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} + +public sealed class ConnectedDeviceViewModel : ObservableObject +{ + private string _alias = string.Empty; + private bool _isControlTarget = true; + private DeviceConnectionStatus _connectionStatus = DeviceConnectionStatus.Connecting; + private string _statusText = "Bağlantı hazırlanıyor..."; + private MediaBrush _statusBrush = CreateFrozenBrush(MediaColor.FromRgb(59, 130, 246)); + + public required string DeviceId { get; init; } + + public required string NativeName { get; init; } + + public required string BluetoothAddress { get; init; } + + public required ulong AddressValue { get; init; } + + public short SignalStrength { get; init; } + + public string Alias + { + get => _alias; + set + { + if (!SetProperty(ref _alias, value ?? string.Empty)) + { + return; + } + + OnPropertyChanged(nameof(DisplayName)); + OnPropertyChanged(nameof(SecondaryLabel)); + } + } + + public bool IsControlTarget + { + get => _isControlTarget; + set => SetProperty(ref _isControlTarget, value); + } + + public DeviceConnectionStatus ConnectionStatus + { + get => _connectionStatus; + set + { + if (!SetProperty(ref _connectionStatus, value)) + { + return; + } + + _statusBrush = CreateFrozenBrush(GetStatusColor(value)); + OnPropertyChanged(nameof(StatusLabel)); + OnPropertyChanged(nameof(StatusBrush)); + } + } + + public string StatusText + { + get => _statusText; + set => SetProperty(ref _statusText, value); + } + + public string DisplayName => string.IsNullOrWhiteSpace(Alias) + ? NativeName + : Alias; + + public string SecondaryLabel => string.IsNullOrWhiteSpace(Alias) + ? BluetoothAddress + : $"{NativeName} - {BluetoothAddress}"; + + public string SignalLabel => SignalStrength == short.MinValue + ? "RSSI bilinmiyor" + : $"RSSI {SignalStrength} dBm"; + + public string StatusLabel => ConnectionStatus switch + { + DeviceConnectionStatus.Connecting => "Bağlanıyor", + DeviceConnectionStatus.Connected => "Hazır", + DeviceConnectionStatus.Faulted => "Hata", + DeviceConnectionStatus.Disconnected => "Beklemede", + _ => "Boş" + }; + + public MediaBrush StatusBrush => _statusBrush; + + private static MediaColor GetStatusColor(DeviceConnectionStatus status) + => status switch + { + DeviceConnectionStatus.Connecting => MediaColor.FromRgb(59, 130, 246), + DeviceConnectionStatus.Connected => MediaColor.FromRgb(16, 185, 129), + DeviceConnectionStatus.Faulted => MediaColor.FromRgb(244, 63, 94), + DeviceConnectionStatus.Disconnected => MediaColor.FromRgb(245, 158, 11), + _ => MediaColor.FromRgb(107, 114, 128) + }; + + private static MediaBrush CreateFrozenBrush(MediaColor color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } +} + +public sealed class ControllerState : ObservableObject +{ + private DeviceConnectionStatus _connectionStatus = DeviceConnectionStatus.Disconnected; + private bool _isPoweredOn; + private LightingMode _currentMode = LightingMode.Rgb; + private MediaColor _selectedColor = MediaColor.FromRgb(255, 106, 78); + private byte _brightness = 100; + private byte _effectSpeed = 50; + private byte _grayscaleLevel = 100; + private byte _temperatureLevel = 50; + private byte _selectedEffectId; + private int _connectedDeviceCount; + private int _controlTargetCount; + private string _statusText = "Tarama için hazır."; + private MediaBrush _statusBrush = CreateFrozenBrush(MediaColor.FromRgb(107, 114, 128)); + private MediaBrush _selectedBrush = CreateFrozenBrush(MediaColor.FromRgb(255, 106, 78)); + + public DeviceConnectionStatus ConnectionStatus + { + get => _connectionStatus; + set + { + if (!SetProperty(ref _connectionStatus, value)) + { + return; + } + + _statusBrush = CreateFrozenBrush(GetStatusColor(value)); + OnPropertyChanged(nameof(ConnectionLabel)); + OnPropertyChanged(nameof(StatusBrush)); + } + } + + public bool IsPoweredOn + { + get => _isPoweredOn; + set + { + if (!SetProperty(ref _isPoweredOn, value)) + { + return; + } + + OnPropertyChanged(nameof(PowerButtonLabel)); + } + } + + public LightingMode CurrentMode + { + get => _currentMode; + set + { + if (!SetProperty(ref _currentMode, value)) + { + return; + } + + OnPropertyChanged(nameof(CurrentModeLabel)); + } + } + + public MediaColor SelectedColor + { + get => _selectedColor; + set + { + if (!SetProperty(ref _selectedColor, value)) + { + return; + } + + _selectedBrush = CreateFrozenBrush(value); + OnPropertyChanged(nameof(SelectedBrush)); + OnPropertyChanged(nameof(SelectedHex)); + OnPropertyChanged(nameof(SelectedRgbLabel)); + } + } + + public byte Brightness + { + get => _brightness; + set + { + if (!SetProperty(ref _brightness, value)) + { + return; + } + + OnPropertyChanged(nameof(BrightnessLabel)); + } + } + + public byte EffectSpeed + { + get => _effectSpeed; + set + { + if (!SetProperty(ref _effectSpeed, value)) + { + return; + } + + OnPropertyChanged(nameof(EffectSpeedLabel)); + } + } + + public byte GrayscaleLevel + { + get => _grayscaleLevel; + set + { + if (!SetProperty(ref _grayscaleLevel, value)) + { + return; + } + + OnPropertyChanged(nameof(GrayscaleLabel)); + } + } + + public byte TemperatureLevel + { + get => _temperatureLevel; + set + { + if (!SetProperty(ref _temperatureLevel, value)) + { + return; + } + + OnPropertyChanged(nameof(TemperatureLabel)); + } + } + + public byte SelectedEffectId + { + get => _selectedEffectId; + set => SetProperty(ref _selectedEffectId, value); + } + + public int ConnectedDeviceCount + { + get => _connectedDeviceCount; + set + { + if (!SetProperty(ref _connectedDeviceCount, value)) + { + return; + } + + OnPropertyChanged(nameof(ConnectedSummary)); + } + } + + public int ControlTargetCount + { + get => _controlTargetCount; + set + { + if (!SetProperty(ref _controlTargetCount, value)) + { + return; + } + + OnPropertyChanged(nameof(TargetSummary)); + } + } + + public string StatusText + { + get => _statusText; + set => SetProperty(ref _statusText, value); + } + + public string ConnectionLabel => ConnectionStatus switch + { + DeviceConnectionStatus.Scanning => "Taranıyor", + DeviceConnectionStatus.Connecting => "Bağlantı kuruluyor", + DeviceConnectionStatus.Connected => "Hazır", + DeviceConnectionStatus.Faulted => "Dikkat gerekiyor", + _ => "Beklemede" + }; + + public MediaBrush StatusBrush => _statusBrush; + + public MediaBrush SelectedBrush => _selectedBrush; + + public string SelectedHex => $"#{SelectedColor.R:X2}{SelectedColor.G:X2}{SelectedColor.B:X2}"; + + public string SelectedRgbLabel => $"RGB {SelectedColor.R}, {SelectedColor.G}, {SelectedColor.B}"; + + public string BrightnessLabel => $"%{Brightness}"; + + public string EffectSpeedLabel => $"%{EffectSpeed}"; + + public string GrayscaleLabel => $"%{GrayscaleLevel}"; + + public string TemperatureLabel => $"%{TemperatureLevel}"; + + public string CurrentModeLabel => CurrentMode switch + { + LightingMode.Effect => "Efekt", + LightingMode.Grayscale => "Gri ton", + LightingMode.Temperature => "Beyaz sıcaklığı", + _ => "RGB" + }; + + public string ConnectedSummary => ConnectedDeviceCount == 0 + ? "Bağlı cihaz yok" + : $"{ConnectedDeviceCount} cihaz hazır"; + + public string TargetSummary => ControlTargetCount == 0 + ? "Komut alacak cihaz seçilmedi" + : $"{ControlTargetCount} cihaz komut alacak"; + + public string PowerButtonLabel => IsPoweredOn ? "Hedefleri kapat" : "Hedefleri aç"; + + private static MediaColor GetStatusColor(DeviceConnectionStatus status) + => status switch + { + DeviceConnectionStatus.Scanning => MediaColor.FromRgb(56, 189, 248), + DeviceConnectionStatus.Connecting => MediaColor.FromRgb(96, 165, 250), + DeviceConnectionStatus.Connected => MediaColor.FromRgb(16, 185, 129), + DeviceConnectionStatus.Faulted => MediaColor.FromRgb(244, 63, 94), + _ => MediaColor.FromRgb(107, 114, 128) + }; + + private static MediaBrush CreateFrozenBrush(MediaColor color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } +} diff --git a/src/RgbLedStripController/MainWindow.xaml b/src/RgbLedStripController/MainWindow.xaml new file mode 100644 index 0000000..2b8ec35 --- /dev/null +++ b/src/RgbLedStripController/MainWindow.xaml @@ -0,0 +1,632 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RgbLedStripController/MainWindow.xaml.cs b/src/RgbLedStripController/MainWindow.xaml.cs new file mode 100644 index 0000000..762f06f --- /dev/null +++ b/src/RgbLedStripController/MainWindow.xaml.cs @@ -0,0 +1,860 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using Forms = System.Windows.Forms; +using RgbLedStripController.Core; +using RgbLedStripController.Services; +using Velopack; +using Velopack.Sources; +using MediaColor = System.Windows.Media.Color; +using WpfButton = System.Windows.Controls.Button; + +namespace RgbLedStripController; + +public partial class MainWindow : Window, INotifyPropertyChanged +{ + private const string GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller"; + private readonly MultiDeviceController _controller; + private bool _effectSpeedCommitPending; + private bool _grayscaleCommitPending; + private bool _isBusy; + private bool _isCheckingForUpdates; + private bool _brightnessCommitPending; + private bool _temperatureCommitPending; + private bool _isInitializing = true; + private bool _updatePendingRestart; + private BleScanResult? _selectedDevice; + private string _aliasEditorText = string.Empty; + private string _updateStatusText = "G\u00fcncelleme kontrol\u00fc haz\u0131r."; + + public MainWindow() + { + InitializeComponent(); + TrySetWindowIcon(); + + State = new ControllerState(); + Effects = ElkBledomProtocol.CreateEffectOptions(); + ColorPresets = CreateColorPresets(); + Devices = new ObservableCollection(); + ConnectedDevices = new ObservableCollection(); + ConnectedDevices.CollectionChanged += HandleConnectedDevicesCollectionChanged; + + _controller = new MultiDeviceController(new SettingsStore(), State); + State.PropertyChanged += HandleStateChanged; + + DataContext = this; + Loaded += HandleLoaded; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public ControllerState State { get; } + + public ObservableCollection Devices { get; } + + public ObservableCollection ConnectedDevices { get; } + + public IReadOnlyList Effects { get; } + + public IReadOnlyList ColorPresets { get; } + + public string VersionDisplay { get; } = $"v{GetApplicationVersion()}"; + + public string UpdateStatusText + { + get => _updateStatusText; + private set => SetProperty(ref _updateStatusText, value); + } + + public bool CanCheckForUpdates => !_isCheckingForUpdates; + + public string UpdateButtonLabel => _isCheckingForUpdates + ? "Kontrol ediliyor..." + : _updatePendingRestart + ? "G\u00fcncellemeyi uygula" + : "G\u00fcncellemeleri kontrol et"; + + public BleScanResult? SelectedDevice + { + get => _selectedDevice; + set + { + if (!SetProperty(ref _selectedDevice, value)) + { + return; + } + + AliasEditorText = value?.Alias ?? string.Empty; + NotifySelectedDeviceChanged(); + } + } + + public string AliasEditorText + { + get => _aliasEditorText; + set => SetProperty(ref _aliasEditorText, value); + } + + public bool CanScan => !_isBusy; + + public bool CanConnect => !_isBusy + && SelectedDevice is not null + && ConnectedDevices.All(device => !string.Equals(device.BluetoothAddress, SelectedDevice.BluetoothAddress, StringComparison.OrdinalIgnoreCase)); + + public bool CanSaveAlias => !_isBusy && SelectedDevice is not null; + + public bool CanUseControls => !_isBusy && State.ControlTargetCount > 0; + + public bool CanUseBrightnessControl => CanUseControls && State.CurrentMode != LightingMode.Grayscale; + + public bool CanDisconnectAll => !_isBusy && ConnectedDevices.Count > 0; + + public bool CanShowEffectControls => State.CurrentMode == LightingMode.Effect; + + public bool CanShowEffectSpeed => CanShowEffectControls && Effects.Any(item => item.PatternId == State.SelectedEffectId && item.SupportsSpeed); + + public bool CanShowGrayscaleControls => State.CurrentMode == LightingMode.Grayscale; + + public bool CanShowTemperatureControls => State.CurrentMode == LightingMode.Temperature; + + public string LastKnownDeviceCaptionDisplay => _controller.LastKnownDevice is null + ? "Henüz kayıtlı cihaz yok." + : $"Son kayıt: {_controller.LastKnownDevice.LocalAlias} ({_controller.LastKnownDevice.BluetoothAddress})"; + + public string SelectedDeviceSummaryDisplay => SelectedDevice is null + ? "Bir cihaz seç." + : $"{SelectedDevice.DisplayName} - {SelectedDevice.BluetoothAddress}"; + + public string LastKnownDeviceCaption => _controller.LastKnownDevice is null + ? "Henüz cihaz hafızası yok." + : $"{_controller.LastKnownDevice.LocalAlias} ({_controller.LastKnownDevice.BluetoothAddress})"; + + public string SelectedDeviceSummary => SelectedDevice is null + ? "Seçim yapılmadı." + : $"{SelectedDevice.DisplayName} - {SelectedDevice.BluetoothAddress}"; + + protected override async void OnClosed(EventArgs e) + { + State.PropertyChanged -= HandleStateChanged; + ConnectedDevices.CollectionChanged -= HandleConnectedDevicesCollectionChanged; + await _controller.DisposeAsync(); + base.OnClosed(e); + } + + private async void HandleLoaded(object sender, RoutedEventArgs e) + { + await RunBusyAsync(async () => + { + await _controller.InitializeAsync(); + EffectsComboBox.SelectedValue = State.SelectedEffectId; + BrightnessSlider.Value = State.Brightness; + EffectSpeedSlider.Value = State.EffectSpeed; + GrayscaleSlider.Value = State.GrayscaleLevel; + TemperatureSlider.Value = State.TemperatureLevel; + NotifyLastKnownDeviceChanged(); + }, treatErrorAsFault: true); + + _isInitializing = false; + _ = CheckForUpdatesAsync(isManualCheck: false); + } + + private async void ScanClicked(object sender, RoutedEventArgs e) + { + await RunBusyAsync(async () => + { + var scanned = await _controller.ScanForDevicesAsync(TimeSpan.FromSeconds(10)); + Devices.Clear(); + foreach (var device in scanned) + { + Devices.Add(device); + } + + SelectedDevice = Devices.FirstOrDefault(device => device.IsLastConnected) ?? Devices.FirstOrDefault(); + }); + } + + private async void ConnectClicked(object sender, RoutedEventArgs e) + { + if (SelectedDevice is null) + { + return; + } + + await RunBusyAsync(async () => + { + var connected = await _controller.ConnectAsync(SelectedDevice); + if (ConnectedDevices.All(device => !string.Equals(device.BluetoothAddress, connected.BluetoothAddress, StringComparison.OrdinalIgnoreCase))) + { + ConnectedDevices.Add(connected); + } + + SelectedDevice.IsLastConnected = true; + NotifyLastKnownDeviceChanged(); + }, treatErrorAsFault: true); + } + + private async void SaveAliasClicked(object sender, RoutedEventArgs e) + { + if (SelectedDevice is null) + { + return; + } + + await RunBusyAsync(async () => + { + await _controller.SetAliasAsync(SelectedDevice, AliasEditorText); + NotifySelectedDeviceChanged(); + NotifyLastKnownDeviceChanged(); + }); + } + + private async void DisconnectConnectedClicked(object sender, RoutedEventArgs e) + { + if (sender is not WpfButton { Tag: ConnectedDeviceViewModel device }) + { + return; + } + + await RunBusyAsync(async () => + { + await _controller.DisconnectAsync(device.BluetoothAddress); + ConnectedDevices.Remove(device); + }); + } + + private async void DisconnectAllClicked(object sender, RoutedEventArgs e) + { + await RunBusyAsync(async () => + { + await _controller.DisconnectAllAsync(); + ConnectedDevices.Clear(); + }); + } + + private async void PowerClicked(object sender, RoutedEventArgs e) + { + await RunBusyAsync( + async () => await _controller.ApplyPowerAsync(!State.IsPoweredOn), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async void SelectColorClicked(object sender, RoutedEventArgs e) + { + if (!CanUseControls) + { + return; + } + + using var colorDialog = new Forms.ColorDialog + { + FullOpen = true, + Color = System.Drawing.Color.FromArgb(State.SelectedColor.R, State.SelectedColor.G, State.SelectedColor.B) + }; + + if (colorDialog.ShowDialog() != Forms.DialogResult.OK) + { + return; + } + + var color = MediaColor.FromRgb(colorDialog.Color.R, colorDialog.Color.G, colorDialog.Color.B); + await RunBusyAsync( + async () => await _controller.QueueColorAsync(color), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async void PresetColorClicked(object sender, RoutedEventArgs e) + { + if (!CanUseControls || sender is not WpfButton { Tag: string tag }) + { + return; + } + + var color = ParseHexColor(tag); + await RunBusyAsync( + async () => await _controller.QueueColorAsync(color), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private void BrightnessChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_isInitializing) + { + return; + } + + State.Brightness = (byte)Math.Round(e.NewValue); + if (!CanUseBrightnessControl) + { + _brightnessCommitPending = false; + return; + } + + _brightnessCommitPending = true; + } + + private async void BrightnessCommitMouseUp(object sender, MouseButtonEventArgs e) + => await CommitBrightnessIfPendingAsync(); + + private async void BrightnessCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e) + => await CommitBrightnessIfPendingAsync(); + + private async void BrightnessCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e) + => await CommitBrightnessIfPendingAsync(); + + private async void EffectChanged(object sender, SelectionChangedEventArgs e) + { + if (_isInitializing) + { + return; + } + + if (EffectsComboBox.SelectedValue is not byte patternId || !CanUseControls) + { + return; + } + + State.SelectedEffectId = patternId; + await RunBusyAsync( + async () => await _controller.ApplyEffectAsync(patternId), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async void SwitchRgbModeClicked(object sender, RoutedEventArgs e) + { + if (!CanUseControls) + { + return; + } + + await RunBusyAsync( + async () => await _controller.QueueColorAsync(State.SelectedColor), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async void SwitchEffectModeClicked(object sender, RoutedEventArgs e) + { + if (!CanUseControls) + { + return; + } + + await RunBusyAsync( + async () => await _controller.ApplyEffectAsync(State.SelectedEffectId), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async void SwitchGrayscaleModeClicked(object sender, RoutedEventArgs e) + { + if (!CanUseControls) + { + return; + } + + await RunBusyAsync( + async () => await _controller.ApplyGrayscaleModeAsync(), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async void SwitchTemperatureModeClicked(object sender, RoutedEventArgs e) + { + if (!CanUseControls) + { + return; + } + + await RunBusyAsync( + async () => await _controller.ApplyTemperatureModeAsync(), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private void EffectSpeedChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_isInitializing) + { + return; + } + + State.EffectSpeed = (byte)Math.Round(e.NewValue); + if (!CanUseControls || !CanShowEffectSpeed) + { + _effectSpeedCommitPending = false; + return; + } + + _effectSpeedCommitPending = true; + } + + private async void EffectSpeedCommitMouseUp(object sender, MouseButtonEventArgs e) + => await CommitEffectSpeedIfPendingAsync(); + + private async void EffectSpeedCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e) + => await CommitEffectSpeedIfPendingAsync(); + + private async void EffectSpeedCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e) + => await CommitEffectSpeedIfPendingAsync(); + + private void GrayscaleChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_isInitializing) + { + return; + } + + State.GrayscaleLevel = (byte)Math.Round(e.NewValue); + if (!CanUseControls || !CanShowGrayscaleControls) + { + _grayscaleCommitPending = false; + return; + } + + _grayscaleCommitPending = true; + } + + private async void GrayscaleCommitMouseUp(object sender, MouseButtonEventArgs e) + => await CommitGrayscaleIfPendingAsync(); + + private async void GrayscaleCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e) + => await CommitGrayscaleIfPendingAsync(); + + private async void GrayscaleCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e) + => await CommitGrayscaleIfPendingAsync(); + + private void TemperatureChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_isInitializing) + { + return; + } + + State.TemperatureLevel = (byte)Math.Round(e.NewValue); + if (!CanUseControls || !CanShowTemperatureControls) + { + _temperatureCommitPending = false; + return; + } + + _temperatureCommitPending = true; + } + + private async void TemperatureCommitMouseUp(object sender, MouseButtonEventArgs e) + => await CommitTemperatureIfPendingAsync(); + + private async void TemperatureCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e) + => await CommitTemperatureIfPendingAsync(); + + private async void TemperatureCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e) + => await CommitTemperatureIfPendingAsync(); + + private async void TemperaturePresetClicked(object sender, RoutedEventArgs e) + { + if (!CanUseControls || sender is not WpfButton { Tag: var rawTag } || !byte.TryParse(rawTag?.ToString(), out var level)) + { + return; + } + + State.TemperatureLevel = level; + TemperatureSlider.Value = level; + _temperatureCommitPending = false; + + await RunBusyAsync( + async () => await _controller.ApplyTemperatureLevelAsync(level), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async void CheckForUpdatesClicked(object sender, RoutedEventArgs e) + => await CheckForUpdatesAsync(isManualCheck: true); + + private async Task RunBusyAsync(Func action, bool useBusyFlag = true, bool treatErrorAsFault = false) + { + if (useBusyFlag && _isBusy) + { + return; + } + + try + { + if (useBusyFlag) + { + _isBusy = true; + NotifyBusyDependentProperties(); + } + + await action(); + } + catch (Exception ex) + { + if (treatErrorAsFault) + { + State.ConnectionStatus = DeviceConnectionStatus.Faulted; + } + + State.StatusText = ex.Message; + } + finally + { + if (useBusyFlag) + { + _isBusy = false; + NotifyBusyDependentProperties(); + } + } + } + + private void HandleStateChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrWhiteSpace(e.PropertyName) + || string.Equals(e.PropertyName, nameof(ControllerState.ControlTargetCount), StringComparison.Ordinal)) + { + OnPropertyChanged(nameof(CanUseControls)); + } + + if (string.IsNullOrWhiteSpace(e.PropertyName) + || string.Equals(e.PropertyName, nameof(ControllerState.SelectedEffectId), StringComparison.Ordinal) + || string.Equals(e.PropertyName, nameof(ControllerState.CurrentMode), StringComparison.Ordinal)) + { + if (!CanShowEffectSpeed) + { + _effectSpeedCommitPending = false; + } + + if (!CanShowGrayscaleControls) + { + _grayscaleCommitPending = false; + } + + if (!CanShowTemperatureControls) + { + _temperatureCommitPending = false; + } + + OnPropertyChanged(nameof(CanUseBrightnessControl)); + OnPropertyChanged(nameof(CanShowEffectControls)); + OnPropertyChanged(nameof(CanShowEffectSpeed)); + OnPropertyChanged(nameof(CanShowGrayscaleControls)); + OnPropertyChanged(nameof(CanShowTemperatureControls)); + } + } + + private void HandleConnectedDevicesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(CanConnect)); + OnPropertyChanged(nameof(CanDisconnectAll)); + } + + private void NotifyBusyDependentProperties() + { + OnPropertyChanged(nameof(CanScan)); + OnPropertyChanged(nameof(CanConnect)); + OnPropertyChanged(nameof(CanSaveAlias)); + OnPropertyChanged(nameof(CanUseControls)); + OnPropertyChanged(nameof(CanUseBrightnessControl)); + OnPropertyChanged(nameof(CanDisconnectAll)); + } + + private void NotifySelectedDeviceChanged() + { + OnPropertyChanged(nameof(CanConnect)); + OnPropertyChanged(nameof(CanSaveAlias)); + OnPropertyChanged(nameof(SelectedDeviceSummary)); + OnPropertyChanged(nameof(SelectedDeviceSummaryDisplay)); + } + + private void NotifyLastKnownDeviceChanged() + { + OnPropertyChanged(nameof(LastKnownDeviceCaption)); + OnPropertyChanged(nameof(LastKnownDeviceCaptionDisplay)); + } + + private async Task CommitBrightnessIfPendingAsync() + { + if (!_brightnessCommitPending || _isInitializing || !CanUseBrightnessControl) + { + return; + } + + _brightnessCommitPending = false; + await RunBusyAsync( + async () => await _controller.QueueBrightnessAsync(State.Brightness), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async Task CommitEffectSpeedIfPendingAsync() + { + if (!_effectSpeedCommitPending || _isInitializing || !CanUseControls || !CanShowEffectSpeed) + { + return; + } + + _effectSpeedCommitPending = false; + await RunBusyAsync( + async () => await _controller.ApplyEffectSpeedAsync(State.EffectSpeed), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async Task CommitGrayscaleIfPendingAsync() + { + if (!_grayscaleCommitPending || _isInitializing || !CanUseControls || !CanShowGrayscaleControls) + { + return; + } + + _grayscaleCommitPending = false; + await RunBusyAsync( + async () => await _controller.ApplyGrayscaleLevelAsync(State.GrayscaleLevel), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private async Task CommitTemperatureIfPendingAsync() + { + if (!_temperatureCommitPending || _isInitializing || !CanUseControls || !CanShowTemperatureControls) + { + return; + } + + _temperatureCommitPending = false; + await RunBusyAsync( + async () => await _controller.ApplyTemperatureLevelAsync(State.TemperatureLevel), + useBusyFlag: false, + treatErrorAsFault: true); + } + + private static IReadOnlyList CreateColorPresets() + => new[] + { + new ColorPreset("Kırmızı", "#FF0000"), + new ColorPreset("Turuncu", "#FF9500"), + new ColorPreset("Amber", "#FFCC00"), + new ColorPreset("Sarı", "#FFD60A"), + new ColorPreset("Lime", "#A8E10C"), + new ColorPreset("Yeşil", "#34C759"), + new ColorPreset("Turkuaz", "#30D5C8"), + new ColorPreset("Camgöbeği", "#32ADE6"), + new ColorPreset("Mavi", "#007AFF"), + new ColorPreset("Mor", "#5856D6"), + new ColorPreset("Macenta", "#FF00FF"), + new ColorPreset("Pembe", "#FF2D55"), + new ColorPreset("Buz mavisi", "#ADD8E6"), + new ColorPreset("Sıcak beyaz", "#FDF4E3") + }; + + private static MediaColor ParseHexColor(string hex) + { + var normalized = hex.TrimStart('#'); + if (normalized.Length != 6) + { + return MediaColor.FromRgb(255, 106, 78); + } + + return MediaColor.FromRgb( + Convert.ToByte(normalized.Substring(0, 2), 16), + Convert.ToByte(normalized.Substring(2, 2), 16), + Convert.ToByte(normalized.Substring(4, 2), 16)); + } + + private void FooterLinkClicked(object sender, RequestNavigateEventArgs e) + { + Process.Start(new ProcessStartInfo + { + FileName = e.Uri.AbsoluteUri, + UseShellExecute = true + }); + + e.Handled = true; + } + + private void TrySetWindowIcon() + { + try + { + Icon = BitmapFrame.Create(new Uri("pack://application:,,,/Assets/AppIcon.ico", UriKind.Absolute)); + } + catch + { + // Window icon is cosmetic; startup should continue even if the icon resource is unavailable. + } + } + + private async Task CheckForUpdatesAsync(bool isManualCheck) + { + if (_isCheckingForUpdates) + { + return; + } + + try + { + _isCheckingForUpdates = true; + NotifyUpdateProperties(); + + var updateManager = new UpdateManager(new GiteaSource(GiteaRepoUrl, null, false)); + if (!updateManager.IsInstalled) + { + _updatePendingRestart = false; + UpdateStatusText = "Kurulu Velopack s\u00fcr\u00fcm\u00fc alg\u0131lanmad\u0131."; + NotifyUpdateProperties(); + + if (isManualCheck) + { + System.Windows.MessageBox.Show( + this, + "G\u00fcncelleme denetimi sadece Setup.exe ile kurulan s\u00fcr\u00fcmlerde kullan\u0131labilir.", + "G\u00fcncelleme", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + + return; + } + + if (updateManager.UpdatePendingRestart is { } pendingAsset) + { + _updatePendingRestart = true; + UpdateStatusText = "\u0130ndirilen g\u00fcncelleme uygulanmay\u0131 bekliyor."; + NotifyUpdateProperties(); + + var applyPending = System.Windows.MessageBox.Show( + this, + "\u0130ndirilen bir g\u00fcncelleme haz\u0131r. Uygulay\u0131p uygulamay\u0131 yeniden ba\u015flatmak ister misin?", + "G\u00fcncelleme haz\u0131r", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (applyPending == MessageBoxResult.Yes) + { + UpdateStatusText = "G\u00fcncelleme uygulan\u0131yor..."; + await updateManager.WaitExitThenApplyUpdatesAsync(pendingAsset, restart: true); + Close(); + } + + return; + } + + _updatePendingRestart = false; + UpdateStatusText = isManualCheck + ? "G\u00fcncellemeler kontrol ediliyor..." + : "Ba\u015flang\u0131\u00e7 g\u00fcncelleme kontrol\u00fc yap\u0131l\u0131yor..."; + NotifyUpdateProperties(); + + var update = await updateManager.CheckForUpdatesAsync(); + if (update is null) + { + UpdateStatusText = "G\u00fcncel s\u00fcr\u00fcm kullan\u0131l\u0131yor."; + if (isManualCheck) + { + System.Windows.MessageBox.Show( + this, + "Yeni bir s\u00fcr\u00fcm bulunamad\u0131.", + "G\u00fcncelleme", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + + return; + } + + var targetVersion = update.TargetFullRelease.Version.ToString(); + UpdateStatusText = $"Yeni s\u00fcr\u00fcm haz\u0131r: v{targetVersion}"; + + var shouldApply = System.Windows.MessageBox.Show( + this, + $"v{targetVersion} bulundu. \u015eimdi indirip uygulamak ister misin?", + "G\u00fcncelleme bulundu", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (shouldApply != MessageBoxResult.Yes) + { + return; + } + + UpdateStatusText = $"v{targetVersion} indiriliyor..."; + await updateManager.DownloadUpdatesAsync(update, progress => + { + Dispatcher.Invoke(() => + { + UpdateStatusText = $"v{targetVersion} indiriliyor... %{progress}"; + }); + }); + + _updatePendingRestart = true; + UpdateStatusText = "G\u00fcncelleme uygulan\u0131yor..."; + NotifyUpdateProperties(); + await updateManager.WaitExitThenApplyUpdatesAsync(update.TargetFullRelease, restart: true); + Close(); + } + catch (Exception ex) + { + _updatePendingRestart = false; + UpdateStatusText = $"G\u00fcncelleme hatas\u0131: {ex.Message}"; + NotifyUpdateProperties(); + + if (isManualCheck) + { + System.Windows.MessageBox.Show( + this, + ex.Message, + "G\u00fcncelleme hatas\u0131", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + finally + { + _isCheckingForUpdates = false; + NotifyUpdateProperties(); + } + } + + private void NotifyUpdateProperties() + { + OnPropertyChanged(nameof(CanCheckForUpdates)); + OnPropertyChanged(nameof(UpdateButtonLabel)); + } + + private static string GetApplicationVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informationalVersion)) + { + return informationalVersion; + } + + return assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + } + + private bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/src/RgbLedStripController/RgbLedStripController.csproj b/src/RgbLedStripController/RgbLedStripController.csproj new file mode 100644 index 0000000..86bb9b4 --- /dev/null +++ b/src/RgbLedStripController/RgbLedStripController.csproj @@ -0,0 +1,41 @@ + + + WinExe + net8.0-windows10.0.19041.0 + true + true + 0.3.0 + 0.3.0.0 + 0.3.0.0 + 0.3.0 + enable + enable + latest + true + 10.0.19041.0 + 10.0.19041.0 + win-x64 + Assets\AppIcon.ico + true + true + false + true + + + + RgbLedStripController.App + + + + + + + + + + + + + + + diff --git a/src/RgbLedStripController/Services/BleDeviceService.cs b/src/RgbLedStripController/Services/BleDeviceService.cs new file mode 100644 index 0000000..f4a3de4 --- /dev/null +++ b/src/RgbLedStripController/Services/BleDeviceService.cs @@ -0,0 +1,427 @@ +using System.Collections.Concurrent; +using System.Globalization; +using RgbLedStripController.Core; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.Advertisement; +using Windows.Devices.Bluetooth.GenericAttributeProfile; +using Windows.Devices.Enumeration; +using Windows.Foundation; +using Windows.Storage.Streams; + +namespace RgbLedStripController.Services; + +public sealed class BleDeviceService : IBleTransport +{ + private readonly SemaphoreSlim _connectionLock = new(1, 1); + private readonly List _writeCharacteristics = []; + private BleScanResult? _targetDevice; + private BluetoothLEDevice? _device; + private GattDeviceService? _service; + private GattCharacteristic? _preferredCharacteristic; + private bool _isManualDisconnect; + + public event EventHandler? ConnectionStatusChanged; + + public async Task> ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default) + { + var captures = new ConcurrentDictionary(); + var activeDuration = duration <= TimeSpan.FromSeconds(4) + ? duration + : TimeSpan.FromMilliseconds(duration.TotalMilliseconds * 0.65); + var passiveDuration = duration - activeDuration; + + await RunScanPassAsync(captures, BluetoothLEScanningMode.Active, activeDuration, cancellationToken); + if (passiveDuration > TimeSpan.Zero) + { + await RunScanPassAsync(captures, BluetoothLEScanningMode.Passive, passiveDuration, cancellationToken); + } + + await MergeWindowsDeviceInventoryAsync(captures); + + return captures.Values + .Select(capture => new BleScanResult + { + DeviceId = FormatAddress(capture.Address), + Name = string.IsNullOrWhiteSpace(capture.Name) ? "İsimsiz BLE cihazı" : capture.Name, + BluetoothAddress = FormatAddress(capture.Address), + AddressValue = capture.Address, + IsKnownCandidate = IsKnownCandidate(capture.Name), + IsLastConnected = false, + SignalStrength = capture.SignalStrength, + Alias = string.Empty + }) + .OrderByDescending(item => item.IsKnownCandidate) + .ThenByDescending(item => item.SignalStrength) + .ToArray(); + } + + public async Task ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default) + { + _targetDevice = device; + _isManualDisconnect = false; + RaiseConnectionStatus(DeviceConnectionStatus.Connecting); + await EnsureCharacteristicReadyAsync(cancellationToken); + } + + public async Task DiscoverAsync(CancellationToken cancellationToken = default) + { + try + { + await EnsureCharacteristicReadyAsync(cancellationToken); + return true; + } + catch + { + return false; + } + } + + public async Task WriteAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + await EnsureCharacteristicReadyAsync(cancellationToken); + await WriteCoreAsync(payload, cancellationToken, allowRetry: true); + } + + public Task DisconnectAsync() + { + _isManualDisconnect = true; + ClearDevice(clearTarget: true); + RaiseConnectionStatus(DeviceConnectionStatus.Disconnected); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await DisconnectAsync(); + _connectionLock.Dispose(); + } + + private async Task EnsureCharacteristicReadyAsync(CancellationToken cancellationToken) + { + if (_targetDevice is null) + { + throw new InvalidOperationException("Target BLE device is not selected."); + } + + await _connectionLock.WaitAsync(cancellationToken); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_device is null) + { + await CreateDeviceAsync(cancellationToken); + } + + if (_device is null) + { + throw new InvalidOperationException("BluetoothLEDevice could not be opened."); + } + + if (_preferredCharacteristic is not null || _writeCharacteristics.Count > 0) + { + RaiseConnectionStatus(DeviceConnectionStatus.Connected); + return; + } + + if (_device.ConnectionStatus != BluetoothConnectionStatus.Connected) + { + await CreateDeviceAsync(cancellationToken); + } + + var serviceResult = await _device.GetGattServicesForUuidAsync( + ElkBledomProtocol.ServiceUuid, + BluetoothCacheMode.Uncached); + + if (serviceResult.Status != GattCommunicationStatus.Success || serviceResult.Services.Count == 0) + { + throw new InvalidOperationException("fff0 service was not found."); + } + + ClearGatt(); + _service = serviceResult.Services[0]; + + var characteristicResult = await _service.GetCharacteristicsAsync(BluetoothCacheMode.Uncached); + if (characteristicResult.Status != GattCommunicationStatus.Success || characteristicResult.Characteristics.Count == 0) + { + throw new InvalidOperationException("No writable BLE characteristic was found."); + } + + _writeCharacteristics.Clear(); + _writeCharacteristics.AddRange( + characteristicResult.Characteristics + .Where(IsWritableCharacteristic) + .OrderByDescending(characteristic => characteristic.Uuid == ElkBledomProtocol.CharacteristicUuid)); + + if (_writeCharacteristics.Count == 0) + { + throw new InvalidOperationException("No writable BLE characteristic was found."); + } + + _preferredCharacteristic = _writeCharacteristics[0]; + RaiseConnectionStatus(DeviceConnectionStatus.Connected); + } + finally + { + _connectionLock.Release(); + } + } + + private async Task WriteCoreAsync(ReadOnlyMemory payload, CancellationToken cancellationToken, bool allowRetry) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_preferredCharacteristic is null && _writeCharacteristics.Count == 0) + { + throw new InvalidOperationException("BLE characteristic is not ready."); + } + + try + { + var payloadBytes = payload.ToArray(); + Exception? lastError = null; + + foreach (var characteristic in EnumerateWriteTargets()) + { + try + { + var writer = new DataWriter(); + writer.WriteBytes(payloadBytes); + + var preferredOption = characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.WriteWithoutResponse) + ? GattWriteOption.WriteWithoutResponse + : GattWriteOption.WriteWithResponse; + + var status = await characteristic.WriteValueAsync(writer.DetachBuffer(), preferredOption); + if (status != GattCommunicationStatus.Success) + { + lastError = new InvalidOperationException($"BLE write failed: {status}"); + continue; + } + + _preferredCharacteristic = characteristic; + RaiseConnectionStatus(DeviceConnectionStatus.Connected); + return; + } + catch (Exception ex) + { + lastError = ex; + } + } + + throw lastError ?? new InvalidOperationException("BLE write failed."); + } + catch when (allowRetry) + { + ClearGatt(); + await EnsureCharacteristicReadyAsync(cancellationToken); + await WriteCoreAsync(payload, cancellationToken, allowRetry: false); + } + } + + private async Task CreateDeviceAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_targetDevice is null) + { + throw new InvalidOperationException("Target BLE device is not selected."); + } + + ClearDevice(clearTarget: false); + + _device = await BluetoothLEDevice.FromBluetoothAddressAsync(_targetDevice.AddressValue); + if (_device is null) + { + throw new InvalidOperationException("Bluetooth device could not be opened."); + } + + _device.ConnectionStatusChanged += HandleConnectionStatusChanged; + } + + private void HandleConnectionStatusChanged(BluetoothLEDevice sender, object args) + { + if (sender.ConnectionStatus == BluetoothConnectionStatus.Disconnected) + { + ClearGatt(); + + if (!_isManualDisconnect) + { + RaiseConnectionStatus(DeviceConnectionStatus.Disconnected); + } + + return; + } + + _isManualDisconnect = false; + RaiseConnectionStatus(DeviceConnectionStatus.Connected); + } + + private void ClearGatt() + { + _preferredCharacteristic = null; + _writeCharacteristics.Clear(); + _service?.Dispose(); + _service = null; + } + + private void ClearDevice(bool clearTarget) + { + ClearGatt(); + + if (_device is not null) + { + _device.ConnectionStatusChanged -= HandleConnectionStatusChanged; + _device.Dispose(); + _device = null; + } + + if (clearTarget) + { + _targetDevice = null; + } + } + + private void RaiseConnectionStatus(DeviceConnectionStatus status) + => ConnectionStatusChanged?.Invoke(this, status); + + private static bool IsKnownCandidate(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + var normalized = name.ToUpperInvariant(); + return normalized.Contains("ELK-BLEDOM") + || normalized.Contains("ELK-BLEDOB") + || normalized.Contains("LOTUS") + || normalized.Contains("DUOCO"); + } + + private static async Task RunScanPassAsync( + ConcurrentDictionary captures, + BluetoothLEScanningMode mode, + TimeSpan duration, + CancellationToken cancellationToken) + { + if (duration <= TimeSpan.Zero) + { + return; + } + + var watcher = new BluetoothLEAdvertisementWatcher + { + ScanningMode = mode, + AllowExtendedAdvertisements = true + }; + + TypedEventHandler receivedHandler = (_, args) => + { + captures.AddOrUpdate( + args.BluetoothAddress, + _ => new ScanCapture(args.BluetoothAddress, args.Advertisement.LocalName, args.RawSignalStrengthInDBm), + (_, existing) => existing.Merge(args.Advertisement.LocalName, args.RawSignalStrengthInDBm)); + }; + + watcher.Received += receivedHandler; + watcher.Start(); + + try + { + await Task.Delay(duration, cancellationToken); + } + finally + { + watcher.Stop(); + watcher.Received -= receivedHandler; + } + } + + private static async Task MergeWindowsDeviceInventoryAsync(ConcurrentDictionary captures) + { + try + { + var additionalProperties = new[] + { + "System.Devices.Aep.DeviceAddress" + }; + + var devices = await DeviceInformation.FindAllAsync( + BluetoothLEDevice.GetDeviceSelector(), + additionalProperties); + + foreach (var device in devices) + { + if (!IsKnownCandidate(device.Name)) + { + continue; + } + + if (!device.Properties.TryGetValue("System.Devices.Aep.DeviceAddress", out var addressValue) + || addressValue is not string addressText + || !TryParseBluetoothAddress(addressText, out var address)) + { + continue; + } + + captures.AddOrUpdate( + address, + _ => new ScanCapture(address, device.Name, short.MinValue), + (_, existing) => existing.Merge(device.Name, short.MinValue)); + } + } + catch + { + // Best effort only. + } + } + + private static bool IsWritableCharacteristic(GattCharacteristic characteristic) + => characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.Write) + || characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.WriteWithoutResponse); + + private IEnumerable EnumerateWriteTargets() + { + if (_preferredCharacteristic is not null) + { + yield return _preferredCharacteristic; + } + + foreach (var characteristic in _writeCharacteristics) + { + if (!ReferenceEquals(characteristic, _preferredCharacteristic)) + { + yield return characteristic; + } + } + } + + private static string FormatAddress(ulong address) + { + var bytes = BitConverter.GetBytes(address).Take(6).Reverse().ToArray(); + return string.Join(":", bytes.Select(item => item.ToString("X2"))); + } + + private static bool TryParseBluetoothAddress(string? value, out ulong address) + { + address = 0; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Replace(":", string.Empty).Replace("-", string.Empty).Trim(); + return ulong.TryParse(normalized, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out address); + } + + private sealed record ScanCapture(ulong Address, string Name, short SignalStrength) + { + public ScanCapture Merge(string? name, short signalStrength) + => this with + { + Name = string.IsNullOrWhiteSpace(name) ? Name : name!, + SignalStrength = signalStrength == short.MinValue ? SignalStrength : signalStrength + }; + } +} diff --git a/src/RgbLedStripController/Services/ElkBledomProtocol.cs b/src/RgbLedStripController/Services/ElkBledomProtocol.cs new file mode 100644 index 0000000..9b2990a --- /dev/null +++ b/src/RgbLedStripController/Services/ElkBledomProtocol.cs @@ -0,0 +1,113 @@ +using RgbLedStripController.Core; +using MediaColor = System.Windows.Media.Color; + +namespace RgbLedStripController.Services; + +public static class ElkBledomProtocol +{ + public static readonly Guid ServiceUuid = Guid.Parse("0000fff0-0000-1000-8000-00805f9b34fb"); + public static readonly Guid CharacteristicUuid = Guid.Parse("0000fff3-0000-1000-8000-00805f9b34fb"); + + public static byte[] BuildPower(bool isOn) + { + var flag = isOn ? (byte)0x01 : (byte)0x00; + return new byte[] { 0x7E, 0x04, 0x04, flag, 0x00, flag, 0xFF, 0x00, 0xEF }; + } + + public static byte[] BuildLegacyPower(bool isOn) + { + var flag = isOn ? (byte)0x01 : (byte)0x00; + return new byte[] { 0x7E, 0x00, 0x04, flag, 0x00, 0x00, 0x00, 0x00, 0xEF }; + } + + public static byte[] BuildColor(MediaColor color) + => new byte[] { 0x7E, 0x07, 0x05, 0x03, color.R, color.G, color.B, 0x10, 0xEF }; + + public static byte[] BuildLegacyColor(MediaColor color) + => new byte[] { 0x7E, 0x00, 0x05, 0x03, color.R, color.G, color.B, 0x00, 0xEF }; + + public static byte[] BuildBrightness(byte brightness) + { + var value = (byte)Math.Clamp((int)brightness, 0, 100); + return new byte[] { 0x7E, 0x04, 0x01, value, 0xFF, 0xFF, 0xFF, 0x00, 0xEF }; + } + + public static byte[] BuildLegacyBrightness(byte brightness) + { + var value = (byte)Math.Clamp((int)brightness, 0, 100); + return new byte[] { 0x7E, 0x00, 0x01, value, 0x00, 0x00, 0x00, 0x00, 0xEF }; + } + + public static byte[] BuildEffect(byte patternId) + { + var clamped = (byte)Math.Clamp((int)patternId, 0, 28); + return new byte[] { 0x7E, 0x05, 0x03, (byte)(clamped + 128), 0x03, 0xFF, 0xFF, 0x00, 0xEF }; + } + + public static byte[] BuildEffectSpeed(byte speed) + { + var value = (byte)Math.Clamp((int)speed, 0, 100); + return new byte[] { 0x7E, 0x00, 0x02, value, 0x00, 0x00, 0x00, 0x00, 0xEF }; + } + + public static byte[] BuildGrayscaleMode() + => new byte[] { 0x7E, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xEF }; + + public static byte[] BuildGrayscaleLevel(byte level) + { + var value = (byte)Math.Clamp((int)level, 0, 100); + return new byte[] { 0x7E, 0x00, 0x05, 0x01, value, 0x00, 0x00, 0x00, 0xEF }; + } + + public static byte[] BuildTemperatureMode(byte level) + { + var mapped = (byte)(0x80 + Math.Round(Math.Clamp((double)level, 0, 100) * 10d / 100d)); + return new byte[] { 0x7E, 0x00, 0x03, mapped, 0x02, 0x00, 0x00, 0x00, 0xEF }; + } + + public static byte[] BuildTemperatureLevel(byte level) + { + var value = (byte)Math.Clamp((int)level, 0, 100); + return new byte[] { 0x7E, 0x00, 0x05, 0x02, value, 0x00, 0x00, 0x00, 0xEF }; + } + + public static byte[] BuildRgbModeReset() + => new byte[] { 0x7E, 0x00, 0x03, 0x00, 0x05, 0x00, 0x00, 0x00, 0xEF }; + + public static bool EffectSupportsSpeed(byte patternId) + => patternId >= 0x07 && patternId <= 0x1C; + + public static IReadOnlyList CreateEffectOptions() + => new[] + { + new EffectOption(0x00, "Tek Renk Kırmızı", false), + new EffectOption(0x01, "Tek Renk Yeşil", false), + new EffectOption(0x02, "Tek Renk Mavi", false), + new EffectOption(0x03, "Tek Renk Sarı", false), + new EffectOption(0x04, "Tek Renk Camgöbeği", false), + new EffectOption(0x05, "Tek Renk Macenta", false), + new EffectOption(0x06, "Tek Renk Beyaz", false), + new EffectOption(0x07, "Atlama RGB", true), + new EffectOption(0x08, "Atlama Gökkuşağı", true), + new EffectOption(0x09, "Geçiş RGB", true), + new EffectOption(0x0A, "Geçiş Gökkuşağı", true), + new EffectOption(0x0B, "Geçiş Kırmızı", true), + new EffectOption(0x0C, "Geçiş Yeşil", true), + new EffectOption(0x0D, "Geçiş Mavi", true), + new EffectOption(0x0E, "Geçiş Sarı", true), + new EffectOption(0x0F, "Geçiş Camgöbeği", true), + new EffectOption(0x10, "Geçiş Macenta", true), + new EffectOption(0x11, "Geçiş Beyaz", true), + new EffectOption(0x12, "Geçiş Kırmızı-Yeşil", true), + new EffectOption(0x13, "Geçiş Kırmızı-Mavi", true), + new EffectOption(0x14, "Geçiş Yeşil-Mavi", true), + new EffectOption(0x15, "Yanıp Sön Gökkuşağı", true), + new EffectOption(0x16, "Yanıp Sön Kırmızı", true), + new EffectOption(0x17, "Yanıp Sön Yeşil", true), + new EffectOption(0x18, "Yanıp Sön Mavi", true), + new EffectOption(0x19, "Yanıp Sön Sarı", true), + new EffectOption(0x1A, "Yanıp Sön Camgöbeği", true), + new EffectOption(0x1B, "Yanıp Sön Macenta", true), + new EffectOption(0x1C, "Yanıp Sön Beyaz", true) + }; +} diff --git a/src/RgbLedStripController/Services/IBleTransport.cs b/src/RgbLedStripController/Services/IBleTransport.cs new file mode 100644 index 0000000..03fb65b --- /dev/null +++ b/src/RgbLedStripController/Services/IBleTransport.cs @@ -0,0 +1,18 @@ +using RgbLedStripController.Core; + +namespace RgbLedStripController.Services; + +public interface IBleTransport : IAsyncDisposable +{ + event EventHandler? ConnectionStatusChanged; + + Task> ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default); + + Task ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default); + + Task DiscoverAsync(CancellationToken cancellationToken = default); + + Task WriteAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default); + + Task DisconnectAsync(); +} diff --git a/src/RgbLedStripController/Services/MultiDeviceController.cs b/src/RgbLedStripController/Services/MultiDeviceController.cs new file mode 100644 index 0000000..b79b899 --- /dev/null +++ b/src/RgbLedStripController/Services/MultiDeviceController.cs @@ -0,0 +1,729 @@ +using System.ComponentModel; +using RgbLedStripController.Core; +using MediaColor = System.Windows.Media.Color; + +namespace RgbLedStripController.Services; + +public sealed class MultiDeviceController : IAsyncDisposable +{ + private readonly SettingsStore _settingsStore; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly object _stateLock = new(); + private readonly object _pendingLock = new(); + private readonly Dictionary _connections = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _aliases = new(StringComparer.OrdinalIgnoreCase); + private readonly List _knownDevices = []; + private MediaColor? _pendingColor; + private byte? _pendingBrightness; + private bool _pendingEnsurePoweredOn; + + public MultiDeviceController(SettingsStore settingsStore, ControllerState state) + { + _settingsStore = settingsStore; + State = state; + } + + public ControllerState State { get; } + + public DeviceProfile? LastKnownDevice + { + get + { + lock (_stateLock) + { + return _knownDevices + .OrderByDescending(device => device.LastSeenUtc) + .FirstOrDefault(); + } + } + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + var snapshot = await _settingsStore.LoadAsync(cancellationToken); + + lock (_stateLock) + { + _aliases.Clear(); + foreach (var pair in snapshot.DeviceAliases) + { + _aliases[pair.Key] = pair.Value; + } + + _knownDevices.Clear(); + _knownDevices.AddRange(snapshot.KnownDevices); + } + + State.SelectedColor = snapshot.SelectedColor; + State.Brightness = snapshot.Brightness; + State.EffectSpeed = snapshot.EffectSpeed; + State.CurrentMode = snapshot.CurrentMode; + State.GrayscaleLevel = snapshot.GrayscaleLevel; + State.TemperatureLevel = snapshot.TemperatureLevel; + State.SelectedEffectId = snapshot.SelectedEffectId; + State.IsPoweredOn = snapshot.IsPoweredOn; + State.StatusText = LastKnownDevice is null + ? "Tarama için hazır." + : $"Son bilinen cihaz: {LastKnownDevice.LocalAlias}"; + + RecalculateAggregateState(); + } + + public async Task> ScanForDevicesAsync(TimeSpan duration, CancellationToken cancellationToken = default) + { + State.ConnectionStatus = DeviceConnectionStatus.Scanning; + State.StatusText = "BLE cihazları taranıyor..."; + + await using var scanner = new BleDeviceService(); + var results = await scanner.ScanAsync(duration, cancellationToken); + foreach (var item in results) + { + var alias = GetAlias(item.BluetoothAddress); + item.Alias = alias; + item.IsLastConnected = string.Equals(item.BluetoothAddress, LastKnownDevice?.BluetoothAddress, StringComparison.OrdinalIgnoreCase); + item.IsKnownCandidate = item.IsKnownCandidate || !string.IsNullOrWhiteSpace(alias); + } + + var candidateCount = results.Count(item => item.IsKnownCandidate); + State.StatusText = results.Count == 0 + ? "Tarama bitti, uygun cihaz bulunamadı." + : $"{results.Count} BLE cihazı bulundu, {candidateCount} tanesi aday görünüyor."; + + RecalculateAggregateState(); + return results + .OrderByDescending(device => device.IsLastConnected) + .ThenByDescending(device => device.IsKnownCandidate) + .ThenByDescending(device => device.SignalStrength) + .ToArray(); + } + + public async Task ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default) + { + DeviceConnectionContext? existing; + lock (_stateLock) + { + _connections.TryGetValue(device.BluetoothAddress, out existing); + } + + if (existing is not null) + { + await EnsureConnectedAsync(existing, cancellationToken); + return existing.ViewModel; + } + + var viewModel = new ConnectedDeviceViewModel + { + DeviceId = device.DeviceId, + NativeName = device.NativeName, + BluetoothAddress = device.BluetoothAddress, + AddressValue = device.AddressValue, + SignalStrength = device.SignalStrength, + Alias = GetAlias(device.BluetoothAddress), + ConnectionStatus = DeviceConnectionStatus.Connecting, + StatusText = "Bağlantı kuruluyor..." + }; + + var transport = new BleDeviceService(); + var context = new DeviceConnectionContext(device, viewModel, transport); + transport.ConnectionStatusChanged += HandleConnectionStatusChanged; + viewModel.PropertyChanged += HandleConnectedDevicePropertyChanged; + + lock (_stateLock) + { + _connections[device.BluetoothAddress] = context; + } + + try + { + await EnsureConnectedAsync(context, cancellationToken); + viewModel.StatusText = "Komut almaya hazır."; + UpsertKnownDevice(device); + await PersistStateAsync(cancellationToken); + RecalculateAggregateState(); + return viewModel; + } + catch + { + await RemoveConnectionAsync(device.BluetoothAddress, cancellationToken); + throw; + } + } + + public async Task DisconnectAsync(string bluetoothAddress, CancellationToken cancellationToken = default) + { + await RemoveConnectionAsync(bluetoothAddress, cancellationToken); + State.StatusText = "Seçili cihaz bağlantıdan çıkarıldı."; + RecalculateAggregateState(); + await PersistStateAsync(cancellationToken); + } + + public async Task DisconnectAllAsync(CancellationToken cancellationToken = default) + { + string[] addresses; + lock (_stateLock) + { + addresses = _connections.Keys.ToArray(); + } + + foreach (var address in addresses) + { + await RemoveConnectionAsync(address, cancellationToken); + } + + State.StatusText = "Tüm cihaz bağlantıları kapatıldı."; + RecalculateAggregateState(); + await PersistStateAsync(cancellationToken); + } + + public async Task SetAliasAsync(BleScanResult device, string alias, CancellationToken cancellationToken = default) + { + var trimmed = alias.Trim(); + DeviceConnectionContext? context; + + lock (_stateLock) + { + if (string.IsNullOrWhiteSpace(trimmed)) + { + _aliases.Remove(device.BluetoothAddress); + device.Alias = string.Empty; + } + else + { + _aliases[device.BluetoothAddress] = trimmed; + device.Alias = trimmed; + } + + _connections.TryGetValue(device.BluetoothAddress, out context); + } + + if (context is not null) + { + context.ViewModel.Alias = device.Alias; + } + + UpsertKnownDevice(device); + State.StatusText = string.IsNullOrWhiteSpace(device.Alias) + ? $"{device.NativeName} için varsayılan isim kullanılacak." + : $"{device.NativeName} için takma ad kaydedildi."; + + await PersistStateAsync(cancellationToken); + } + + public async Task ApplyPowerAsync(bool isOn, CancellationToken cancellationToken = default) + { + await BroadcastAsync( + async transport => await transport.WriteAsync(ElkBledomProtocol.BuildPower(isOn), cancellationToken), + successMessage: isOn ? "Işıklar açıldı." : "Işıklar kapatıldı.", + cancellationToken); + + State.IsPoweredOn = isOn; + await PersistStateAsync(cancellationToken); + } + + public async Task QueueColorAsync(MediaColor color) + { + EnsureTargetsAvailable(); + var shouldPowerOn = !State.IsPoweredOn; + + State.CurrentMode = LightingMode.Rgb; + State.SelectedColor = color; + State.IsPoweredOn = true; + State.StatusText = "Renk uygulanıyor..."; + + lock (_pendingLock) + { + _pendingColor = color; + _pendingEnsurePoweredOn |= shouldPowerOn; + } + + await DrainPendingWritesAsync(CancellationToken.None); + } + + public async Task QueueBrightnessAsync(byte brightness) + { + EnsureTargetsAvailable(); + var shouldPowerOn = !State.IsPoweredOn && brightness > 0; + + State.Brightness = brightness; + if (brightness > 0) + { + State.IsPoweredOn = true; + } + + State.StatusText = "Parlaklık uygulanıyor..."; + + lock (_pendingLock) + { + _pendingBrightness = brightness; + _pendingEnsurePoweredOn |= shouldPowerOn; + } + + await DrainPendingWritesAsync(CancellationToken.None); + } + + public async Task ApplyEffectAsync(byte patternId, CancellationToken cancellationToken = default) + { + EnsureTargetsAvailable(); + await DrainPendingWritesAsync(cancellationToken); + + State.CurrentMode = LightingMode.Effect; + State.SelectedEffectId = patternId; + State.IsPoweredOn = true; + + await BroadcastAsync( + async transport => + { + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildEffect(patternId), cancellationToken); + if (ElkBledomProtocol.EffectSupportsSpeed(patternId)) + { + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildEffectSpeed(State.EffectSpeed), cancellationToken); + } + }, + $"Efekt {patternId + 1:00} uygulandı.", + cancellationToken); + + await PersistStateAsync(cancellationToken); + } + + public async Task ApplyGrayscaleModeAsync(CancellationToken cancellationToken = default) + { + EnsureTargetsAvailable(); + State.CurrentMode = LightingMode.Grayscale; + State.IsPoweredOn = true; + + await BroadcastAsync( + async transport => + { + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildGrayscaleMode(), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildGrayscaleLevel(State.GrayscaleLevel), cancellationToken); + }, + "Gri ton modu uygulandı.", + cancellationToken); + + await PersistStateAsync(cancellationToken); + } + + public async Task ApplyGrayscaleLevelAsync(byte level, CancellationToken cancellationToken = default) + { + EnsureTargetsAvailable(); + State.CurrentMode = LightingMode.Grayscale; + State.GrayscaleLevel = level; + State.IsPoweredOn = true; + State.StatusText = "Gri ton uygulanıyor..."; + + await BroadcastAsync( + async transport => await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildGrayscaleLevel(level), cancellationToken), + "Gri ton uygulandı.", + cancellationToken); + + await PersistStateAsync(cancellationToken); + } + + public async Task ApplyTemperatureModeAsync(CancellationToken cancellationToken = default) + { + EnsureTargetsAvailable(); + State.CurrentMode = LightingMode.Temperature; + State.IsPoweredOn = true; + + await BroadcastAsync( + async transport => + { + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildTemperatureMode(State.TemperatureLevel), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildTemperatureLevel(State.TemperatureLevel), cancellationToken); + }, + "Beyaz sıcaklığı modu uygulandı.", + cancellationToken); + + await PersistStateAsync(cancellationToken); + } + + public async Task ApplyTemperatureLevelAsync(byte level, CancellationToken cancellationToken = default) + { + EnsureTargetsAvailable(); + State.CurrentMode = LightingMode.Temperature; + State.TemperatureLevel = level; + State.IsPoweredOn = true; + State.StatusText = "Beyaz sıcaklığı uygulanıyor..."; + + await BroadcastAsync( + async transport => await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildTemperatureLevel(level), cancellationToken), + "Beyaz sıcaklığı uygulandı.", + cancellationToken); + + await PersistStateAsync(cancellationToken); + } + + public async Task ApplyEffectSpeedAsync(byte speed, CancellationToken cancellationToken = default) + { + State.EffectSpeed = speed; + if (!ElkBledomProtocol.EffectSupportsSpeed(State.SelectedEffectId)) + { + await PersistStateAsync(cancellationToken); + return; + } + + EnsureTargetsAvailable(); + State.StatusText = "Efekt hızı uygulanıyor..."; + + await BroadcastAsync( + async transport => await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildEffectSpeed(speed), cancellationToken), + "Efekt hızı uygulandı.", + cancellationToken); + + await PersistStateAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + CancelPendingFlush(); + + DeviceConnectionContext[] contexts; + lock (_stateLock) + { + contexts = _connections.Values.ToArray(); + _connections.Clear(); + } + + foreach (var context in contexts) + { + context.Transport.ConnectionStatusChanged -= HandleConnectionStatusChanged; + context.ViewModel.PropertyChanged -= HandleConnectedDevicePropertyChanged; + await context.Transport.DisposeAsync(); + } + + _writeLock.Dispose(); + } + + private async Task EnsureConnectedAsync(DeviceConnectionContext context, CancellationToken cancellationToken) + { + context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connecting; + context.ViewModel.StatusText = "Bağlantı kuruluyor..."; + await context.Transport.ConnectAsync(context.Device, cancellationToken); + var discovered = await context.Transport.DiscoverAsync(cancellationToken); + if (!discovered) + { + context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Faulted; + context.ViewModel.StatusText = "fff0 / fff3 bulunamadı."; + throw new InvalidOperationException("Hedef cihazda fff0 / fff3 bulunamadı."); + } + + context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connected; + context.ViewModel.StatusText = "Komut almaya hazır."; + } + + private async Task BroadcastAsync(Func sendAsync, string successMessage, CancellationToken cancellationToken) + { + var targets = GetTargetConnections(); + if (targets.Count == 0) + { + throw new InvalidOperationException("Komut gönderebilmek için en az bir hedef cihaz seçmelisin."); + } + + await _writeLock.WaitAsync(cancellationToken); + + try + { + var successCount = 0; + var failureCount = 0; + var firstFailureMessage = string.Empty; + + foreach (var context in targets) + { + context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connecting; + context.ViewModel.StatusText = "Komut gönderiliyor..."; + + try + { + await sendAsync(context.Transport); + context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connected; + context.ViewModel.StatusText = successMessage; + UpsertKnownDevice(context.Device); + successCount++; + } + catch (Exception ex) + { + context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Faulted; + context.ViewModel.StatusText = $"Hata: {ex.Message}"; + failureCount++; + if (string.IsNullOrWhiteSpace(firstFailureMessage)) + { + firstFailureMessage = ex.Message; + } + } + + await Task.Delay(25, cancellationToken); + } + + State.StatusText = BuildStatusMessage(successMessage, targets.Count, successCount, failureCount, firstFailureMessage); + RecalculateAggregateState(); + } + finally + { + _writeLock.Release(); + } + } + + private async Task DrainPendingWritesAsync(CancellationToken cancellationToken) + { + MediaColor? color; + byte? brightness; + bool ensurePoweredOn; + + lock (_pendingLock) + { + color = _pendingColor; + brightness = _pendingBrightness; + ensurePoweredOn = _pendingEnsurePoweredOn; + _pendingColor = null; + _pendingBrightness = null; + _pendingEnsurePoweredOn = false; + } + + if (!color.HasValue && !brightness.HasValue) + { + return; + } + + await BroadcastAsync( + async transport => + { + var shouldResetRgbMode = color.HasValue || brightness.HasValue; + var shouldPowerOnBeforeStaticWrite = color.HasValue || brightness.GetValueOrDefault() > 0 || ensurePoweredOn; + var targetColor = color ?? State.SelectedColor; + var targetBrightness = brightness ?? State.Brightness; + if (shouldResetRgbMode) + { + if (shouldPowerOnBeforeStaticWrite) + { + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildLegacyPower(true), cancellationToken); + } + + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildRgbModeReset(), cancellationToken); + } + + if (color.HasValue || brightness.HasValue) + { + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildColor(targetColor), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildLegacyColor(targetColor), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildBrightness(targetBrightness), cancellationToken); + await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildLegacyBrightness(targetBrightness), cancellationToken); + } + }, + "Değerler uygulandı.", + cancellationToken); + + await PersistStateAsync(cancellationToken); + } + + private void CancelPendingFlush() + { + lock (_pendingLock) + { + _pendingColor = null; + _pendingBrightness = null; + _pendingEnsurePoweredOn = false; + } + } + + private async Task RemoveConnectionAsync(string bluetoothAddress, CancellationToken cancellationToken) + { + DeviceConnectionContext? context; + lock (_stateLock) + { + if (!_connections.TryGetValue(bluetoothAddress, out context)) + { + return; + } + + _connections.Remove(bluetoothAddress); + } + + if (context is null) + { + return; + } + + context.Transport.ConnectionStatusChanged -= HandleConnectionStatusChanged; + context.ViewModel.PropertyChanged -= HandleConnectedDevicePropertyChanged; + await context.Transport.DisconnectAsync(); + await context.Transport.DisposeAsync(); + cancellationToken.ThrowIfCancellationRequested(); + } + + private async Task PersistStateAsync(CancellationToken cancellationToken) + { + List knownDevicesSnapshot; + Dictionary aliasesSnapshot; + lock (_stateLock) + { + knownDevicesSnapshot = _knownDevices + .OrderByDescending(device => device.LastSeenUtc) + .ToList(); + aliasesSnapshot = new Dictionary(_aliases, StringComparer.OrdinalIgnoreCase); + } + + var snapshot = new SettingsSnapshot( + knownDevicesSnapshot, + aliasesSnapshot, + State.SelectedColor, + State.Brightness, + State.EffectSpeed, + State.CurrentMode, + State.GrayscaleLevel, + State.TemperatureLevel, + State.SelectedEffectId, + State.IsPoweredOn); + + await _settingsStore.SaveAsync(snapshot, cancellationToken); + } + + private void UpsertKnownDevice(BleScanResult device) + { + var alias = GetAlias(device.BluetoothAddress); + var profile = new DeviceProfile( + string.IsNullOrWhiteSpace(alias) ? device.NativeName : alias, + device.DeviceId, + device.BluetoothAddress, + DateTimeOffset.UtcNow); + + lock (_stateLock) + { + var existingIndex = _knownDevices.FindIndex(item => string.Equals(item.BluetoothAddress, device.BluetoothAddress, StringComparison.OrdinalIgnoreCase)); + if (existingIndex >= 0) + { + _knownDevices[existingIndex] = profile; + } + else + { + _knownDevices.Add(profile); + } + } + } + + private string GetAlias(string bluetoothAddress) + { + lock (_stateLock) + { + return _aliases.TryGetValue(bluetoothAddress, out var alias) + ? alias + : string.Empty; + } + } + + private IReadOnlyList GetTargetConnections() + { + lock (_stateLock) + { + return _connections.Values + .Where(context => context.ViewModel.IsControlTarget) + .ToArray(); + } + } + + private void HandleConnectionStatusChanged(object? sender, DeviceConnectionStatus status) + { + DeviceConnectionContext? context; + lock (_stateLock) + { + context = _connections.Values.FirstOrDefault(item => ReferenceEquals(item.Transport, sender)); + } + + if (context is null) + { + return; + } + + context.ViewModel.ConnectionStatus = status; + context.ViewModel.StatusText = status switch + { + DeviceConnectionStatus.Connected => "Komut almaya hazır.", + DeviceConnectionStatus.Disconnected => "Bağlantı koptu, sonraki yazmada tekrar kurulacak.", + DeviceConnectionStatus.Connecting => "Bağlantı kuruluyor...", + DeviceConnectionStatus.Faulted => "Bağlantı hata verdi.", + _ => context.ViewModel.StatusText + }; + + RecalculateAggregateState(); + } + + private void HandleConnectedDevicePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ConnectedDeviceViewModel.IsControlTarget)) + { + RecalculateAggregateState(); + } + } + + private void RecalculateAggregateState() + { + DeviceConnectionContext[] connections; + lock (_stateLock) + { + connections = _connections.Values.ToArray(); + } + + State.ConnectedDeviceCount = connections.Length; + State.ControlTargetCount = connections.Count(context => context.ViewModel.IsControlTarget); + State.ConnectionStatus = connections.Any(context => context.ViewModel.ConnectionStatus == DeviceConnectionStatus.Connected) + ? DeviceConnectionStatus.Connected + : connections.Any(context => context.ViewModel.ConnectionStatus == DeviceConnectionStatus.Connecting) + ? DeviceConnectionStatus.Connecting + : connections.Any(context => context.ViewModel.ConnectionStatus == DeviceConnectionStatus.Faulted) + ? DeviceConnectionStatus.Faulted + : DeviceConnectionStatus.Disconnected; + } + + private void EnsureTargetsAvailable() + { + lock (_stateLock) + { + if (!_connections.Values.Any(context => context.ViewModel.IsControlTarget)) + { + throw new InvalidOperationException("Komut gönderebilmek için en az bir hedef cihaz seçmelisin."); + } + } + } + + private static async Task WriteWithPacingAsync(IBleTransport transport, ReadOnlyMemory payload, CancellationToken cancellationToken) + { + await transport.WriteAsync(payload, cancellationToken); + await Task.Delay(90, cancellationToken); + } + + private static string BuildStatusMessage(string successMessage, int totalCount, int successCount, int failureCount, string firstFailureMessage) + { + if (failureCount == 0) + { + return successMessage; + } + + if (successCount == 0) + { + return string.IsNullOrWhiteSpace(firstFailureMessage) + ? $"{totalCount} hedefte işlem başarısız oldu." + : $"İşlem başarısız oldu: {firstFailureMessage}"; + } + + return $"{successMessage} {successCount}/{totalCount} hedef başarılı, {failureCount} hata."; + } + + private sealed class DeviceConnectionContext + { + public DeviceConnectionContext(BleScanResult device, ConnectedDeviceViewModel viewModel, IBleTransport transport) + { + Device = device; + ViewModel = viewModel; + Transport = transport; + } + + public BleScanResult Device { get; } + + public ConnectedDeviceViewModel ViewModel { get; } + + public IBleTransport Transport { get; } + } +} diff --git a/src/RgbLedStripController/Services/SettingsStore.cs b/src/RgbLedStripController/Services/SettingsStore.cs new file mode 100644 index 0000000..606598d --- /dev/null +++ b/src/RgbLedStripController/Services/SettingsStore.cs @@ -0,0 +1,140 @@ +using System.IO; +using System.Text.Json; +using RgbLedStripController.Core; +using MediaColor = System.Windows.Media.Color; + +namespace RgbLedStripController.Services; + +public sealed class SettingsStore +{ + private readonly string _settingsPath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public SettingsStore() + { + var settingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "RgbLedStripController"); + + _settingsPath = Path.Combine(settingsDirectory, "settings.json"); + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_settingsPath)) + { + return CreateDefaultSnapshot(); + } + + try + { + await using var stream = File.OpenRead(_settingsPath); + var stored = await JsonSerializer.DeserializeAsync(stream, _jsonOptions, cancellationToken); + + return new SettingsSnapshot( + stored?.KnownDevices ?? [], + new Dictionary(stored?.DeviceAliases ?? new Dictionary(), StringComparer.OrdinalIgnoreCase), + ParseColor(stored?.SelectedColorHex), + stored?.Brightness ?? 100, + stored?.EffectSpeed ?? 50, + stored?.CurrentMode ?? LightingMode.Rgb, + stored?.GrayscaleLevel ?? 100, + stored?.TemperatureLevel ?? 50, + stored?.SelectedEffectId ?? 0, + stored?.IsPoweredOn ?? false); + } + catch + { + return CreateDefaultSnapshot(); + } + } + + public async Task SaveAsync(SettingsSnapshot snapshot, CancellationToken cancellationToken = default) + { + await _writeLock.WaitAsync(cancellationToken); + + try + { + var directory = Path.GetDirectoryName(_settingsPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + await using var stream = File.Create(_settingsPath); + var stored = new StoredSettings + { + KnownDevices = snapshot.KnownDevices.ToList(), + DeviceAliases = new Dictionary(snapshot.DeviceAliases, StringComparer.OrdinalIgnoreCase), + SelectedColorHex = $"#{snapshot.SelectedColor.R:X2}{snapshot.SelectedColor.G:X2}{snapshot.SelectedColor.B:X2}", + Brightness = snapshot.Brightness, + EffectSpeed = snapshot.EffectSpeed, + CurrentMode = snapshot.CurrentMode, + GrayscaleLevel = snapshot.GrayscaleLevel, + TemperatureLevel = snapshot.TemperatureLevel, + SelectedEffectId = snapshot.SelectedEffectId, + IsPoweredOn = snapshot.IsPoweredOn + }; + + await JsonSerializer.SerializeAsync(stream, stored, _jsonOptions, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + private static SettingsSnapshot CreateDefaultSnapshot() + => new( + [], + new Dictionary(StringComparer.OrdinalIgnoreCase), + MediaColor.FromRgb(255, 106, 78), + 100, + 50, + LightingMode.Rgb, + 100, + 50, + 0, + false); + + private static MediaColor ParseColor(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length != 7 || value[0] != '#') + { + return MediaColor.FromRgb(255, 106, 78); + } + + return MediaColor.FromRgb( + Convert.ToByte(value.Substring(1, 2), 16), + Convert.ToByte(value.Substring(3, 2), 16), + Convert.ToByte(value.Substring(5, 2), 16)); + } + + private sealed class StoredSettings + { + public List KnownDevices { get; set; } = []; + + public Dictionary DeviceAliases { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + public string? SelectedColorHex { get; set; } + + public byte Brightness { get; set; } + + public byte EffectSpeed { get; set; } + + public LightingMode CurrentMode { get; set; } + + public byte GrayscaleLevel { get; set; } + + public byte TemperatureLevel { get; set; } + + public byte SelectedEffectId { get; set; } + + public bool IsPoweredOn { get; set; } + } +}