- 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
730 lines
26 KiB
C#
730 lines
26 KiB
C#
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; }
|
||
}
|
||
}
|