Files
rgb-led-strip-controller/src/RgbLedStripController/Services/MultiDeviceController.cs
T
fristy c9e643d617 Initial commit: RGB LED Strip Controller v0.3.0
- WPF + WinRT BLE controller for ELK-BLEDOM compatible LED strips

- Multi-device control, effects, grayscale and temperature modes

- Velopack updates via Gitea Releases, Gitea Actions CI
2026-09-04 23:31:11 +03:00

730 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, DeviceConnectionContext> _connections = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _aliases = new(StringComparer.OrdinalIgnoreCase);
private readonly List<DeviceProfile> _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<IReadOnlyList<BleScanResult>> 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<ConnectedDeviceViewModel> 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<IBleTransport, Task> 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<DeviceProfile> knownDevicesSnapshot;
Dictionary<string, string> aliasesSnapshot;
lock (_stateLock)
{
knownDevicesSnapshot = _knownDevices
.OrderByDescending(device => device.LastSeenUtc)
.ToList();
aliasesSnapshot = new Dictionary<string, string>(_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<DeviceConnectionContext> 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<byte> 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; }
}
}