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
This commit is contained in:
fristy committed 2026-09-04 23:31:11 +03:00
commit c9e643d617
40 files changed
+5411

No files matched your search

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