v0.3.0: CI dotnet 8.0.424 pinned with isolated install
- Pin .NET SDK to 8.0.424 in all CI jobs to avoid floating channel drift - Install SDK to runner temp via DOTNET_INSTALL_DIR to bypass C:\\Program Files\\dotnet overwrite/extract conflicts on Gitea Windows runners - Simplify release job to dotnet publish + upload-artifact - Exclude memory-bank/ via .gitignore (internal dev memory stays local)
This commit is contained in:
18 files changed
+4122
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"vpk": {
|
||||||
|
"version": "0.0.1298",
|
||||||
|
"commands": [
|
||||||
|
"vpk"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup .NET 8 SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.424
|
||||||
|
env:
|
||||||
|
DOTNET_INSTALL_DIR: ${{ runner.temp }}\dotnet
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '.config/dotnet-tools.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Restore tools
|
||||||
|
run: dotnet tool restore
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore src/RgbLedStripController/RgbLedStripController.csproj --configfile NuGet.Config
|
||||||
|
|
||||||
|
- name: Build (Release)
|
||||||
|
run: dotnet build src/RgbLedStripController/RgbLedStripController.csproj -c Release --no-restore
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup .NET 8 SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.424
|
||||||
|
env:
|
||||||
|
DOTNET_INSTALL_DIR: ${{ runner.temp }}\dotnet
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '.config/dotnet-tools.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Run tests (if any test projects exist)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$tests = @(Get-ChildItem -Recurse -Filter "*Tests.csproj" | Where-Object { $_.FullName -notlike "*\obj\*" -and $_.FullName -notlike "*\bin\*" })
|
||||||
|
if ($tests.Count -eq 0) {
|
||||||
|
Write-Host "No test projects found yet; skipping dotnet test."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
foreach ($t in $tests) {
|
||||||
|
dotnet test $t.FullName -c Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "dotnet test failed for $($t.FullName)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup .NET 8 SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.424
|
||||||
|
env:
|
||||||
|
DOTNET_INSTALL_DIR: ${{ runner.temp }}\dotnet
|
||||||
|
|
||||||
|
- name: Cache NuGet packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '.config/dotnet-tools.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
nuget-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Restore tools
|
||||||
|
run: dotnet tool restore
|
||||||
|
|
||||||
|
- name: Publish (Release, self-contained, single file)
|
||||||
|
run: dotnet publish src/RgbLedStripController/RgbLedStripController.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true --configfile NuGet.Config
|
||||||
|
|
||||||
|
- name: Upload release artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rgb-led-strip-controller-${{ gitea.ref_name }}-win-x64
|
||||||
|
path: |
|
||||||
|
src/RgbLedStripController/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/RgbLedStripController.exe
|
||||||
|
src/RgbLedStripController/bin/Release/net8.0-windows10.0.19041.0/win-x64/publish/RgbLedStripController.pdb
|
||||||
|
if-no-files-found: error
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# .NET build outputs
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
artifacts/
|
||||||
|
publish/
|
||||||
|
Releases/
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
# NuGet packages and local caches
|
||||||
|
*.nupkg
|
||||||
|
*.snupkg
|
||||||
|
.nuget/
|
||||||
|
|
||||||
|
# Local sandbox dirs created by scripts/release/*.ps1
|
||||||
|
.dotnet/
|
||||||
|
.appdata/
|
||||||
|
|
||||||
|
# Code signing material - NEVER commit private keys
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.cer
|
||||||
|
|
||||||
|
# Secrets / local env
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
appsettings.*.local.json
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
|
||||||
|
# Local agent/tooling state (regenerable)
|
||||||
|
.opencode/
|
||||||
|
.codegraph/
|
||||||
|
|
||||||
|
# Internal dev memory (stays local, not published)
|
||||||
|
memory-bank/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RgbLedStripController", "src\RgbLedStripController\RgbLedStripController.csproj", "{A703B02A-BF19-5053-22FA-A79628B40F49}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A703B02A-BF19-5053-22FA-A79628B40F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A703B02A-BF19-5053-22FA-A79628B40F49}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A703B02A-BF19-5053-22FA-A79628B40F49}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A703B02A-BF19-5053-22FA-A79628B40F49}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{A703B02A-BF19-5053-22FA-A79628B40F49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {09247BEF-9950-43C7-91FF-2FCFA0740DA0}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user