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; } } }