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

+433
View File
@@ -0,0 +1,433 @@
<Application x:Class="RgbLedStripController.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<Color x:Key="BackgroundColor">#FF090D12</Color>
<Color x:Key="BackgroundSecondaryColor">#FF101721</Color>
<Color x:Key="CardColor">#FF121923</Color>
<Color x:Key="CardMutedColor">#FF161F2B</Color>
<Color x:Key="CardBorderColor">#1FFFFFFF</Color>
<Color x:Key="TextColor">#FFF4F7FB</Color>
<Color x:Key="MutedTextColor">#FF9AA8BA</Color>
<Color x:Key="AccentColor">#FF4CC2FF</Color>
<Color x:Key="AccentDeepColor">#FF198BB7</Color>
<Color x:Key="AccentWarmColor">#FFFF7A59</Color>
<Color x:Key="SuccessColor">#FF3DD598</Color>
<Color x:Key="WarningColor">#FFF6C453</Color>
<Color x:Key="DangerColor">#FFFF6B7A</Color>
<Color x:Key="SecondaryColor">#FF222E3F</Color>
<Color x:Key="InputColor">#FF0E151F</Color>
<Color x:Key="InputHoverColor">#FF121B28</Color>
<SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}" />
<SolidColorBrush x:Key="BackgroundSecondaryBrush" Color="{StaticResource BackgroundSecondaryColor}" />
<SolidColorBrush x:Key="CardBrush" Color="{StaticResource CardColor}" />
<SolidColorBrush x:Key="CardMutedBrush" Color="{StaticResource CardMutedColor}" />
<SolidColorBrush x:Key="CardBorderBrush" Color="{StaticResource CardBorderColor}" />
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}" />
<SolidColorBrush x:Key="MutedTextBrush" Color="{StaticResource MutedTextColor}" />
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentDeepBrush" Color="{StaticResource AccentDeepColor}" />
<SolidColorBrush x:Key="AccentWarmBrush" Color="{StaticResource AccentWarmColor}" />
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource SecondaryColor}" />
<SolidColorBrush x:Key="InputBrush" Color="{StaticResource InputColor}" />
<SolidColorBrush x:Key="InputHoverBrush" Color="{StaticResource InputHoverColor}" />
<SolidColorBrush x:Key="{x:Static SystemColors.WindowBrushKey}" Color="{StaticResource InputColor}" />
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}" Color="{StaticResource InputColor}" />
<SolidColorBrush x:Key="{x:Static SystemColors.WindowTextBrushKey}" Color="{StaticResource TextColor}" />
<SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}" Color="{StaticResource TextColor}" />
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="#FF091018" />
<SolidColorBrush x:Key="{x:Static SystemColors.GrayTextBrushKey}" Color="{StaticResource MutedTextColor}" />
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" Color="#33243B53" />
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="{StaticResource TextColor}" />
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource BackgroundBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontFamily" Value="Segoe UI Variable Display" />
<Setter Property="UseLayoutRounding" Value="True" />
<Setter Property="SnapsToDevicePixels" Value="True" />
</Style>
<Style x:Key="CardBorderStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Padding" Value="20" />
</Style>
<Style x:Key="PanelCardStyle" TargetType="Border" BasedOn="{StaticResource CardBorderStyle}">
<Setter Property="Background" Value="{StaticResource CardMutedBrush}" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="18" />
</Style>
<Style x:Key="SectionTitleStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Bahnschrift SemiCondensed" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style TargetType="Button">
<Setter Property="Foreground" Value="#FF061019" />
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentDeepBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="ButtonBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="12">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.96" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.84" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.40" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GhostButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Background" Value="{StaticResource SecondaryBrush}" />
<Setter Property="BorderBrush" Value="#36FFFFFF" />
</Style>
<Style x:Key="DangerGhostButtonStyle" TargetType="Button" BasedOn="{StaticResource GhostButtonStyle}">
<Setter Property="Foreground" Value="#FFFFC3CB" />
<Setter Property="BorderBrush" Value="#4DFF6B7A" />
<Setter Property="Background" Value="#261E2431" />
</Style>
<Style x:Key="WarmGhostButtonStyle" TargetType="Button" BasedOn="{StaticResource GhostButtonStyle}">
<Setter Property="Foreground" Value="{StaticResource AccentWarmBrush}" />
<Setter Property="BorderBrush" Value="#55FF7A59" />
<Setter Property="Background" Value="#221E2B33" />
</Style>
<Style x:Key="SwatchButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
<Setter Property="BorderBrush" Value="#55FFFFFF" />
</Style>
<Style TargetType="ListBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
</Style>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="ItemBorder"
Background="#FF151D29"
BorderBrush="#18FFFFFF"
BorderThickness="1"
CornerRadius="14"
Padding="14">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="#FF182435" />
<Setter TargetName="ItemBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="#FF182131" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource InputBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="BorderBrush" Value="#30FFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="TextBoxBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="12">
<ScrollViewer x:Name="PART_ContentHost"
Margin="{TemplateBinding Padding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="TextBoxBorder" Property="Background" Value="{StaticResource InputHoverBrush}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="TextBoxBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Background" Value="{StaticResource InputBrush}" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="ItemBorder"
Background="{TemplateBinding Background}"
CornerRadius="10">
<ContentPresenter Margin="{TemplateBinding Padding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="#FF1C2737" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="#FF081019" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource InputBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="BorderBrush" Value="#30FFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinHeight" Value="42" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
</Style>
<Style x:Key="EffectComboBoxToggleStyle" TargetType="ToggleButton">
<Setter Property="Focusable" Value="False" />
<Setter Property="Background" Value="{StaticResource InputBrush}" />
<Setter Property="BorderBrush" Value="#30FFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="ToggleBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter Margin="16,12,12,12"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
<Border Grid.Column="1"
Width="34"
Height="34"
Margin="0,6,6,6"
CornerRadius="10"
Background="#16FFFFFF">
<Path x:Name="Chevron"
Width="10"
Height="6"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="M 0 0 L 5 5 L 10 0"
Stroke="{StaticResource TextBrush}"
StrokeThickness="1.8"
Stretch="Fill" />
</Border>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ToggleBorder" Property="Background" Value="{StaticResource InputHoverBrush}" />
<Setter TargetName="ToggleBorder" Property="BorderBrush" Value="#46FFFFFF" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="ToggleBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter TargetName="ToggleBorder" Property="Background" Value="#FF122031" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="ToggleBorder" Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="EffectComboBoxStyle" TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
<Setter Property="Background" Value="#FF111925" />
<Setter Property="BorderBrush" Value="#30FFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="0" />
<Setter Property="MaxDropDownHeight" Value="300" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="DropDownToggle"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
ClickMode="Press"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"
Style="{StaticResource EffectComboBoxToggleStyle}">
<ContentPresenter Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
IsHitTestVisible="False" />
</ToggleButton>
<Popup x:Name="PART_Popup"
AllowsTransparency="True"
Focusable="False"
IsOpen="{TemplateBinding IsDropDownOpen}"
Placement="Bottom"
PopupAnimation="Fade"
StaysOpen="False">
<Border x:Name="PopupBorder"
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
Margin="0,8,0,0"
Padding="8"
Background="#FF0F1723"
BorderBrush="#30FFFFFF"
BorderThickness="1"
CornerRadius="16">
<ScrollViewer CanContentScroll="True"
SnapsToDevicePixels="True"
VerticalScrollBarVisibility="Auto">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="DropDownToggle" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Trigger>
<Trigger Property="HasItems" Value="False">
<Setter TargetName="PopupBorder" Property="MinHeight" Value="56" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="DropDownToggle" Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style x:Key="TargetCheckBoxStyle" TargetType="CheckBox" BasedOn="{StaticResource {x:Type CheckBox}}">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Border x:Name="Shell"
Padding="10,8"
Background="#FF101824"
BorderBrush="#24FFFFFF"
BorderThickness="1"
CornerRadius="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border x:Name="TickBox"
Width="20"
Height="20"
Background="#FF0B121B"
BorderBrush="#3AFFFFFF"
BorderThickness="1"
CornerRadius="7">
<TextBlock x:Name="CheckGlyph"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="13"
FontWeight="Bold"
Foreground="#FF061019"
Text="✓"
Visibility="Collapsed" />
</Border>
<ContentPresenter Grid.Column="2"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Shell" Property="BorderBrush" Value="#45FFFFFF" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Shell" Property="Background" Value="#FF142235" />
<Setter TargetName="Shell" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter TargetName="TickBox" Property="Background" Value="{StaticResource AccentBrush}" />
<Setter TargetName="TickBox" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter TargetName="CheckGlyph" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Shell" Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Slider">
<Setter Property="Margin" Value="0,10,0,0" />
</Style>
</Application.Resources>
</Application>
+17
View File
@@ -0,0 +1,17 @@
using System;
using Velopack;
namespace RgbLedStripController;
public partial class App : System.Windows.Application
{
[STAThread]
public static void Main(string[] args)
{
VelopackApp.Build().Run();
var app = new App();
app.InitializeComponent();
app.Run();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+491
View File
@@ -0,0 +1,491 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using MediaBrush = System.Windows.Media.Brush;
using MediaColor = System.Windows.Media.Color;
using SolidColorBrush = System.Windows.Media.SolidColorBrush;
namespace RgbLedStripController.Core;
public enum DeviceConnectionStatus
{
Disconnected,
Scanning,
Connecting,
Connected,
Faulted
}
public enum LightingMode
{
Rgb,
Effect,
Grayscale,
Temperature
}
public sealed class BleScanResult : ObservableObject
{
private string _alias = string.Empty;
private bool _isKnownCandidate;
private bool _isLastConnected;
public required string DeviceId { get; init; }
public required string Name { get; init; }
public required string BluetoothAddress { get; init; }
public required ulong AddressValue { get; init; }
public short SignalStrength { get; init; }
public string Alias
{
get => _alias;
set
{
if (!SetProperty(ref _alias, value ?? string.Empty))
{
return;
}
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(SecondaryLabel));
}
}
public bool IsKnownCandidate
{
get => _isKnownCandidate;
set
{
if (!SetProperty(ref _isKnownCandidate, value))
{
return;
}
OnPropertyChanged(nameof(Badge));
}
}
public bool IsLastConnected
{
get => _isLastConnected;
set
{
if (!SetProperty(ref _isLastConnected, value))
{
return;
}
OnPropertyChanged(nameof(Badge));
}
}
public string NativeName => string.IsNullOrWhiteSpace(Name)
? "İsimsiz BLE cihazı"
: Name;
public string DisplayName => string.IsNullOrWhiteSpace(Alias)
? NativeName
: Alias;
public string SecondaryLabel => string.IsNullOrWhiteSpace(Alias)
? BluetoothAddress
: $"{NativeName} - {BluetoothAddress}";
public string Badge => IsLastConnected
? "Son cihaz"
: IsKnownCandidate
? "Aday"
: "BLE";
public string SignalLabel => SignalStrength == short.MinValue
? "RSSI bilinmiyor"
: $"RSSI {SignalStrength} dBm";
}
public sealed record DeviceProfile(
string LocalAlias,
string DeviceId,
string BluetoothAddress,
DateTimeOffset LastSeenUtc);
public sealed record EffectOption(byte PatternId, string Label, bool SupportsSpeed)
{
public string Code => $"{PatternId + 1:00}";
}
public sealed record ColorPreset(string Label, string Hex)
{
public string ToolTipLabel => $"{Label} · {Hex}";
}
public sealed record SettingsSnapshot(
IReadOnlyList<DeviceProfile> KnownDevices,
IReadOnlyDictionary<string, string> DeviceAliases,
MediaColor SelectedColor,
byte Brightness,
byte EffectSpeed,
LightingMode CurrentMode,
byte GrayscaleLevel,
byte TemperatureLevel,
byte SelectedEffectId,
bool IsPoweredOn);
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public sealed class ConnectedDeviceViewModel : ObservableObject
{
private string _alias = string.Empty;
private bool _isControlTarget = true;
private DeviceConnectionStatus _connectionStatus = DeviceConnectionStatus.Connecting;
private string _statusText = "Bağlantı hazırlanıyor...";
private MediaBrush _statusBrush = CreateFrozenBrush(MediaColor.FromRgb(59, 130, 246));
public required string DeviceId { get; init; }
public required string NativeName { get; init; }
public required string BluetoothAddress { get; init; }
public required ulong AddressValue { get; init; }
public short SignalStrength { get; init; }
public string Alias
{
get => _alias;
set
{
if (!SetProperty(ref _alias, value ?? string.Empty))
{
return;
}
OnPropertyChanged(nameof(DisplayName));
OnPropertyChanged(nameof(SecondaryLabel));
}
}
public bool IsControlTarget
{
get => _isControlTarget;
set => SetProperty(ref _isControlTarget, value);
}
public DeviceConnectionStatus ConnectionStatus
{
get => _connectionStatus;
set
{
if (!SetProperty(ref _connectionStatus, value))
{
return;
}
_statusBrush = CreateFrozenBrush(GetStatusColor(value));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(StatusBrush));
}
}
public string StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
public string DisplayName => string.IsNullOrWhiteSpace(Alias)
? NativeName
: Alias;
public string SecondaryLabel => string.IsNullOrWhiteSpace(Alias)
? BluetoothAddress
: $"{NativeName} - {BluetoothAddress}";
public string SignalLabel => SignalStrength == short.MinValue
? "RSSI bilinmiyor"
: $"RSSI {SignalStrength} dBm";
public string StatusLabel => ConnectionStatus switch
{
DeviceConnectionStatus.Connecting => "Bağlanıyor",
DeviceConnectionStatus.Connected => "Hazır",
DeviceConnectionStatus.Faulted => "Hata",
DeviceConnectionStatus.Disconnected => "Beklemede",
_ => "Boş"
};
public MediaBrush StatusBrush => _statusBrush;
private static MediaColor GetStatusColor(DeviceConnectionStatus status)
=> status switch
{
DeviceConnectionStatus.Connecting => MediaColor.FromRgb(59, 130, 246),
DeviceConnectionStatus.Connected => MediaColor.FromRgb(16, 185, 129),
DeviceConnectionStatus.Faulted => MediaColor.FromRgb(244, 63, 94),
DeviceConnectionStatus.Disconnected => MediaColor.FromRgb(245, 158, 11),
_ => MediaColor.FromRgb(107, 114, 128)
};
private static MediaBrush CreateFrozenBrush(MediaColor color)
{
var brush = new SolidColorBrush(color);
brush.Freeze();
return brush;
}
}
public sealed class ControllerState : ObservableObject
{
private DeviceConnectionStatus _connectionStatus = DeviceConnectionStatus.Disconnected;
private bool _isPoweredOn;
private LightingMode _currentMode = LightingMode.Rgb;
private MediaColor _selectedColor = MediaColor.FromRgb(255, 106, 78);
private byte _brightness = 100;
private byte _effectSpeed = 50;
private byte _grayscaleLevel = 100;
private byte _temperatureLevel = 50;
private byte _selectedEffectId;
private int _connectedDeviceCount;
private int _controlTargetCount;
private string _statusText = "Tarama için hazır.";
private MediaBrush _statusBrush = CreateFrozenBrush(MediaColor.FromRgb(107, 114, 128));
private MediaBrush _selectedBrush = CreateFrozenBrush(MediaColor.FromRgb(255, 106, 78));
public DeviceConnectionStatus ConnectionStatus
{
get => _connectionStatus;
set
{
if (!SetProperty(ref _connectionStatus, value))
{
return;
}
_statusBrush = CreateFrozenBrush(GetStatusColor(value));
OnPropertyChanged(nameof(ConnectionLabel));
OnPropertyChanged(nameof(StatusBrush));
}
}
public bool IsPoweredOn
{
get => _isPoweredOn;
set
{
if (!SetProperty(ref _isPoweredOn, value))
{
return;
}
OnPropertyChanged(nameof(PowerButtonLabel));
}
}
public LightingMode CurrentMode
{
get => _currentMode;
set
{
if (!SetProperty(ref _currentMode, value))
{
return;
}
OnPropertyChanged(nameof(CurrentModeLabel));
}
}
public MediaColor SelectedColor
{
get => _selectedColor;
set
{
if (!SetProperty(ref _selectedColor, value))
{
return;
}
_selectedBrush = CreateFrozenBrush(value);
OnPropertyChanged(nameof(SelectedBrush));
OnPropertyChanged(nameof(SelectedHex));
OnPropertyChanged(nameof(SelectedRgbLabel));
}
}
public byte Brightness
{
get => _brightness;
set
{
if (!SetProperty(ref _brightness, value))
{
return;
}
OnPropertyChanged(nameof(BrightnessLabel));
}
}
public byte EffectSpeed
{
get => _effectSpeed;
set
{
if (!SetProperty(ref _effectSpeed, value))
{
return;
}
OnPropertyChanged(nameof(EffectSpeedLabel));
}
}
public byte GrayscaleLevel
{
get => _grayscaleLevel;
set
{
if (!SetProperty(ref _grayscaleLevel, value))
{
return;
}
OnPropertyChanged(nameof(GrayscaleLabel));
}
}
public byte TemperatureLevel
{
get => _temperatureLevel;
set
{
if (!SetProperty(ref _temperatureLevel, value))
{
return;
}
OnPropertyChanged(nameof(TemperatureLabel));
}
}
public byte SelectedEffectId
{
get => _selectedEffectId;
set => SetProperty(ref _selectedEffectId, value);
}
public int ConnectedDeviceCount
{
get => _connectedDeviceCount;
set
{
if (!SetProperty(ref _connectedDeviceCount, value))
{
return;
}
OnPropertyChanged(nameof(ConnectedSummary));
}
}
public int ControlTargetCount
{
get => _controlTargetCount;
set
{
if (!SetProperty(ref _controlTargetCount, value))
{
return;
}
OnPropertyChanged(nameof(TargetSummary));
}
}
public string StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
public string ConnectionLabel => ConnectionStatus switch
{
DeviceConnectionStatus.Scanning => "Taranıyor",
DeviceConnectionStatus.Connecting => "Bağlantı kuruluyor",
DeviceConnectionStatus.Connected => "Hazır",
DeviceConnectionStatus.Faulted => "Dikkat gerekiyor",
_ => "Beklemede"
};
public MediaBrush StatusBrush => _statusBrush;
public MediaBrush SelectedBrush => _selectedBrush;
public string SelectedHex => $"#{SelectedColor.R:X2}{SelectedColor.G:X2}{SelectedColor.B:X2}";
public string SelectedRgbLabel => $"RGB {SelectedColor.R}, {SelectedColor.G}, {SelectedColor.B}";
public string BrightnessLabel => $"%{Brightness}";
public string EffectSpeedLabel => $"%{EffectSpeed}";
public string GrayscaleLabel => $"%{GrayscaleLevel}";
public string TemperatureLabel => $"%{TemperatureLevel}";
public string CurrentModeLabel => CurrentMode switch
{
LightingMode.Effect => "Efekt",
LightingMode.Grayscale => "Gri ton",
LightingMode.Temperature => "Beyaz sıcaklığı",
_ => "RGB"
};
public string ConnectedSummary => ConnectedDeviceCount == 0
? "Bağlı cihaz yok"
: $"{ConnectedDeviceCount} cihaz hazır";
public string TargetSummary => ControlTargetCount == 0
? "Komut alacak cihaz seçilmedi"
: $"{ControlTargetCount} cihaz komut alacak";
public string PowerButtonLabel => IsPoweredOn ? "Hedefleri kapat" : "Hedefleri aç";
private static MediaColor GetStatusColor(DeviceConnectionStatus status)
=> status switch
{
DeviceConnectionStatus.Scanning => MediaColor.FromRgb(56, 189, 248),
DeviceConnectionStatus.Connecting => MediaColor.FromRgb(96, 165, 250),
DeviceConnectionStatus.Connected => MediaColor.FromRgb(16, 185, 129),
DeviceConnectionStatus.Faulted => MediaColor.FromRgb(244, 63, 94),
_ => MediaColor.FromRgb(107, 114, 128)
};
private static MediaBrush CreateFrozenBrush(MediaColor color)
{
var brush = new SolidColorBrush(color);
brush.Freeze();
return brush;
}
}
+632
View File
@@ -0,0 +1,632 @@
<Window x:Class="RgbLedStripController.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="RGB Led Strip Controller"
Height="800"
Width="1320"
MinWidth="1040"
MinHeight="700"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<DataTemplate x:Key="EffectOptionTemplate">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Padding="8,4"
Background="#18FFFFFF"
CornerRadius="8">
<TextBlock FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding Code}" />
</Border>
<TextBlock Grid.Column="2"
VerticalAlignment="Center"
FontSize="13"
FontWeight="SemiBold"
Text="{Binding Label}" />
</Grid>
</DataTemplate>
</Window.Resources>
<Border Margin="12"
Padding="24"
Background="#FF0D131B"
BorderBrush="#18FFFFFF"
BorderThickness="1"
CornerRadius="22"
ClipToBounds="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="16" />
<RowDefinition Height="*" />
<RowDefinition Height="12" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Style="{StaticResource CardBorderStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}"
FontSize="30"
Text="RGB Led Strip Controller" />
<TextBlock Margin="0,8,0,0"
FontSize="14"
Foreground="{StaticResource MutedTextBrush}"
Text="Windows 10 için sade, çok fonksiyonlu multi ELK-BLEDOM protokolüne uygun rgb led şerit kontrolcüsü." />
</StackPanel>
<StackPanel Grid.Column="1"
Margin="24,0,0,0"
VerticalAlignment="Center">
<Border HorizontalAlignment="Right"
Padding="12,6"
CornerRadius="12"
Background="{Binding State.StatusBrush}">
<TextBlock FontSize="13"
FontWeight="SemiBold"
Foreground="Black"
Text="{Binding State.ConnectionLabel}" />
</Border>
<TextBlock Margin="0,12,0,0"
HorizontalAlignment="Right"
FontSize="13"
Text="{Binding State.ConnectedSummary}" />
<TextBlock Margin="0,4,0,0"
HorizontalAlignment="Right"
FontSize="13"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding State.TargetSummary}" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320" />
<ColumnDefinition Width="18" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource CardBorderStyle}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="14" />
<RowDefinition Height="*" />
<RowDefinition Height="14" />
<RowDefinition Height="Auto" />
<RowDefinition Height="12" />
<RowDefinition Height="Auto" />
<RowDefinition Height="12" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource SectionTitleStyle}"
FontSize="22"
Text="Cihazlar" />
<Button Grid.Column="1"
Click="ScanClicked"
IsEnabled="{Binding CanScan}">
<TextBlock FontSize="13" Text="Tara" />
</Button>
</Grid>
<ListBox Grid.Row="2"
ItemsSource="{Binding Devices}"
SelectedItem="{Binding SelectedDevice, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock FontSize="15"
FontWeight="SemiBold"
Text="{Binding DisplayName}" />
<Border Grid.Column="1"
Margin="12,0,0,0"
Padding="8,3"
CornerRadius="8"
Background="#213CC7FF"
VerticalAlignment="Center">
<TextBlock FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}"
Text="{Binding Badge}" />
</Border>
</Grid>
<StackPanel Grid.Row="1" Margin="0,8,0,0">
<TextBlock FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding SecondaryLabel}" />
<TextBlock Margin="0,3,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding SignalLabel}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Border Grid.Row="4" Style="{StaticResource PanelCardStyle}">
<StackPanel>
<TextBlock FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Seçili cihaz" />
<TextBlock Margin="0,8,0,0"
FontSize="15"
FontWeight="SemiBold"
Text="{Binding SelectedDeviceSummaryDisplay}"
TextWrapping="Wrap" />
<TextBlock Margin="0,10,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding LastKnownDeviceCaptionDisplay}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Grid Grid.Row="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding AliasEditorText, UpdateSourceTrigger=PropertyChanged}" />
<Button Grid.Column="2"
Click="SaveAliasClicked"
IsEnabled="{Binding CanSaveAlias}">
<TextBlock FontSize="13" Text="Takma adı kaydet" />
</Button>
</Grid>
<Grid Grid.Row="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Click="ConnectClicked"
IsEnabled="{Binding CanConnect}">
<TextBlock FontSize="14" Text="Bağlan" />
</Button>
<Button Grid.Column="2"
Style="{StaticResource GhostButtonStyle}"
Click="DisconnectAllClicked"
IsEnabled="{Binding CanDisconnectAll}">
<TextBlock FontSize="14" Text="Tümünü kes" />
</Button>
</Grid>
</Grid>
</Border>
<Grid Grid.Column="2">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="16" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Style="{StaticResource CardBorderStyle}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="14" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource SectionTitleStyle}"
FontSize="22"
Text="Bağlı cihazlar" />
<ListBox Grid.Row="2"
ItemsSource="{Binding ConnectedDevices}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="182" />
</Grid.ColumnDefinitions>
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock FontSize="15"
FontWeight="SemiBold"
Text="{Binding DisplayName}" />
<Border Grid.Column="1"
Margin="12,0,0,0"
Padding="8,3"
CornerRadius="8"
Background="{Binding StatusBrush}"
VerticalAlignment="Center">
<TextBlock FontSize="11"
FontWeight="SemiBold"
Foreground="Black"
Text="{Binding StatusLabel}" />
</Border>
</Grid>
<TextBlock Margin="0,8,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding SecondaryLabel}" />
<TextBlock Margin="0,3,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding SignalLabel}" />
<TextBlock Margin="0,8,0,0"
FontSize="13"
Text="{Binding StatusText}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<CheckBox Content="Komuta dahil"
IsChecked="{Binding IsControlTarget, Mode=TwoWay}"
Margin="0,0,0,10"
Style="{StaticResource TargetCheckBoxStyle}" />
<Button Style="{StaticResource DangerGhostButtonStyle}"
Click="DisconnectConnectedClicked"
Tag="{Binding}">
<TextBlock FontSize="13" Text="Bağlantıyı kes" />
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
<Border Grid.Row="2" Style="{StaticResource CardBorderStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="1.15*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="0.95*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource PanelCardStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}"
FontSize="20"
Text="Güç ve parlaklık" />
<Button Margin="0,16,0,0"
Click="PowerClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="14" Text="{Binding State.PowerButtonLabel}" />
</Button>
<TextBlock Margin="0,22,0,0"
FontSize="13"
Foreground="{StaticResource MutedTextBrush}"
Text="Parlaklık" />
<DockPanel Margin="0,6,0,0">
<TextBlock DockPanel.Dock="Right"
FontSize="15"
FontWeight="SemiBold"
Text="{Binding State.BrightnessLabel}" />
</DockPanel>
<Slider x:Name="BrightnessSlider"
Minimum="0"
Maximum="100"
TickFrequency="5"
Value="{Binding State.Brightness}"
IsEnabled="{Binding CanUseBrightnessControl}"
LostMouseCapture="BrightnessCommitLostMouseCapture"
PreviewKeyUp="BrightnessCommitKeyUp"
PreviewMouseLeftButtonUp="BrightnessCommitMouseUp"
ValueChanged="BrightnessChanged" />
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource PanelCardStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}"
FontSize="20"
Text="Renk" />
<StackPanel Orientation="Horizontal" Margin="0,16,0,0">
<Border Width="74"
Height="74"
CornerRadius="12"
Background="{Binding State.SelectedBrush}" />
<StackPanel Margin="14,4,0,0"
VerticalAlignment="Center">
<TextBlock FontSize="16"
FontWeight="SemiBold"
Text="{Binding State.SelectedHex}" />
<TextBlock Margin="0,4,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding State.SelectedRgbLabel}" />
</StackPanel>
</StackPanel>
<TextBlock Margin="0,18,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Hızlı renkler" />
<ItemsControl Margin="0,14,0,0"
ItemsSource="{Binding ColorPresets}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="7" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Width="38"
Height="38"
Margin="0,0,10,10"
Background="{Binding Hex}"
Tag="{Binding Hex}"
ToolTip="{Binding ToolTipLabel}"
Click="PresetColorClicked"
Style="{StaticResource SwatchButtonStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Margin="0,4,0,0"
Style="{StaticResource WarmGhostButtonStyle}"
Click="SelectColorClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="14" Text="Renk seçici" />
</Button>
</StackPanel>
</Border>
<Border Grid.Column="4" Style="{StaticResource PanelCardStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}"
FontSize="20"
Text="Mod" />
<TextBlock Margin="0,16,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Aktif mod" />
<TextBlock Margin="0,8,0,0"
FontSize="15"
FontWeight="SemiBold"
Text="{Binding State.CurrentModeLabel}" />
<UniformGrid Margin="0,14,0,0"
Columns="4">
<Button Margin="0,0,8,0"
Click="SwitchRgbModeClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="13" Text="RGB" />
</Button>
<Button Margin="0,0,8,0"
Click="SwitchEffectModeClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="13" Text="Efekt" />
</Button>
<Button Click="SwitchGrayscaleModeClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="13" Text="Gri ton" />
</Button>
<Button Margin="8,0,0,0"
Click="SwitchTemperatureModeClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="13" Text="Beyaz" />
</Button>
</UniformGrid>
<StackPanel Margin="0,18,0,0"
Visibility="{Binding CanShowEffectControls, Converter={StaticResource BooleanToVisibilityConverter}}">
<TextBlock FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Hazır efekt" />
<ComboBox x:Name="EffectsComboBox"
Margin="0,10,0,0"
Style="{StaticResource EffectComboBoxStyle}"
ItemTemplate="{StaticResource EffectOptionTemplate}"
ItemsSource="{Binding Effects}"
SelectedValuePath="PatternId"
SelectedValue="{Binding State.SelectedEffectId}"
SelectionChanged="EffectChanged"
IsEnabled="{Binding CanUseControls}" />
</StackPanel>
<StackPanel Margin="0,18,0,0"
Visibility="{Binding CanShowEffectSpeed, Converter={StaticResource BooleanToVisibilityConverter}}">
<TextBlock FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Efekt hızı" />
<DockPanel Margin="0,6,0,0">
<TextBlock DockPanel.Dock="Right"
FontSize="15"
FontWeight="SemiBold"
Text="{Binding State.EffectSpeedLabel}" />
</DockPanel>
<Slider x:Name="EffectSpeedSlider"
Minimum="0"
Maximum="100"
TickFrequency="5"
Value="{Binding State.EffectSpeed}"
IsEnabled="{Binding CanUseControls}"
LostMouseCapture="EffectSpeedCommitLostMouseCapture"
PreviewKeyUp="EffectSpeedCommitKeyUp"
PreviewMouseLeftButtonUp="EffectSpeedCommitMouseUp"
ValueChanged="EffectSpeedChanged" />
</StackPanel>
<StackPanel Margin="0,18,0,0"
Visibility="{Binding CanShowGrayscaleControls, Converter={StaticResource BooleanToVisibilityConverter}}">
<TextBlock FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Gri ton" />
<DockPanel Margin="0,6,0,0">
<TextBlock DockPanel.Dock="Right"
FontSize="15"
FontWeight="SemiBold"
Text="{Binding State.GrayscaleLabel}" />
</DockPanel>
<Slider x:Name="GrayscaleSlider"
Minimum="0"
Maximum="100"
TickFrequency="5"
Value="{Binding State.GrayscaleLevel}"
IsEnabled="{Binding CanUseControls}"
LostMouseCapture="GrayscaleCommitLostMouseCapture"
PreviewKeyUp="GrayscaleCommitKeyUp"
PreviewMouseLeftButtonUp="GrayscaleCommitMouseUp"
ValueChanged="GrayscaleChanged" />
</StackPanel>
<StackPanel Margin="0,18,0,0"
Visibility="{Binding CanShowTemperatureControls, Converter={StaticResource BooleanToVisibilityConverter}}">
<TextBlock FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Beyaz sıcaklığı" />
<DockPanel Margin="0,6,0,0">
<TextBlock DockPanel.Dock="Right"
FontSize="15"
FontWeight="SemiBold"
Text="{Binding State.TemperatureLabel}" />
</DockPanel>
<Slider x:Name="TemperatureSlider"
Minimum="0"
Maximum="100"
TickFrequency="5"
Value="{Binding State.TemperatureLevel}"
IsEnabled="{Binding CanUseControls}"
LostMouseCapture="TemperatureCommitLostMouseCapture"
PreviewKeyUp="TemperatureCommitKeyUp"
PreviewMouseLeftButtonUp="TemperatureCommitMouseUp"
ValueChanged="TemperatureChanged" />
<UniformGrid Margin="0,14,0,0"
Columns="3">
<Button Margin="0,0,8,0"
Tag="0"
Click="TemperaturePresetClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="12" Text="Soğuk" />
</Button>
<Button Margin="0,0,8,0"
Tag="50"
Click="TemperaturePresetClicked"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="12" Text="Doğal" />
</Button>
<Button Click="TemperaturePresetClicked"
Tag="100"
IsEnabled="{Binding CanUseControls}">
<TextBlock FontSize="12" Text="Sıcak" />
</Button>
</UniformGrid>
</StackPanel>
<TextBlock Margin="0,20,0,0"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="Durum" />
<TextBlock Margin="0,8,0,0"
FontSize="15"
FontWeight="SemiBold"
Text="{Binding State.StatusText}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
<Border Grid.Row="4"
Padding="14,12"
Background="#99111822"
BorderBrush="#18FFFFFF"
BorderThickness="1"
CornerRadius="14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock VerticalAlignment="Center"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}">
<Run Text="Made by " />
<Hyperlink NavigateUri="https://fristy.net"
RequestNavigate="FooterLinkClicked">
<Run Text="@fristyxd" />
</Hyperlink>
</TextBlock>
<StackPanel Grid.Column="2"
VerticalAlignment="Center"
HorizontalAlignment="Center">
<TextBlock HorizontalAlignment="Center"
FontSize="12"
FontWeight="SemiBold"
Text="{Binding VersionDisplay}" />
<TextBlock Margin="0,4,0,0"
HorizontalAlignment="Center"
FontSize="12"
Foreground="{StaticResource MutedTextBrush}"
Text="{Binding UpdateStatusText}"
TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="4"
Click="CheckForUpdatesClicked"
IsEnabled="{Binding CanCheckForUpdates}">
<TextBlock FontSize="13"
Text="{Binding UpdateButtonLabel}" />
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Window>
@@ -0,0 +1,860 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using Forms = System.Windows.Forms;
using RgbLedStripController.Core;
using RgbLedStripController.Services;
using Velopack;
using Velopack.Sources;
using MediaColor = System.Windows.Media.Color;
using WpfButton = System.Windows.Controls.Button;
namespace RgbLedStripController;
public partial class MainWindow : Window, INotifyPropertyChanged
{
private const string GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller";
private readonly MultiDeviceController _controller;
private bool _effectSpeedCommitPending;
private bool _grayscaleCommitPending;
private bool _isBusy;
private bool _isCheckingForUpdates;
private bool _brightnessCommitPending;
private bool _temperatureCommitPending;
private bool _isInitializing = true;
private bool _updatePendingRestart;
private BleScanResult? _selectedDevice;
private string _aliasEditorText = string.Empty;
private string _updateStatusText = "G\u00fcncelleme kontrol\u00fc haz\u0131r.";
public MainWindow()
{
InitializeComponent();
TrySetWindowIcon();
State = new ControllerState();
Effects = ElkBledomProtocol.CreateEffectOptions();
ColorPresets = CreateColorPresets();
Devices = new ObservableCollection<BleScanResult>();
ConnectedDevices = new ObservableCollection<ConnectedDeviceViewModel>();
ConnectedDevices.CollectionChanged += HandleConnectedDevicesCollectionChanged;
_controller = new MultiDeviceController(new SettingsStore(), State);
State.PropertyChanged += HandleStateChanged;
DataContext = this;
Loaded += HandleLoaded;
}
public event PropertyChangedEventHandler? PropertyChanged;
public ControllerState State { get; }
public ObservableCollection<BleScanResult> Devices { get; }
public ObservableCollection<ConnectedDeviceViewModel> ConnectedDevices { get; }
public IReadOnlyList<EffectOption> Effects { get; }
public IReadOnlyList<ColorPreset> ColorPresets { get; }
public string VersionDisplay { get; } = $"v{GetApplicationVersion()}";
public string UpdateStatusText
{
get => _updateStatusText;
private set => SetProperty(ref _updateStatusText, value);
}
public bool CanCheckForUpdates => !_isCheckingForUpdates;
public string UpdateButtonLabel => _isCheckingForUpdates
? "Kontrol ediliyor..."
: _updatePendingRestart
? "G\u00fcncellemeyi uygula"
: "G\u00fcncellemeleri kontrol et";
public BleScanResult? SelectedDevice
{
get => _selectedDevice;
set
{
if (!SetProperty(ref _selectedDevice, value))
{
return;
}
AliasEditorText = value?.Alias ?? string.Empty;
NotifySelectedDeviceChanged();
}
}
public string AliasEditorText
{
get => _aliasEditorText;
set => SetProperty(ref _aliasEditorText, value);
}
public bool CanScan => !_isBusy;
public bool CanConnect => !_isBusy
&& SelectedDevice is not null
&& ConnectedDevices.All(device => !string.Equals(device.BluetoothAddress, SelectedDevice.BluetoothAddress, StringComparison.OrdinalIgnoreCase));
public bool CanSaveAlias => !_isBusy && SelectedDevice is not null;
public bool CanUseControls => !_isBusy && State.ControlTargetCount > 0;
public bool CanUseBrightnessControl => CanUseControls && State.CurrentMode != LightingMode.Grayscale;
public bool CanDisconnectAll => !_isBusy && ConnectedDevices.Count > 0;
public bool CanShowEffectControls => State.CurrentMode == LightingMode.Effect;
public bool CanShowEffectSpeed => CanShowEffectControls && Effects.Any(item => item.PatternId == State.SelectedEffectId && item.SupportsSpeed);
public bool CanShowGrayscaleControls => State.CurrentMode == LightingMode.Grayscale;
public bool CanShowTemperatureControls => State.CurrentMode == LightingMode.Temperature;
public string LastKnownDeviceCaptionDisplay => _controller.LastKnownDevice is null
? "Henüz kayıtlı cihaz yok."
: $"Son kayıt: {_controller.LastKnownDevice.LocalAlias} ({_controller.LastKnownDevice.BluetoothAddress})";
public string SelectedDeviceSummaryDisplay => SelectedDevice is null
? "Bir cihaz seç."
: $"{SelectedDevice.DisplayName} - {SelectedDevice.BluetoothAddress}";
public string LastKnownDeviceCaption => _controller.LastKnownDevice is null
? "Henüz cihaz hafızası yok."
: $"{_controller.LastKnownDevice.LocalAlias} ({_controller.LastKnownDevice.BluetoothAddress})";
public string SelectedDeviceSummary => SelectedDevice is null
? "Seçim yapılmadı."
: $"{SelectedDevice.DisplayName} - {SelectedDevice.BluetoothAddress}";
protected override async void OnClosed(EventArgs e)
{
State.PropertyChanged -= HandleStateChanged;
ConnectedDevices.CollectionChanged -= HandleConnectedDevicesCollectionChanged;
await _controller.DisposeAsync();
base.OnClosed(e);
}
private async void HandleLoaded(object sender, RoutedEventArgs e)
{
await RunBusyAsync(async () =>
{
await _controller.InitializeAsync();
EffectsComboBox.SelectedValue = State.SelectedEffectId;
BrightnessSlider.Value = State.Brightness;
EffectSpeedSlider.Value = State.EffectSpeed;
GrayscaleSlider.Value = State.GrayscaleLevel;
TemperatureSlider.Value = State.TemperatureLevel;
NotifyLastKnownDeviceChanged();
}, treatErrorAsFault: true);
_isInitializing = false;
_ = CheckForUpdatesAsync(isManualCheck: false);
}
private async void ScanClicked(object sender, RoutedEventArgs e)
{
await RunBusyAsync(async () =>
{
var scanned = await _controller.ScanForDevicesAsync(TimeSpan.FromSeconds(10));
Devices.Clear();
foreach (var device in scanned)
{
Devices.Add(device);
}
SelectedDevice = Devices.FirstOrDefault(device => device.IsLastConnected) ?? Devices.FirstOrDefault();
});
}
private async void ConnectClicked(object sender, RoutedEventArgs e)
{
if (SelectedDevice is null)
{
return;
}
await RunBusyAsync(async () =>
{
var connected = await _controller.ConnectAsync(SelectedDevice);
if (ConnectedDevices.All(device => !string.Equals(device.BluetoothAddress, connected.BluetoothAddress, StringComparison.OrdinalIgnoreCase)))
{
ConnectedDevices.Add(connected);
}
SelectedDevice.IsLastConnected = true;
NotifyLastKnownDeviceChanged();
}, treatErrorAsFault: true);
}
private async void SaveAliasClicked(object sender, RoutedEventArgs e)
{
if (SelectedDevice is null)
{
return;
}
await RunBusyAsync(async () =>
{
await _controller.SetAliasAsync(SelectedDevice, AliasEditorText);
NotifySelectedDeviceChanged();
NotifyLastKnownDeviceChanged();
});
}
private async void DisconnectConnectedClicked(object sender, RoutedEventArgs e)
{
if (sender is not WpfButton { Tag: ConnectedDeviceViewModel device })
{
return;
}
await RunBusyAsync(async () =>
{
await _controller.DisconnectAsync(device.BluetoothAddress);
ConnectedDevices.Remove(device);
});
}
private async void DisconnectAllClicked(object sender, RoutedEventArgs e)
{
await RunBusyAsync(async () =>
{
await _controller.DisconnectAllAsync();
ConnectedDevices.Clear();
});
}
private async void PowerClicked(object sender, RoutedEventArgs e)
{
await RunBusyAsync(
async () => await _controller.ApplyPowerAsync(!State.IsPoweredOn),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async void SelectColorClicked(object sender, RoutedEventArgs e)
{
if (!CanUseControls)
{
return;
}
using var colorDialog = new Forms.ColorDialog
{
FullOpen = true,
Color = System.Drawing.Color.FromArgb(State.SelectedColor.R, State.SelectedColor.G, State.SelectedColor.B)
};
if (colorDialog.ShowDialog() != Forms.DialogResult.OK)
{
return;
}
var color = MediaColor.FromRgb(colorDialog.Color.R, colorDialog.Color.G, colorDialog.Color.B);
await RunBusyAsync(
async () => await _controller.QueueColorAsync(color),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async void PresetColorClicked(object sender, RoutedEventArgs e)
{
if (!CanUseControls || sender is not WpfButton { Tag: string tag })
{
return;
}
var color = ParseHexColor(tag);
await RunBusyAsync(
async () => await _controller.QueueColorAsync(color),
useBusyFlag: false,
treatErrorAsFault: true);
}
private void BrightnessChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (_isInitializing)
{
return;
}
State.Brightness = (byte)Math.Round(e.NewValue);
if (!CanUseBrightnessControl)
{
_brightnessCommitPending = false;
return;
}
_brightnessCommitPending = true;
}
private async void BrightnessCommitMouseUp(object sender, MouseButtonEventArgs e)
=> await CommitBrightnessIfPendingAsync();
private async void BrightnessCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
=> await CommitBrightnessIfPendingAsync();
private async void BrightnessCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
=> await CommitBrightnessIfPendingAsync();
private async void EffectChanged(object sender, SelectionChangedEventArgs e)
{
if (_isInitializing)
{
return;
}
if (EffectsComboBox.SelectedValue is not byte patternId || !CanUseControls)
{
return;
}
State.SelectedEffectId = patternId;
await RunBusyAsync(
async () => await _controller.ApplyEffectAsync(patternId),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async void SwitchRgbModeClicked(object sender, RoutedEventArgs e)
{
if (!CanUseControls)
{
return;
}
await RunBusyAsync(
async () => await _controller.QueueColorAsync(State.SelectedColor),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async void SwitchEffectModeClicked(object sender, RoutedEventArgs e)
{
if (!CanUseControls)
{
return;
}
await RunBusyAsync(
async () => await _controller.ApplyEffectAsync(State.SelectedEffectId),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async void SwitchGrayscaleModeClicked(object sender, RoutedEventArgs e)
{
if (!CanUseControls)
{
return;
}
await RunBusyAsync(
async () => await _controller.ApplyGrayscaleModeAsync(),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async void SwitchTemperatureModeClicked(object sender, RoutedEventArgs e)
{
if (!CanUseControls)
{
return;
}
await RunBusyAsync(
async () => await _controller.ApplyTemperatureModeAsync(),
useBusyFlag: false,
treatErrorAsFault: true);
}
private void EffectSpeedChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (_isInitializing)
{
return;
}
State.EffectSpeed = (byte)Math.Round(e.NewValue);
if (!CanUseControls || !CanShowEffectSpeed)
{
_effectSpeedCommitPending = false;
return;
}
_effectSpeedCommitPending = true;
}
private async void EffectSpeedCommitMouseUp(object sender, MouseButtonEventArgs e)
=> await CommitEffectSpeedIfPendingAsync();
private async void EffectSpeedCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
=> await CommitEffectSpeedIfPendingAsync();
private async void EffectSpeedCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
=> await CommitEffectSpeedIfPendingAsync();
private void GrayscaleChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (_isInitializing)
{
return;
}
State.GrayscaleLevel = (byte)Math.Round(e.NewValue);
if (!CanUseControls || !CanShowGrayscaleControls)
{
_grayscaleCommitPending = false;
return;
}
_grayscaleCommitPending = true;
}
private async void GrayscaleCommitMouseUp(object sender, MouseButtonEventArgs e)
=> await CommitGrayscaleIfPendingAsync();
private async void GrayscaleCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
=> await CommitGrayscaleIfPendingAsync();
private async void GrayscaleCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
=> await CommitGrayscaleIfPendingAsync();
private void TemperatureChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (_isInitializing)
{
return;
}
State.TemperatureLevel = (byte)Math.Round(e.NewValue);
if (!CanUseControls || !CanShowTemperatureControls)
{
_temperatureCommitPending = false;
return;
}
_temperatureCommitPending = true;
}
private async void TemperatureCommitMouseUp(object sender, MouseButtonEventArgs e)
=> await CommitTemperatureIfPendingAsync();
private async void TemperatureCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
=> await CommitTemperatureIfPendingAsync();
private async void TemperatureCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
=> await CommitTemperatureIfPendingAsync();
private async void TemperaturePresetClicked(object sender, RoutedEventArgs e)
{
if (!CanUseControls || sender is not WpfButton { Tag: var rawTag } || !byte.TryParse(rawTag?.ToString(), out var level))
{
return;
}
State.TemperatureLevel = level;
TemperatureSlider.Value = level;
_temperatureCommitPending = false;
await RunBusyAsync(
async () => await _controller.ApplyTemperatureLevelAsync(level),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async void CheckForUpdatesClicked(object sender, RoutedEventArgs e)
=> await CheckForUpdatesAsync(isManualCheck: true);
private async Task RunBusyAsync(Func<Task> action, bool useBusyFlag = true, bool treatErrorAsFault = false)
{
if (useBusyFlag && _isBusy)
{
return;
}
try
{
if (useBusyFlag)
{
_isBusy = true;
NotifyBusyDependentProperties();
}
await action();
}
catch (Exception ex)
{
if (treatErrorAsFault)
{
State.ConnectionStatus = DeviceConnectionStatus.Faulted;
}
State.StatusText = ex.Message;
}
finally
{
if (useBusyFlag)
{
_isBusy = false;
NotifyBusyDependentProperties();
}
}
}
private void HandleStateChanged(object? sender, PropertyChangedEventArgs e)
{
if (string.IsNullOrWhiteSpace(e.PropertyName)
|| string.Equals(e.PropertyName, nameof(ControllerState.ControlTargetCount), StringComparison.Ordinal))
{
OnPropertyChanged(nameof(CanUseControls));
}
if (string.IsNullOrWhiteSpace(e.PropertyName)
|| string.Equals(e.PropertyName, nameof(ControllerState.SelectedEffectId), StringComparison.Ordinal)
|| string.Equals(e.PropertyName, nameof(ControllerState.CurrentMode), StringComparison.Ordinal))
{
if (!CanShowEffectSpeed)
{
_effectSpeedCommitPending = false;
}
if (!CanShowGrayscaleControls)
{
_grayscaleCommitPending = false;
}
if (!CanShowTemperatureControls)
{
_temperatureCommitPending = false;
}
OnPropertyChanged(nameof(CanUseBrightnessControl));
OnPropertyChanged(nameof(CanShowEffectControls));
OnPropertyChanged(nameof(CanShowEffectSpeed));
OnPropertyChanged(nameof(CanShowGrayscaleControls));
OnPropertyChanged(nameof(CanShowTemperatureControls));
}
}
private void HandleConnectedDevicesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(CanConnect));
OnPropertyChanged(nameof(CanDisconnectAll));
}
private void NotifyBusyDependentProperties()
{
OnPropertyChanged(nameof(CanScan));
OnPropertyChanged(nameof(CanConnect));
OnPropertyChanged(nameof(CanSaveAlias));
OnPropertyChanged(nameof(CanUseControls));
OnPropertyChanged(nameof(CanUseBrightnessControl));
OnPropertyChanged(nameof(CanDisconnectAll));
}
private void NotifySelectedDeviceChanged()
{
OnPropertyChanged(nameof(CanConnect));
OnPropertyChanged(nameof(CanSaveAlias));
OnPropertyChanged(nameof(SelectedDeviceSummary));
OnPropertyChanged(nameof(SelectedDeviceSummaryDisplay));
}
private void NotifyLastKnownDeviceChanged()
{
OnPropertyChanged(nameof(LastKnownDeviceCaption));
OnPropertyChanged(nameof(LastKnownDeviceCaptionDisplay));
}
private async Task CommitBrightnessIfPendingAsync()
{
if (!_brightnessCommitPending || _isInitializing || !CanUseBrightnessControl)
{
return;
}
_brightnessCommitPending = false;
await RunBusyAsync(
async () => await _controller.QueueBrightnessAsync(State.Brightness),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async Task CommitEffectSpeedIfPendingAsync()
{
if (!_effectSpeedCommitPending || _isInitializing || !CanUseControls || !CanShowEffectSpeed)
{
return;
}
_effectSpeedCommitPending = false;
await RunBusyAsync(
async () => await _controller.ApplyEffectSpeedAsync(State.EffectSpeed),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async Task CommitGrayscaleIfPendingAsync()
{
if (!_grayscaleCommitPending || _isInitializing || !CanUseControls || !CanShowGrayscaleControls)
{
return;
}
_grayscaleCommitPending = false;
await RunBusyAsync(
async () => await _controller.ApplyGrayscaleLevelAsync(State.GrayscaleLevel),
useBusyFlag: false,
treatErrorAsFault: true);
}
private async Task CommitTemperatureIfPendingAsync()
{
if (!_temperatureCommitPending || _isInitializing || !CanUseControls || !CanShowTemperatureControls)
{
return;
}
_temperatureCommitPending = false;
await RunBusyAsync(
async () => await _controller.ApplyTemperatureLevelAsync(State.TemperatureLevel),
useBusyFlag: false,
treatErrorAsFault: true);
}
private static IReadOnlyList<ColorPreset> CreateColorPresets()
=> new[]
{
new ColorPreset("Kırmızı", "#FF0000"),
new ColorPreset("Turuncu", "#FF9500"),
new ColorPreset("Amber", "#FFCC00"),
new ColorPreset("Sarı", "#FFD60A"),
new ColorPreset("Lime", "#A8E10C"),
new ColorPreset("Yeşil", "#34C759"),
new ColorPreset("Turkuaz", "#30D5C8"),
new ColorPreset("Camgöbeği", "#32ADE6"),
new ColorPreset("Mavi", "#007AFF"),
new ColorPreset("Mor", "#5856D6"),
new ColorPreset("Macenta", "#FF00FF"),
new ColorPreset("Pembe", "#FF2D55"),
new ColorPreset("Buz mavisi", "#ADD8E6"),
new ColorPreset("Sıcak beyaz", "#FDF4E3")
};
private static MediaColor ParseHexColor(string hex)
{
var normalized = hex.TrimStart('#');
if (normalized.Length != 6)
{
return MediaColor.FromRgb(255, 106, 78);
}
return MediaColor.FromRgb(
Convert.ToByte(normalized.Substring(0, 2), 16),
Convert.ToByte(normalized.Substring(2, 2), 16),
Convert.ToByte(normalized.Substring(4, 2), 16));
}
private void FooterLinkClicked(object sender, RequestNavigateEventArgs e)
{
Process.Start(new ProcessStartInfo
{
FileName = e.Uri.AbsoluteUri,
UseShellExecute = true
});
e.Handled = true;
}
private void TrySetWindowIcon()
{
try
{
Icon = BitmapFrame.Create(new Uri("pack://application:,,,/Assets/AppIcon.ico", UriKind.Absolute));
}
catch
{
// Window icon is cosmetic; startup should continue even if the icon resource is unavailable.
}
}
private async Task CheckForUpdatesAsync(bool isManualCheck)
{
if (_isCheckingForUpdates)
{
return;
}
try
{
_isCheckingForUpdates = true;
NotifyUpdateProperties();
var updateManager = new UpdateManager(new GiteaSource(GiteaRepoUrl, null, false));
if (!updateManager.IsInstalled)
{
_updatePendingRestart = false;
UpdateStatusText = "Kurulu Velopack s\u00fcr\u00fcm\u00fc alg\u0131lanmad\u0131.";
NotifyUpdateProperties();
if (isManualCheck)
{
System.Windows.MessageBox.Show(
this,
"G\u00fcncelleme denetimi sadece Setup.exe ile kurulan s\u00fcr\u00fcmlerde kullan\u0131labilir.",
"G\u00fcncelleme",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
return;
}
if (updateManager.UpdatePendingRestart is { } pendingAsset)
{
_updatePendingRestart = true;
UpdateStatusText = "\u0130ndirilen g\u00fcncelleme uygulanmay\u0131 bekliyor.";
NotifyUpdateProperties();
var applyPending = System.Windows.MessageBox.Show(
this,
"\u0130ndirilen bir g\u00fcncelleme haz\u0131r. Uygulay\u0131p uygulamay\u0131 yeniden ba\u015flatmak ister misin?",
"G\u00fcncelleme haz\u0131r",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (applyPending == MessageBoxResult.Yes)
{
UpdateStatusText = "G\u00fcncelleme uygulan\u0131yor...";
await updateManager.WaitExitThenApplyUpdatesAsync(pendingAsset, restart: true);
Close();
}
return;
}
_updatePendingRestart = false;
UpdateStatusText = isManualCheck
? "G\u00fcncellemeler kontrol ediliyor..."
: "Ba\u015flang\u0131\u00e7 g\u00fcncelleme kontrol\u00fc yap\u0131l\u0131yor...";
NotifyUpdateProperties();
var update = await updateManager.CheckForUpdatesAsync();
if (update is null)
{
UpdateStatusText = "G\u00fcncel s\u00fcr\u00fcm kullan\u0131l\u0131yor.";
if (isManualCheck)
{
System.Windows.MessageBox.Show(
this,
"Yeni bir s\u00fcr\u00fcm bulunamad\u0131.",
"G\u00fcncelleme",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
return;
}
var targetVersion = update.TargetFullRelease.Version.ToString();
UpdateStatusText = $"Yeni s\u00fcr\u00fcm haz\u0131r: v{targetVersion}";
var shouldApply = System.Windows.MessageBox.Show(
this,
$"v{targetVersion} bulundu. \u015eimdi indirip uygulamak ister misin?",
"G\u00fcncelleme bulundu",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (shouldApply != MessageBoxResult.Yes)
{
return;
}
UpdateStatusText = $"v{targetVersion} indiriliyor...";
await updateManager.DownloadUpdatesAsync(update, progress =>
{
Dispatcher.Invoke(() =>
{
UpdateStatusText = $"v{targetVersion} indiriliyor... %{progress}";
});
});
_updatePendingRestart = true;
UpdateStatusText = "G\u00fcncelleme uygulan\u0131yor...";
NotifyUpdateProperties();
await updateManager.WaitExitThenApplyUpdatesAsync(update.TargetFullRelease, restart: true);
Close();
}
catch (Exception ex)
{
_updatePendingRestart = false;
UpdateStatusText = $"G\u00fcncelleme hatas\u0131: {ex.Message}";
NotifyUpdateProperties();
if (isManualCheck)
{
System.Windows.MessageBox.Show(
this,
ex.Message,
"G\u00fcncelleme hatas\u0131",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
finally
{
_isCheckingForUpdates = false;
NotifyUpdateProperties();
}
}
private void NotifyUpdateProperties()
{
OnPropertyChanged(nameof(CanCheckForUpdates));
OnPropertyChanged(nameof(UpdateButtonLabel));
}
private static string GetApplicationVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informationalVersion))
{
return informationalVersion;
}
return assembly.GetName().Version?.ToString(3) ?? "0.0.0";
}
private bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<Version>0.3.0</Version>
<AssemblyVersion>0.3.0.0</AssemblyVersion>
<FileVersion>0.3.0.0</FileVersion>
<InformationalVersion>0.3.0</InformationalVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<PropertyGroup>
<StartupObject>RgbLedStripController.App</StartupObject>
</PropertyGroup>
<ItemGroup>
<ApplicationDefinition Remove="App.xaml" />
<Page Include="App.xaml" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\AppIcon.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Velopack" Version="0.0.1298" />
</ItemGroup>
</Project>
@@ -0,0 +1,427 @@
using System.Collections.Concurrent;
using System.Globalization;
using RgbLedStripController.Core;
using Windows.Devices.Bluetooth;
using Windows.Devices.Bluetooth.Advertisement;
using Windows.Devices.Bluetooth.GenericAttributeProfile;
using Windows.Devices.Enumeration;
using Windows.Foundation;
using Windows.Storage.Streams;
namespace RgbLedStripController.Services;
public sealed class BleDeviceService : IBleTransport
{
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly List<GattCharacteristic> _writeCharacteristics = [];
private BleScanResult? _targetDevice;
private BluetoothLEDevice? _device;
private GattDeviceService? _service;
private GattCharacteristic? _preferredCharacteristic;
private bool _isManualDisconnect;
public event EventHandler<DeviceConnectionStatus>? ConnectionStatusChanged;
public async Task<IReadOnlyList<BleScanResult>> ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
{
var captures = new ConcurrentDictionary<ulong, ScanCapture>();
var activeDuration = duration <= TimeSpan.FromSeconds(4)
? duration
: TimeSpan.FromMilliseconds(duration.TotalMilliseconds * 0.65);
var passiveDuration = duration - activeDuration;
await RunScanPassAsync(captures, BluetoothLEScanningMode.Active, activeDuration, cancellationToken);
if (passiveDuration > TimeSpan.Zero)
{
await RunScanPassAsync(captures, BluetoothLEScanningMode.Passive, passiveDuration, cancellationToken);
}
await MergeWindowsDeviceInventoryAsync(captures);
return captures.Values
.Select(capture => new BleScanResult
{
DeviceId = FormatAddress(capture.Address),
Name = string.IsNullOrWhiteSpace(capture.Name) ? "İsimsiz BLE cihazı" : capture.Name,
BluetoothAddress = FormatAddress(capture.Address),
AddressValue = capture.Address,
IsKnownCandidate = IsKnownCandidate(capture.Name),
IsLastConnected = false,
SignalStrength = capture.SignalStrength,
Alias = string.Empty
})
.OrderByDescending(item => item.IsKnownCandidate)
.ThenByDescending(item => item.SignalStrength)
.ToArray();
}
public async Task ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default)
{
_targetDevice = device;
_isManualDisconnect = false;
RaiseConnectionStatus(DeviceConnectionStatus.Connecting);
await EnsureCharacteristicReadyAsync(cancellationToken);
}
public async Task<bool> DiscoverAsync(CancellationToken cancellationToken = default)
{
try
{
await EnsureCharacteristicReadyAsync(cancellationToken);
return true;
}
catch
{
return false;
}
}
public async Task WriteAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken = default)
{
await EnsureCharacteristicReadyAsync(cancellationToken);
await WriteCoreAsync(payload, cancellationToken, allowRetry: true);
}
public Task DisconnectAsync()
{
_isManualDisconnect = true;
ClearDevice(clearTarget: true);
RaiseConnectionStatus(DeviceConnectionStatus.Disconnected);
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
_connectionLock.Dispose();
}
private async Task EnsureCharacteristicReadyAsync(CancellationToken cancellationToken)
{
if (_targetDevice is null)
{
throw new InvalidOperationException("Target BLE device is not selected.");
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
cancellationToken.ThrowIfCancellationRequested();
if (_device is null)
{
await CreateDeviceAsync(cancellationToken);
}
if (_device is null)
{
throw new InvalidOperationException("BluetoothLEDevice could not be opened.");
}
if (_preferredCharacteristic is not null || _writeCharacteristics.Count > 0)
{
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
return;
}
if (_device.ConnectionStatus != BluetoothConnectionStatus.Connected)
{
await CreateDeviceAsync(cancellationToken);
}
var serviceResult = await _device.GetGattServicesForUuidAsync(
ElkBledomProtocol.ServiceUuid,
BluetoothCacheMode.Uncached);
if (serviceResult.Status != GattCommunicationStatus.Success || serviceResult.Services.Count == 0)
{
throw new InvalidOperationException("fff0 service was not found.");
}
ClearGatt();
_service = serviceResult.Services[0];
var characteristicResult = await _service.GetCharacteristicsAsync(BluetoothCacheMode.Uncached);
if (characteristicResult.Status != GattCommunicationStatus.Success || characteristicResult.Characteristics.Count == 0)
{
throw new InvalidOperationException("No writable BLE characteristic was found.");
}
_writeCharacteristics.Clear();
_writeCharacteristics.AddRange(
characteristicResult.Characteristics
.Where(IsWritableCharacteristic)
.OrderByDescending(characteristic => characteristic.Uuid == ElkBledomProtocol.CharacteristicUuid));
if (_writeCharacteristics.Count == 0)
{
throw new InvalidOperationException("No writable BLE characteristic was found.");
}
_preferredCharacteristic = _writeCharacteristics[0];
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
}
finally
{
_connectionLock.Release();
}
}
private async Task WriteCoreAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken, bool allowRetry)
{
cancellationToken.ThrowIfCancellationRequested();
if (_preferredCharacteristic is null && _writeCharacteristics.Count == 0)
{
throw new InvalidOperationException("BLE characteristic is not ready.");
}
try
{
var payloadBytes = payload.ToArray();
Exception? lastError = null;
foreach (var characteristic in EnumerateWriteTargets())
{
try
{
var writer = new DataWriter();
writer.WriteBytes(payloadBytes);
var preferredOption = characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.WriteWithoutResponse)
? GattWriteOption.WriteWithoutResponse
: GattWriteOption.WriteWithResponse;
var status = await characteristic.WriteValueAsync(writer.DetachBuffer(), preferredOption);
if (status != GattCommunicationStatus.Success)
{
lastError = new InvalidOperationException($"BLE write failed: {status}");
continue;
}
_preferredCharacteristic = characteristic;
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
return;
}
catch (Exception ex)
{
lastError = ex;
}
}
throw lastError ?? new InvalidOperationException("BLE write failed.");
}
catch when (allowRetry)
{
ClearGatt();
await EnsureCharacteristicReadyAsync(cancellationToken);
await WriteCoreAsync(payload, cancellationToken, allowRetry: false);
}
}
private async Task CreateDeviceAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_targetDevice is null)
{
throw new InvalidOperationException("Target BLE device is not selected.");
}
ClearDevice(clearTarget: false);
_device = await BluetoothLEDevice.FromBluetoothAddressAsync(_targetDevice.AddressValue);
if (_device is null)
{
throw new InvalidOperationException("Bluetooth device could not be opened.");
}
_device.ConnectionStatusChanged += HandleConnectionStatusChanged;
}
private void HandleConnectionStatusChanged(BluetoothLEDevice sender, object args)
{
if (sender.ConnectionStatus == BluetoothConnectionStatus.Disconnected)
{
ClearGatt();
if (!_isManualDisconnect)
{
RaiseConnectionStatus(DeviceConnectionStatus.Disconnected);
}
return;
}
_isManualDisconnect = false;
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
}
private void ClearGatt()
{
_preferredCharacteristic = null;
_writeCharacteristics.Clear();
_service?.Dispose();
_service = null;
}
private void ClearDevice(bool clearTarget)
{
ClearGatt();
if (_device is not null)
{
_device.ConnectionStatusChanged -= HandleConnectionStatusChanged;
_device.Dispose();
_device = null;
}
if (clearTarget)
{
_targetDevice = null;
}
}
private void RaiseConnectionStatus(DeviceConnectionStatus status)
=> ConnectionStatusChanged?.Invoke(this, status);
private static bool IsKnownCandidate(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var normalized = name.ToUpperInvariant();
return normalized.Contains("ELK-BLEDOM")
|| normalized.Contains("ELK-BLEDOB")
|| normalized.Contains("LOTUS")
|| normalized.Contains("DUOCO");
}
private static async Task RunScanPassAsync(
ConcurrentDictionary<ulong, ScanCapture> captures,
BluetoothLEScanningMode mode,
TimeSpan duration,
CancellationToken cancellationToken)
{
if (duration <= TimeSpan.Zero)
{
return;
}
var watcher = new BluetoothLEAdvertisementWatcher
{
ScanningMode = mode,
AllowExtendedAdvertisements = true
};
TypedEventHandler<BluetoothLEAdvertisementWatcher, BluetoothLEAdvertisementReceivedEventArgs> receivedHandler = (_, args) =>
{
captures.AddOrUpdate(
args.BluetoothAddress,
_ => new ScanCapture(args.BluetoothAddress, args.Advertisement.LocalName, args.RawSignalStrengthInDBm),
(_, existing) => existing.Merge(args.Advertisement.LocalName, args.RawSignalStrengthInDBm));
};
watcher.Received += receivedHandler;
watcher.Start();
try
{
await Task.Delay(duration, cancellationToken);
}
finally
{
watcher.Stop();
watcher.Received -= receivedHandler;
}
}
private static async Task MergeWindowsDeviceInventoryAsync(ConcurrentDictionary<ulong, ScanCapture> captures)
{
try
{
var additionalProperties = new[]
{
"System.Devices.Aep.DeviceAddress"
};
var devices = await DeviceInformation.FindAllAsync(
BluetoothLEDevice.GetDeviceSelector(),
additionalProperties);
foreach (var device in devices)
{
if (!IsKnownCandidate(device.Name))
{
continue;
}
if (!device.Properties.TryGetValue("System.Devices.Aep.DeviceAddress", out var addressValue)
|| addressValue is not string addressText
|| !TryParseBluetoothAddress(addressText, out var address))
{
continue;
}
captures.AddOrUpdate(
address,
_ => new ScanCapture(address, device.Name, short.MinValue),
(_, existing) => existing.Merge(device.Name, short.MinValue));
}
}
catch
{
// Best effort only.
}
}
private static bool IsWritableCharacteristic(GattCharacteristic characteristic)
=> characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.Write)
|| characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.WriteWithoutResponse);
private IEnumerable<GattCharacteristic> EnumerateWriteTargets()
{
if (_preferredCharacteristic is not null)
{
yield return _preferredCharacteristic;
}
foreach (var characteristic in _writeCharacteristics)
{
if (!ReferenceEquals(characteristic, _preferredCharacteristic))
{
yield return characteristic;
}
}
}
private static string FormatAddress(ulong address)
{
var bytes = BitConverter.GetBytes(address).Take(6).Reverse().ToArray();
return string.Join(":", bytes.Select(item => item.ToString("X2")));
}
private static bool TryParseBluetoothAddress(string? value, out ulong address)
{
address = 0;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Replace(":", string.Empty).Replace("-", string.Empty).Trim();
return ulong.TryParse(normalized, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out address);
}
private sealed record ScanCapture(ulong Address, string Name, short SignalStrength)
{
public ScanCapture Merge(string? name, short signalStrength)
=> this with
{
Name = string.IsNullOrWhiteSpace(name) ? Name : name!,
SignalStrength = signalStrength == short.MinValue ? SignalStrength : signalStrength
};
}
}
@@ -0,0 +1,113 @@
using RgbLedStripController.Core;
using MediaColor = System.Windows.Media.Color;
namespace RgbLedStripController.Services;
public static class ElkBledomProtocol
{
public static readonly Guid ServiceUuid = Guid.Parse("0000fff0-0000-1000-8000-00805f9b34fb");
public static readonly Guid CharacteristicUuid = Guid.Parse("0000fff3-0000-1000-8000-00805f9b34fb");
public static byte[] BuildPower(bool isOn)
{
var flag = isOn ? (byte)0x01 : (byte)0x00;
return new byte[] { 0x7E, 0x04, 0x04, flag, 0x00, flag, 0xFF, 0x00, 0xEF };
}
public static byte[] BuildLegacyPower(bool isOn)
{
var flag = isOn ? (byte)0x01 : (byte)0x00;
return new byte[] { 0x7E, 0x00, 0x04, flag, 0x00, 0x00, 0x00, 0x00, 0xEF };
}
public static byte[] BuildColor(MediaColor color)
=> new byte[] { 0x7E, 0x07, 0x05, 0x03, color.R, color.G, color.B, 0x10, 0xEF };
public static byte[] BuildLegacyColor(MediaColor color)
=> new byte[] { 0x7E, 0x00, 0x05, 0x03, color.R, color.G, color.B, 0x00, 0xEF };
public static byte[] BuildBrightness(byte brightness)
{
var value = (byte)Math.Clamp((int)brightness, 0, 100);
return new byte[] { 0x7E, 0x04, 0x01, value, 0xFF, 0xFF, 0xFF, 0x00, 0xEF };
}
public static byte[] BuildLegacyBrightness(byte brightness)
{
var value = (byte)Math.Clamp((int)brightness, 0, 100);
return new byte[] { 0x7E, 0x00, 0x01, value, 0x00, 0x00, 0x00, 0x00, 0xEF };
}
public static byte[] BuildEffect(byte patternId)
{
var clamped = (byte)Math.Clamp((int)patternId, 0, 28);
return new byte[] { 0x7E, 0x05, 0x03, (byte)(clamped + 128), 0x03, 0xFF, 0xFF, 0x00, 0xEF };
}
public static byte[] BuildEffectSpeed(byte speed)
{
var value = (byte)Math.Clamp((int)speed, 0, 100);
return new byte[] { 0x7E, 0x00, 0x02, value, 0x00, 0x00, 0x00, 0x00, 0xEF };
}
public static byte[] BuildGrayscaleMode()
=> new byte[] { 0x7E, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xEF };
public static byte[] BuildGrayscaleLevel(byte level)
{
var value = (byte)Math.Clamp((int)level, 0, 100);
return new byte[] { 0x7E, 0x00, 0x05, 0x01, value, 0x00, 0x00, 0x00, 0xEF };
}
public static byte[] BuildTemperatureMode(byte level)
{
var mapped = (byte)(0x80 + Math.Round(Math.Clamp((double)level, 0, 100) * 10d / 100d));
return new byte[] { 0x7E, 0x00, 0x03, mapped, 0x02, 0x00, 0x00, 0x00, 0xEF };
}
public static byte[] BuildTemperatureLevel(byte level)
{
var value = (byte)Math.Clamp((int)level, 0, 100);
return new byte[] { 0x7E, 0x00, 0x05, 0x02, value, 0x00, 0x00, 0x00, 0xEF };
}
public static byte[] BuildRgbModeReset()
=> new byte[] { 0x7E, 0x00, 0x03, 0x00, 0x05, 0x00, 0x00, 0x00, 0xEF };
public static bool EffectSupportsSpeed(byte patternId)
=> patternId >= 0x07 && patternId <= 0x1C;
public static IReadOnlyList<EffectOption> CreateEffectOptions()
=> new[]
{
new EffectOption(0x00, "Tek Renk Kırmızı", false),
new EffectOption(0x01, "Tek Renk Yeşil", false),
new EffectOption(0x02, "Tek Renk Mavi", false),
new EffectOption(0x03, "Tek Renk Sarı", false),
new EffectOption(0x04, "Tek Renk Camgöbeği", false),
new EffectOption(0x05, "Tek Renk Macenta", false),
new EffectOption(0x06, "Tek Renk Beyaz", false),
new EffectOption(0x07, "Atlama RGB", true),
new EffectOption(0x08, "Atlama Gökkuşağı", true),
new EffectOption(0x09, "Geçiş RGB", true),
new EffectOption(0x0A, "Geçiş Gökkuşağı", true),
new EffectOption(0x0B, "Geçiş Kırmızı", true),
new EffectOption(0x0C, "Geçiş Yeşil", true),
new EffectOption(0x0D, "Geçiş Mavi", true),
new EffectOption(0x0E, "Geçiş Sarı", true),
new EffectOption(0x0F, "Geçiş Camgöbeği", true),
new EffectOption(0x10, "Geçiş Macenta", true),
new EffectOption(0x11, "Geçiş Beyaz", true),
new EffectOption(0x12, "Geçiş Kırmızı-Yeşil", true),
new EffectOption(0x13, "Geçiş Kırmızı-Mavi", true),
new EffectOption(0x14, "Geçiş Yeşil-Mavi", true),
new EffectOption(0x15, "Yanıp Sön Gökkuşağı", true),
new EffectOption(0x16, "Yanıp Sön Kırmızı", true),
new EffectOption(0x17, "Yanıp Sön Yeşil", true),
new EffectOption(0x18, "Yanıp Sön Mavi", true),
new EffectOption(0x19, "Yanıp Sön Sarı", true),
new EffectOption(0x1A, "Yanıp Sön Camgöbeği", true),
new EffectOption(0x1B, "Yanıp Sön Macenta", true),
new EffectOption(0x1C, "Yanıp Sön Beyaz", true)
};
}
@@ -0,0 +1,18 @@
using RgbLedStripController.Core;
namespace RgbLedStripController.Services;
public interface IBleTransport : IAsyncDisposable
{
event EventHandler<DeviceConnectionStatus>? ConnectionStatusChanged;
Task<IReadOnlyList<BleScanResult>> ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default);
Task ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default);
Task<bool> DiscoverAsync(CancellationToken cancellationToken = default);
Task WriteAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken = default);
Task DisconnectAsync();
}
@@ -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; }
}
}
@@ -0,0 +1,140 @@
using System.IO;
using System.Text.Json;
using RgbLedStripController.Core;
using MediaColor = System.Windows.Media.Color;
namespace RgbLedStripController.Services;
public sealed class SettingsStore
{
private readonly string _settingsPath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public SettingsStore()
{
var settingsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"RgbLedStripController");
_settingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public async Task<SettingsSnapshot> LoadAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(_settingsPath))
{
return CreateDefaultSnapshot();
}
try
{
await using var stream = File.OpenRead(_settingsPath);
var stored = await JsonSerializer.DeserializeAsync<StoredSettings>(stream, _jsonOptions, cancellationToken);
return new SettingsSnapshot(
stored?.KnownDevices ?? [],
new Dictionary<string, string>(stored?.DeviceAliases ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase),
ParseColor(stored?.SelectedColorHex),
stored?.Brightness ?? 100,
stored?.EffectSpeed ?? 50,
stored?.CurrentMode ?? LightingMode.Rgb,
stored?.GrayscaleLevel ?? 100,
stored?.TemperatureLevel ?? 50,
stored?.SelectedEffectId ?? 0,
stored?.IsPoweredOn ?? false);
}
catch
{
return CreateDefaultSnapshot();
}
}
public async Task SaveAsync(SettingsSnapshot snapshot, CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken);
try
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
await using var stream = File.Create(_settingsPath);
var stored = new StoredSettings
{
KnownDevices = snapshot.KnownDevices.ToList(),
DeviceAliases = new Dictionary<string, string>(snapshot.DeviceAliases, StringComparer.OrdinalIgnoreCase),
SelectedColorHex = $"#{snapshot.SelectedColor.R:X2}{snapshot.SelectedColor.G:X2}{snapshot.SelectedColor.B:X2}",
Brightness = snapshot.Brightness,
EffectSpeed = snapshot.EffectSpeed,
CurrentMode = snapshot.CurrentMode,
GrayscaleLevel = snapshot.GrayscaleLevel,
TemperatureLevel = snapshot.TemperatureLevel,
SelectedEffectId = snapshot.SelectedEffectId,
IsPoweredOn = snapshot.IsPoweredOn
};
await JsonSerializer.SerializeAsync(stream, stored, _jsonOptions, cancellationToken);
}
finally
{
_writeLock.Release();
}
}
private static SettingsSnapshot CreateDefaultSnapshot()
=> new(
[],
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
MediaColor.FromRgb(255, 106, 78),
100,
50,
LightingMode.Rgb,
100,
50,
0,
false);
private static MediaColor ParseColor(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length != 7 || value[0] != '#')
{
return MediaColor.FromRgb(255, 106, 78);
}
return MediaColor.FromRgb(
Convert.ToByte(value.Substring(1, 2), 16),
Convert.ToByte(value.Substring(3, 2), 16),
Convert.ToByte(value.Substring(5, 2), 16));
}
private sealed class StoredSettings
{
public List<DeviceProfile> KnownDevices { get; set; } = [];
public Dictionary<string, string> DeviceAliases { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public string? SelectedColorHex { get; set; }
public byte Brightness { get; set; }
public byte EffectSpeed { get; set; }
public LightingMode CurrentMode { get; set; }
public byte GrayscaleLevel { get; set; }
public byte TemperatureLevel { get; set; }
public byte SelectedEffectId { get; set; }
public bool IsPoweredOn { get; set; }
}
}