commit acc34d9543b8208aef7b642f7d260ed362706cfa Author: fristyxd Date: Sat Sep 5 00:22:09 2026 +0300 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) 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 0000000..928efba Binary files /dev/null and b/src/RgbLedStripController/Assets/AppIcon.ico differ diff --git a/src/RgbLedStripController/Assets/AppIcon.png b/src/RgbLedStripController/Assets/AppIcon.png new file mode 100644 index 0000000..930729f Binary files /dev/null and b/src/RgbLedStripController/Assets/AppIcon.png differ 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; } + } +}