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:
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; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user