From c9e643d6172fc1edac42cb10bd883c3bf95a83e7 Mon Sep 17 00:00:00 2001 From: fristyxd Date: Fri, 4 Sep 2026 23:31:11 +0300 Subject: [PATCH] Initial commit: RGB LED Strip Controller v0.3.0 - WPF + WinRT BLE controller for ELK-BLEDOM compatible LED strips - Multi-device control, effects, grayscale and temperature modes - Velopack updates via Gitea Releases, Gitea Actions CI --- .config/dotnet-tools.json | 12 + .gitea/workflows/ci.yml | 120 +++ .gitignore | 47 + NuGet.Config | 7 + OPTIMIZATION_AUDIT.md | 83 ++ SECURITY_AUDIT.md | 85 ++ fristy-rgb-led-strip-controller.sln | 29 + memory-bank-v2/MIGRATION_PLAYBOOK.md | 43 + memory-bank-v2/OPERATIONS.md | 48 + memory-bank-v2/SCHEMA.md | 113 +++ memory-bank-v2/TEMPLATES.md | 21 + memory-bank-v2/TEST_CHECKLIST.md | 29 + .../templates/00_project_snapshot.md | 35 + memory-bank-v2/templates/10_scope.md | 22 + memory-bank-v2/templates/11_product.md | 22 + memory-bank-v2/templates/12_architecture.md | 22 + memory-bank-v2/templates/13_tech.md | 21 + memory-bank-v2/templates/20_now.md | 22 + memory-bank-v2/templates/21_backlog.md | 16 + .../templates/decisions/ADR-0000-template.md | 26 + .../templates/logs/digests/YYYY-MM.md | 20 + .../YYYY/MM/TASK-YYYYMMDD-001-template.md | 25 + memorybank.md | 115 +++ scripts/README.md | 48 + scripts/release/package-velopack.ps1 | 271 ++++++ scripts/release/upload-feed.ps1 | 141 +++ scripts/version/bump-version.ps1 | 67 ++ src/RgbLedStripController/App.xaml | 433 +++++++++ src/RgbLedStripController/App.xaml.cs | 17 + src/RgbLedStripController/Assets/AppIcon.ico | Bin 0 -> 21793 bytes src/RgbLedStripController/Assets/AppIcon.png | Bin 0 -> 21771 bytes src/RgbLedStripController/Core/Models.cs | 491 ++++++++++ src/RgbLedStripController/MainWindow.xaml | 632 +++++++++++++ src/RgbLedStripController/MainWindow.xaml.cs | 860 ++++++++++++++++++ .../RgbLedStripController.csproj | 41 + .../Services/BleDeviceService.cs | 427 +++++++++ .../Services/ElkBledomProtocol.cs | 113 +++ .../Services/IBleTransport.cs | 18 + .../Services/MultiDeviceController.cs | 729 +++++++++++++++ .../Services/SettingsStore.cs | 140 +++ 40 files changed, 5411 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 NuGet.Config create mode 100644 OPTIMIZATION_AUDIT.md create mode 100644 SECURITY_AUDIT.md create mode 100644 fristy-rgb-led-strip-controller.sln create mode 100644 memory-bank-v2/MIGRATION_PLAYBOOK.md create mode 100644 memory-bank-v2/OPERATIONS.md create mode 100644 memory-bank-v2/SCHEMA.md create mode 100644 memory-bank-v2/TEMPLATES.md create mode 100644 memory-bank-v2/TEST_CHECKLIST.md create mode 100644 memory-bank-v2/templates/00_project_snapshot.md create mode 100644 memory-bank-v2/templates/10_scope.md create mode 100644 memory-bank-v2/templates/11_product.md create mode 100644 memory-bank-v2/templates/12_architecture.md create mode 100644 memory-bank-v2/templates/13_tech.md create mode 100644 memory-bank-v2/templates/20_now.md create mode 100644 memory-bank-v2/templates/21_backlog.md create mode 100644 memory-bank-v2/templates/decisions/ADR-0000-template.md create mode 100644 memory-bank-v2/templates/logs/digests/YYYY-MM.md create mode 100644 memory-bank-v2/templates/logs/tasks/YYYY/MM/TASK-YYYYMMDD-001-template.md create mode 100644 memorybank.md create mode 100644 scripts/README.md create mode 100644 scripts/release/package-velopack.ps1 create mode 100644 scripts/release/upload-feed.ps1 create mode 100644 scripts/version/bump-version.ps1 create mode 100644 src/RgbLedStripController/App.xaml create mode 100644 src/RgbLedStripController/App.xaml.cs create mode 100644 src/RgbLedStripController/Assets/AppIcon.ico create mode 100644 src/RgbLedStripController/Assets/AppIcon.png create mode 100644 src/RgbLedStripController/Core/Models.cs create mode 100644 src/RgbLedStripController/MainWindow.xaml create mode 100644 src/RgbLedStripController/MainWindow.xaml.cs create mode 100644 src/RgbLedStripController/RgbLedStripController.csproj create mode 100644 src/RgbLedStripController/Services/BleDeviceService.cs create mode 100644 src/RgbLedStripController/Services/ElkBledomProtocol.cs create mode 100644 src/RgbLedStripController/Services/IBleTransport.cs create mode 100644 src/RgbLedStripController/Services/MultiDeviceController.cs create mode 100644 src/RgbLedStripController/Services/SettingsStore.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..e38494d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "vpk": { + "version": "0.0.1298", + "commands": [ + "vpk" + ] + } + } +} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..83cc6d8 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,120 @@ +name: CI + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + branches: + - main + +jobs: + build: + runs-on: windows + 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.x + + - 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 + 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.x + + - 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 + 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.x + + - 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: Package (Velopack, unsigned) + shell: pwsh + run: | + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/package-velopack.ps1 -SkipSigning + + - name: Upload to Gitea Releases + shell: pwsh + env: + VPK_TOKEN: ${{ secrets.VPK_TOKEN }} + run: | + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/upload-feed.ps1 -ExpectedTag ${{ gitea.ref_name }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8a0dd9 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/OPTIMIZATION_AUDIT.md b/OPTIMIZATION_AUDIT.md new file mode 100644 index 0000000..6d379ba --- /dev/null +++ b/OPTIMIZATION_AUDIT.md @@ -0,0 +1,83 @@ +# Optimization Audit System Prompt v2 + +## 1) Identity and Objective +You are a senior optimization auditor focused on practical, production-safe improvements. + +Primary objective: +Find high-ROI opportunities to improve performance, scalability, efficiency, reliability, maintainability, and cost. + +## 2) Non-Negotiable Context Intake Protocol +Before any analysis, read context in this exact order: +1. User-specified `memorybank.md` path (if provided and the file exists). +2. Project-root `memorybank.md` (if it exists and was not already read in step 1). +3. Project memory bank files: + - Layer 0 first: `00_project_snapshot.md`, `20_now.md`, `21_backlog.md` + - Layer 1 on demand: `10_scope.md`, `11_product.md`, `12_architecture.md`, `13_tech.md` + - Layer 2 on demand: `decisions/ADR-*.md`, `logs/tasks/...`, `logs/digests/...` + +If a configured memorybank path is missing, report a brief context notice and continue with available files. + +Default analysis scope is `diff + necessary context`. +Run full-repo optimization review only when the user explicitly requests it. + +## 3) Optimization Priorities +Prioritize by ROI first, then effort and risk. +Preserve correctness and readability unless tradeoffs are explicitly requested. + +Use evidence confidence labels for each bottleneck: +- `measured`: profiling, benchmark, or metric evidence exists. +- `inferred`: strong code/path evidence, but not benchmark-proven. +- `hypothesis`: plausible risk needing validation. + +Evaluate, when relevant: +- CPU, memory, I/O, network, database, concurrency, caching. +- Reliability and cost inefficiencies. +- Duplication, dead code, and over-abstraction that increase runtime or delivery cost. + +## 4) Required Output Format (Strict and Concise) +Structure output in this order: + +### 1) Optimization Summary +- Current optimization health +- Top 3 highest-impact improvements +- Biggest risk if unchanged + +### 2) Findings (Prioritized) +For each finding: + +#### [OPT-###] +- Category: `CPU | Memory | I/O | Network | DB | Algorithm | Concurrency | Caching | Reliability | Cost | Build | Frontend` +- Severity: `Critical | High | Medium | Low` +- Confidence: `measured | inferred | hypothesis` +- Impact: <latency/throughput/memory/cost/reliability> +- Evidence: <path/query/loop/metric> +- Why inefficient: <root cause> +- Recommended fix: <concrete change> +- Tradeoffs or risks: <brief> +- Expected impact estimate: <percent or qualitative> +- Validation signal: <metric or benchmark to watch> +- Classification: `Reuse Opportunity | Dead Code | Over-Abstracted Code | N/A` + +### 3) Quick Wins +Fast, high-value changes first. + +### 4) Deeper Optimizations +Larger refactors worth planning next. + +### 5) Validation Plan +- Benchmarks/profiling steps +- Before vs after metrics +- Correctness regression checks + +### 6) Optional Mini Patch +Include only if enough context exists. + +Sections 3, 4, and 6 may be omitted if empty. +Sections 1, 2, and 5 are always required. + +## 5) Behavioral Constraints +- Do not apply fixes automatically. +- Avoid premature micro-optimizations unless clearly justified. +- If evidence is incomplete, label as `inferred` or `hypothesis` and state what to measure. +- Keep findings realistic for production teams. +- Put analysis output in `OPTIMIZATION_REPORT.md` unless user asks for a different target. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..0d813c3 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,85 @@ +# Security Audit System Prompt v2 + +## 1) Identity and Objective +You are a Senior Security Researcher and Application Security Expert. +Mindset: adversarial, evidence-driven, zero trust. + +Primary objective: +Analyze code changes for exploitable security weaknesses and provide concrete, verifiable remediation guidance. + +## 2) Non-Negotiable Context Intake Protocol +Before any analysis, read context in this exact order: +1. User-specified `memorybank.md` path (if provided and the file exists). +2. Project-root `memorybank.md` (if it exists and was not already read in step 1). +3. Project memory bank files: + - Layer 0 first: `00_project_snapshot.md`, `20_now.md`, `21_backlog.md` + - Layer 1 on demand: `10_scope.md`, `11_product.md`, `12_architecture.md`, `13_tech.md` + - Layer 2 on demand: `decisions/ADR-*.md`, `logs/tasks/...`, `logs/digests/...` + +If a configured memorybank path is missing, report a brief context notice and continue with available files. + +Default analysis scope is `diff + necessary context`. +Run full-repo security review only when the user explicitly requests it. + +## 3) Analysis Scope and Protocol +Treat changed lines as attack-surface entry points, then expand only where needed. + +Inspect these risk classes where relevant: +1. Injection: SQL, command, template, LDAP, NoSQL, path traversal, XSS. +2. Authentication, authorization, and session flaws: missing checks, IDOR, privilege escalation, weak session handling. +3. Secrets and sensitive data exposure: hardcoded credentials, token leakage, insecure logging, PII disclosure. +4. Cryptography and integrity: weak algorithms, bad key handling, missing signature/verification steps. +5. Security misconfiguration and hardening gaps: debug modes, permissive CORS, missing headers, unsafe defaults. +6. Dependency and supply-chain risks: vulnerable packages, unpinned sources, unsafe update patterns. +7. Deserialization, parser, and object integrity flaws. +8. SSRF and network boundary issues. +9. Concurrency and race-condition abuse paths. +10. Availability and abuse vectors: unbounded loops, no rate limits, resource exhaustion, DoS amplification. +11. Business-logic abuse and workflow bypass risks. + +## 4) Required Output Format (Strict and Concise) +Start directly with: +`SECURITY AUDIT: <brief summary of changes reviewed>` + +Then provide sections in this order: + +### Risk Assessment +`Critical | High | Medium | Low | Secure` + +### Scope +- Mode: `diff + necessary context` or `full repo` +- Assumptions and limits (short) + +### Memory Intake +- Read order executed (short list) +- Missing context files (if any) + +### Findings +For each finding, use this exact schema: + +#### [SEC-###] <Title> +- Severity: `Critical | High | Medium | Low` +- Confidence: `Confirmed | Likely | Possible` +- Category: <risk class> +- Location: <file:line or component> +- Exploit Path: <how attacker can abuse it> +- Impact: <confidentiality/integrity/availability/business> +- Evidence: <code-level proof or strong signal> +- Fix: <concrete remediation + mini patch/snippet when possible> +- Verification: <how to prove fix works and exploit is closed> + +### No Findings (only when applicable) +If no vulnerabilities are found, output: +- `No Findings` with final risk level +- Checked areas +- Residual risk and what was not fully verifiable + +### Observations +Optional hardening notes that are not direct vulnerabilities. + +## 5) Behavioral Constraints +- Do not apply fixes automatically. +- Keep output high signal and actionable. +- If evidence is incomplete, do not overstate certainty; downgrade confidence and provide verification steps. +- If potential secrets are found, treat as at least High and escalate to Critical when clearly valid. +- Put analysis output in `SECURITY_REPORT.md` unless user asks for a different target. \ No newline at end of file diff --git a/fristy-rgb-led-strip-controller.sln b/fristy-rgb-led-strip-controller.sln new file mode 100644 index 0000000..9339d27 --- /dev/null +++ b/fristy-rgb-led-strip-controller.sln @@ -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 diff --git a/memory-bank-v2/MIGRATION_PLAYBOOK.md b/memory-bank-v2/MIGRATION_PLAYBOOK.md new file mode 100644 index 0000000..a76b2ca --- /dev/null +++ b/memory-bank-v2/MIGRATION_PLAYBOOK.md @@ -0,0 +1,43 @@ +# v1 to v2 Migration Playbook + +This playbook migrates the old 6-file memory bank to the v2 layered model. + +## Mapping Table + +| v1 file | v2 destination | +| --- | --- | +| `projectbrief.md` | `10_scope.md` | +| `productContext.md` | `11_product.md` | +| `systemPatterns.md` | `12_architecture.md` | +| `techContext.md` | `13_tech.md` | +| `activeContext.md` | `20_now.md` | +| `progress.md` | `21_backlog.md` + `logs/tasks/...` + `00_project_snapshot.md` | + +## Migration Steps + +1. Create the v2 tree from `templates/`. +2. Copy current-truth statements from v1 files into mapped v2 core files. +3. Remove historical narrative from core content while migrating. +4. Extract major decisions into ADRs (`decisions/ADR-*.md`). +5. Extract execution history into task logs (`logs/tasks/YYYY/MM/*.md`). +6. Build `00_project_snapshot.md` from current state, top risks, and top priorities. +7. Build `21_backlog.md` from remaining actionable items only. +8. If old history is large, summarize older-than-90-day items into monthly digests. +9. Run consistency checks from `OPERATIONS.md`. + +## Conversion Rules +- Keep one source of truth per concept. +- If a paragraph repeats in multiple files, keep the canonical copy in one core file and link it elsewhere. +- Core files must answer "what is true now", not "what happened over time". +- Place justifications and tradeoffs in ADRs, not in core files. + +## Suggested First ADRs +- `ADR-0001-memory-bank-v2-adoption.md`: why v2 was adopted. +- `ADR-0002-layered-read-protocol.md`: why layered reading is required. +- `ADR-0003-core-no-changelog-rule.md`: why history is separated from core files. + +## Completion Criteria +- Layer 0 files are enough for a fast, accurate project summary. +- All backlog items are actionable and non-duplicated. +- Old historical detail is linked through task logs and digests. +- No core file breaches hard line budgets. diff --git a/memory-bank-v2/OPERATIONS.md b/memory-bank-v2/OPERATIONS.md new file mode 100644 index 0000000..6b25dd5 --- /dev/null +++ b/memory-bank-v2/OPERATIONS.md @@ -0,0 +1,48 @@ +# Memory Bank v2 Operations + +## Command: `update memory bank` + +Use this when significant changes happen or context gets stale. + +### Procedure +1. Read Layer 0 (`00_project_snapshot.md`, `20_now.md`, `21_backlog.md`). +2. Read additional Layer 1 and Layer 2 files that are relevant to touched work. +3. Refresh core files to current truth. +4. Write or update ADRs for durable decisions. +5. Write a task log for significant implementation steps. +6. Validate: + - All referenced ADR and task IDs exist. + - Core files do not contain changelog-style history. + - Budgets from `SCHEMA.md` are respected. +7. Update `Last Updated` fields in touched files. + +### Expected Result +- Layer 0 gives an accurate project state in minutes. +- Decision and execution history is traceable via IDs. + +## Command: `compact memory bank` + +Use this manually for maintenance. + +### Triggers +- Any file exceeds hard budget. +- Backlog has many stale completed items. +- Task logs older than 90 days are accumulating. + +### Procedure +1. Identify oversized core files. +2. Remove historical narrative from core files. +3. Ensure old details live in ADR/task logs. +4. For each month older than 90 days, create or refresh `logs/digests/YYYY-MM.md`. +5. Replace verbose old references in core files with digest links. +6. Re-validate ID/link integrity and budgets. + +### Expected Result +- Core remains concise. +- Older history stays discoverable through digests and linked logs. + +## Consistency Checklist +- Every backlog item has an ID, status, and owner. +- Every ADR has status (`proposed`, `accepted`, `superseded`) and links. +- `20_now.md` and `21_backlog.md` do not conflict. +- `00_project_snapshot.md` reflects the same priorities as `20_now.md`. diff --git a/memory-bank-v2/SCHEMA.md b/memory-bank-v2/SCHEMA.md new file mode 100644 index 0000000..4d34921 --- /dev/null +++ b/memory-bank-v2/SCHEMA.md @@ -0,0 +1,113 @@ +# Memory Bank v2 Schema + +## Required Tree + +```text +memory-bank/ + 00_project_snapshot.md + 10_scope.md + 11_product.md + 12_architecture.md + 13_tech.md + 20_now.md + 21_backlog.md + decisions/ + ADR-0001-short-title.md + logs/ + tasks/YYYY/MM/TASK-YYYYMMDD-001-short-title.md + digests/YYYY-MM.md +``` + +## ID and Naming Rules +- ADR ID: `ADR-0001` style, incrementing sequence. +- Task ID: `TASK-YYYYMMDD-###` style, sequence resets daily. +- Backlog ID: `BG-001` style. +- Core file names are fixed and must not be renamed. +- Keep file names in English; content may be Turkish or English. + +## Hard Size Budgets + +| File | Max lines | Role | +| --- | ---: | --- | +| `00_project_snapshot.md` | 120 | One-page entry point and quick orientation | +| `10_scope.md` | 180 | Scope boundaries and success criteria | +| `11_product.md` | 220 | User/problem/value model | +| `12_architecture.md` | 260 | Architecture map and critical paths | +| `13_tech.md` | 220 | Stack, setup, constraints, runbook basics | +| `20_now.md` | 160 | Current focus, blockers, assumptions | +| `21_backlog.md` | 200 | Prioritized backlog and status | +| `decisions/ADR-*.md` | 180 each | Durable rationale and decision records | +| `logs/tasks/.../*.md` | 250 each | Task execution narrative | +| `logs/digests/YYYY-MM.md` | 220 each | Monthly summary of old history | + +## Section Budgets + +### `00_project_snapshot.md` (<=120) +- Project Card: <=8 lines +- One Paragraph Summary: <=6 lines +- Current State: <=12 bullets +- Decision Index: <=12 ADR links +- Top Risks: <=8 bullets +- Next 7 Days: <=7 bullets +- Read Map: <=6 lines + +### `10_scope.md` (<=180) +- Mission: <=6 lines +- In Scope: <=25 bullets +- Out of Scope: <=25 bullets +- Constraints: <=15 bullets +- Success Criteria: <=15 bullets +- Non-goals: <=10 bullets + +### `11_product.md` (<=220) +- User Segments: <=12 bullets +- Problems: <=15 bullets +- Desired Outcomes: <=15 bullets +- Core Flows: <=15 bullets +- UX Principles: <=10 bullets +- Product Risks: <=12 bullets + +### `12_architecture.md` (<=260) +- System Map: <=40 lines +- Components and Contracts: <=25 bullets +- Data Flows: <=25 bullets +- Critical Paths: <=20 bullets +- Failure Modes: <=20 bullets +- Scaling Constraints: <=15 bullets + +### `13_tech.md` (<=220) +- Stack Matrix: <=35 lines +- Environments: <=20 bullets +- Setup and Commands: <=25 lines +- Dependencies: <=20 bullets +- Constraints and Limits: <=15 bullets + +### `20_now.md` (<=160) +- Current Objective: <=5 lines +- Active Workstreams: <=12 bullets +- Blockers: <=10 bullets +- Next Actions: <=10 bullets +- Assumptions: <=10 bullets +- Watchlist: <=10 bullets + +### `21_backlog.md` (<=200) +- Prioritized table rows: <=60 items +- Completed items older than 30 days must be removed from active table and referenced from task logs/digests. + +## Core File Guardrails +- Core files are current truth only. +- Forbidden in core files: long chronology, date-by-date logs, and narrative changelogs. +- Keep details in ADRs and task logs; keep summaries in core files. +- Prefer references over duplicated prose. + +## Over-Limit Policy +When any file exceeds budget: +1. Keep only current truth in the core file. +2. Move historical detail to ADR/task logs. +3. Add references to moved details. +4. Re-run a consistency check (IDs resolve, no stale links). + +## 90-Day Retention Policy +- Task logs in the last 90 days are hot history. +- History older than 90 days must be summarized into monthly digest files. +- Compaction is manual and triggered by `compact memory bank`. diff --git a/memory-bank-v2/TEMPLATES.md b/memory-bank-v2/TEMPLATES.md new file mode 100644 index 0000000..7582228 --- /dev/null +++ b/memory-bank-v2/TEMPLATES.md @@ -0,0 +1,21 @@ +# Memory Bank v2 Templates + +Use files under `templates/` as copy-ready starters for each new project. + +## Template Index +- `templates/00_project_snapshot.md` +- `templates/10_scope.md` +- `templates/11_product.md` +- `templates/12_architecture.md` +- `templates/13_tech.md` +- `templates/20_now.md` +- `templates/21_backlog.md` +- `templates/decisions/ADR-0000-template.md` +- `templates/logs/tasks/YYYY/MM/TASK-YYYYMMDD-001-template.md` +- `templates/logs/digests/YYYY-MM.md` + +## Usage Rules +1. Keep section headings stable so tools can parse quickly. +2. Replace placeholders (`<...>`) immediately; avoid leaving TODO placeholders in active projects. +3. Keep IDs stable once created. +4. Keep core files concise and route history to ADR/log templates. diff --git a/memory-bank-v2/TEST_CHECKLIST.md b/memory-bank-v2/TEST_CHECKLIST.md new file mode 100644 index 0000000..6a73eb0 --- /dev/null +++ b/memory-bank-v2/TEST_CHECKLIST.md @@ -0,0 +1,29 @@ +# Memory Bank v2 Test Checklist + +## 1. Startup Test (2-3 minute orientation) +- Read only Layer 0 files. +- Confirm the summary includes objective, current status, risks, and next actions. +- Pass if no Layer 1/2 file is required for a basic project brief. + +## 2. Scale Test (200+ task logs) +- Ensure core files remain within hard budgets. +- Confirm old details are reachable through task logs and digests. +- Pass if context load time remains fast and no core file turns into a log. + +## 3. Audit Trace Test +- Pick one decision and trace: + 1. Core summary mention + 2. ADR rationale + 3. Task log implementation record +- Pass if links are complete and consistent. + +## 4. Anti-duplication Test +- Scan core files for repeated long paragraphs. +- Pass if repeated content is replaced with canonical links. + +## 5. Compaction Test +- Run `compact memory bank` procedure. +- Confirm: + - Core files shrink to budget. + - 90+ day history appears in monthly digests. + - Link integrity is preserved. diff --git a/memory-bank-v2/templates/00_project_snapshot.md b/memory-bank-v2/templates/00_project_snapshot.md new file mode 100644 index 0000000..d1f3274 --- /dev/null +++ b/memory-bank-v2/templates/00_project_snapshot.md @@ -0,0 +1,35 @@ +# 00 Project Snapshot + +Last Updated: YYYY-MM-DD +Project: <name> +Owner: <person-or-team> +Stage: discovery|build|stabilize|maintenance + +## One Paragraph Summary +<3-5 sentences that explain what this project does and why it matters.> + +## Current State +- <current truth bullet 1> +- <current truth bullet 2> + +## Top Facts +- [F01] <fact> +- [F02] <fact> + +## Decision Index +- [ADR-0001] <title> + +## Top Risks +- <risk and impact> + +## Next 7 Days +- <priority 1> +- <priority 2> + +## Read Map +- Scope: `10_scope.md` +- Product: `11_product.md` +- Architecture: `12_architecture.md` +- Tech: `13_tech.md` +- Current Operations: `20_now.md` +- Backlog: `21_backlog.md` diff --git a/memory-bank-v2/templates/10_scope.md b/memory-bank-v2/templates/10_scope.md new file mode 100644 index 0000000..6e16912 --- /dev/null +++ b/memory-bank-v2/templates/10_scope.md @@ -0,0 +1,22 @@ +# 10 Scope + +Last Updated: YYYY-MM-DD +Owner: <person-or-team> + +## Mission +<single paragraph> + +## In Scope +- <item> + +## Out of Scope +- <item> + +## Constraints +- <constraint> + +## Success Criteria +- <measurable criterion> + +## Non-goals +- <non-goal> diff --git a/memory-bank-v2/templates/11_product.md b/memory-bank-v2/templates/11_product.md new file mode 100644 index 0000000..6ccd2d1 --- /dev/null +++ b/memory-bank-v2/templates/11_product.md @@ -0,0 +1,22 @@ +# 11 Product + +Last Updated: YYYY-MM-DD +Owner: <person-or-team> + +## User Segments +- <segment> + +## Problems +- <problem> + +## Desired Outcomes +- <outcome> + +## Core Flows +- <flow> + +## UX Principles +- <principle> + +## Product Risks +- <risk> diff --git a/memory-bank-v2/templates/12_architecture.md b/memory-bank-v2/templates/12_architecture.md new file mode 100644 index 0000000..edc817a --- /dev/null +++ b/memory-bank-v2/templates/12_architecture.md @@ -0,0 +1,22 @@ +# 12 Architecture + +Last Updated: YYYY-MM-DD +Owner: <person-or-team> + +## System Map +<high-level architecture summary> + +## Components and Contracts +- <component>: <contract> + +## Data Flows +- <flow> + +## Critical Paths +- <path> + +## Failure Modes +- <failure mode and mitigation> + +## Scaling Constraints +- <constraint> diff --git a/memory-bank-v2/templates/13_tech.md b/memory-bank-v2/templates/13_tech.md new file mode 100644 index 0000000..fca1898 --- /dev/null +++ b/memory-bank-v2/templates/13_tech.md @@ -0,0 +1,21 @@ +# 13 Tech + +Last Updated: YYYY-MM-DD +Owner: <person-or-team> + +## Stack Matrix +| Layer | Technology | Notes | +| --- | --- | --- | +| <layer> | <tech> | <notes> | + +## Environments +- <environment details> + +## Setup and Commands +- `<command>`: <purpose> + +## Dependencies +- <dependency and role> + +## Constraints and Limits +- <limit> diff --git a/memory-bank-v2/templates/20_now.md b/memory-bank-v2/templates/20_now.md new file mode 100644 index 0000000..14c88a1 --- /dev/null +++ b/memory-bank-v2/templates/20_now.md @@ -0,0 +1,22 @@ +# 20 Now + +Last Updated: YYYY-MM-DD +Owner: <person-or-team> + +## Current Objective +<single objective statement> + +## Active Workstreams +- <workstream> + +## Blockers +- <blocker> + +## Next Actions +- [TASK-YYYYMMDD-001] <action> + +## Assumptions +- <assumption> + +## Watchlist +- <item to monitor> diff --git a/memory-bank-v2/templates/21_backlog.md b/memory-bank-v2/templates/21_backlog.md new file mode 100644 index 0000000..57e941c --- /dev/null +++ b/memory-bank-v2/templates/21_backlog.md @@ -0,0 +1,16 @@ +# 21 Backlog + +Last Updated: YYYY-MM-DD +Owner: <person-or-team> + +## Prioritized Items +| ID | Status | Priority | Summary | Owner | Link | +| --- | --- | --- | --- | --- | --- | +| BG-001 | todo | p1 | <item> | <owner> | <task-log-or-doc-link> | + +Status values: `todo`, `in_progress`, `blocked`, `done`. + +## Deferred +| ID | Reason | Revisit Date | +| --- | --- | --- | +| BG-099 | <reason> | YYYY-MM-DD | diff --git a/memory-bank-v2/templates/decisions/ADR-0000-template.md b/memory-bank-v2/templates/decisions/ADR-0000-template.md new file mode 100644 index 0000000..35146f5 --- /dev/null +++ b/memory-bank-v2/templates/decisions/ADR-0000-template.md @@ -0,0 +1,26 @@ +# ADR-0000: <short title> + +Date: YYYY-MM-DD +Status: proposed|accepted|superseded +Owners: <person-or-team> + +## Context +<why this decision is needed> + +## Decision +<what was decided> + +## Consequences +- Positive: <impact> +- Negative: <tradeoff> + +## Alternatives Considered +- <option A> - <why not chosen> +- <option B> - <why not chosen> + +## Links +- Related tasks: `TASK-YYYYMMDD-001` +- Related docs: `<path>` + +## Supersedes / Superseded By +- <ADR-id-or-none> diff --git a/memory-bank-v2/templates/logs/digests/YYYY-MM.md b/memory-bank-v2/templates/logs/digests/YYYY-MM.md new file mode 100644 index 0000000..d0cc089 --- /dev/null +++ b/memory-bank-v2/templates/logs/digests/YYYY-MM.md @@ -0,0 +1,20 @@ +# YYYY-MM Digest + +Month: YYYY-MM +Last Updated: YYYY-MM-DD +Owner: <person-or-team> + +## Summary +<high-level summary of this month for old-history compaction> + +## Key Outcomes +- <outcome> + +## Decisions Referenced +- `ADR-0001` - <title> + +## Notable Tasks +- `TASK-YYYYMMDD-001` - <result> + +## Carryover Risks +- <risk> diff --git a/memory-bank-v2/templates/logs/tasks/YYYY/MM/TASK-YYYYMMDD-001-template.md b/memory-bank-v2/templates/logs/tasks/YYYY/MM/TASK-YYYYMMDD-001-template.md new file mode 100644 index 0000000..19e37fc --- /dev/null +++ b/memory-bank-v2/templates/logs/tasks/YYYY/MM/TASK-YYYYMMDD-001-template.md @@ -0,0 +1,25 @@ +# TASK-YYYYMMDD-001: <short title> + +Date: YYYY-MM-DD +Owner: <person-or-team> +Status: in_progress|done|blocked + +## Intent +<what this task was trying to achieve> + +## Scope +- <in> +- <out> + +## Changes +- <change summary> + +## Validation +- <test/check 1> +- <test/check 2> + +## Decision Links +- ADR: `ADR-0001` + +## Follow-ups +- [BG-001] <follow-up item> diff --git a/memorybank.md b/memorybank.md new file mode 100644 index 0000000..171419e --- /dev/null +++ b/memorybank.md @@ -0,0 +1,115 @@ +# Memory Bank v2 System Prompt + +You are an expert software engineer with zero session memory between runs. +Your only reliable continuity is the project Memory Bank. Maintain it with +strict structure, minimal duplication, and strong audit traceability. + +## Objectives + +1. Fast context load for any task. +2. Maximum useful coverage without bloated core files. +3. Strong audit trail across decisions and execution history. +4. Safe reuse of external project references and imported documentation. + +## Required Contract + +Use this layout under `memory-bank/`: + +```text +memory-bank/ + 00_project_snapshot.md + 10_scope.md + 11_product.md + 12_architecture.md + 13_tech.md + 20_now.md + 21_backlog.md + references/ + ... + decisions/ + ADR-*.md + logs/ + tasks/YYYY/MM/*.md + digests/YYYY-MM.md +``` + +If a required file is missing, create a minimal stub immediately. + +## Layered Read Protocol + +At the start of every task: + +1. Always read Layer 0: `00_project_snapshot.md`, `20_now.md`, `21_backlog.md`. +2. Read Layer 1 only when needed: + + * Product intent: `11_product.md` + * Scope and goals: `10_scope.md` + * Architecture: `12_architecture.md` + * Tech/runtime/tooling: `13_tech.md` +3. Read Layer 1.5 only when external reference context is needed: + + * Imported external framework or library documentation: `references/**` + * External project materials used as implementation reference: `references/**` +4. Read Layer 2 only when evidence is required: + + * Rationale and permanent decisions: `decisions/ADR-*.md` + * Execution history and outcomes: `logs/tasks/...`, `logs/digests/...` + +Do not read all files by default. Expand only when the task requires it. + +## Reference Rules + +1. `memory-bank/references/` is reserved only for external project references and imported documentation. +2. Examples include framework docs, library usage guides, API references, vendor docs, and selected external project materials. +3. Unless the user explicitly says otherwise, everything under `memory-bank/references/` is read-only for the AI/LLM. +4. Read-only means the AI/LLM must not create, modify, move, rename, summarize-overwrite, or delete files in `references/` unless the user explicitly authorizes it. +5. The AI/LLM may read and cite `references/` when relevant. +6. When project implementation depends on an external framework or tool, prefer `references/` as the authoritative source for that external behavior, while keeping project-specific decisions and adaptations inside the normal Memory Bank files. + +## Write Protocol + +1. Core files are `current truth only`. +2. Never keep changelog-style history inside core files. +3. Historical details belong only in `decisions/` and `logs/`. +4. Every non-trivial decision gets an ADR (or updates an existing ADR). +5. Every significant execution step gets a task log entry. +6. Prefer linking IDs (`ADR-xxxx`, `TASK-...`) instead of copying content. +7. Never write into `references/` unless the user explicitly instructs you to do so. + +## Commands + +When the user says `update memory bank`: + +1. Re-read all core files and recently relevant ADR/task logs. +2. Re-read relevant files under `references/` only if external reference context affects current truth. +3. Refresh core files to reflect current truth. +4. Run consistency checks (links, IDs, stale sections, budget limits). +5. Add or update ADRs and task logs when needed. +6. Do not modify `references/` unless explicitly authorized by the user. + +When the user says `compact memory bank`: + +1. Enforce hard size budgets from `memory-bank-v2/SCHEMA.md`. +2. Move old details out of core files into ADR/log references. +3. Summarize `90+` day task history into monthly digests in `logs/digests/`. +4. Keep core files concise and decision-ready. +5. Do not compact, rewrite, or prune `references/` unless explicitly authorized by the user. + +## Guardrails + +1. No changelog sections in core files (`10_*` through `21_*`). +2. No duplicated long-form text across core files. +3. No orphan IDs: all ADR/task references must resolve to real files. +4. If uncertain, record assumptions explicitly in `20_now.md`. +5. Do not treat `references/` as a workspace for generated project memory. +6. Do not copy large amounts of external documentation into core files; extract only project-relevant conclusions, constraints, and decisions. + +## Formatting and Language + +1. File names stay in English. +2. Content can be English or Turkish. +3. Keep entries concise and scannable. + +Detailed budgets, templates, operations, and migration guidance are in: +`memory-bank-v2/SCHEMA.md`, `memory-bank-v2/TEMPLATES.md`, +`memory-bank-v2/OPERATIONS.md`, and `memory-bank-v2/MIGRATION_PLAYBOOK.md`. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..bc17b97 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,48 @@ +# Scripts + +## Structure +- `release/` + - `package-velopack.ps1`: Gitea version guard + build + package release assets + optional Authenticode signing + installer icon + - `upload-feed.ps1`: `vpk upload gitea` ile Gitea Releases'e hedefli upload +- `version/` + - `bump-version.ps1`: bump `major.minor.patch` version fields in the app project + +## Prerequisites +- .NET 8 SDK (`actions/setup-dotnet` CI'da kurar, localde PATH'te `dotnet` olmali) +- Repo-local `vpk` tool: `dotnet tool restore` (manifest: `.config/dotnet-tools.json`, vpk 0.0.1298) + +## Common Commands +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/version/bump-version.ps1 -Patch` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/version/bump-version.ps1 -Minor` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/version/bump-version.ps1 -Major` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/package-velopack.ps1 -SkipSigning` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/package-velopack.ps1 -PfxPath <cert.pfx>` +- `$env:VPK_TOKEN = "<gitea-token>"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/release/upload-feed.ps1` + +## Signing +- Authenticode signing varsayilan olarak **pasif**: `-PfxPath` verilmezse uyariyla atlanir. +- Acmak icin `-PfxPath <cert.pfx>` ver; PFX sifresi interaktif sorulur, script icinde saklanmaz. +- Timestamp (TSA) kullanilmiyor; gerekirse global bir TSA servisiyle `-SignToolPath` akisina eklenebilir. +- v0.3.0 stratejisi: GPG imzali git tag + Gitea release hash'leri yeterli. + +## Packaging Guard +- `scripts/release/package-velopack.ps1` Gitea Releases API uzerinden cloud surumu kontrol eder. +- Gitea'da ayni surum (tag `v<version>`) varsa paketleme durdurulur. +- Gitea'daki surum daha yuksekse paketleme durdurulur. +- API'ye erisilemiyorsa fail-fast durur; offline paketleme icin `-SkipCloudCheck` kullanilabilir. +- Varsayilan installer ikonu `src/RgbLedStripController/Assets/AppIcon.ico` dosyasindan `vpk pack --icon` ile verilir. + +## Upload Guard +- `scripts/release/upload-feed.ps1` yerel release manifest'ini okur, `full/delta .nupkg` + `Setup.exe` / `Portable.zip` varligini preflight olarak dogrular. +- `-Token` verilmezse `VPK_TOKEN` env kullanilir; ikisi de yoksa durur. +- Hedef tag (`v<version>`) Gitea'da varsa upload durdurulur (`-Merge` ile mevcut release'e birlestirilebilir). +- `-ExpectedTag` verilirse turetilen tag ile karsilastirilir (CI'da tag/csproj tutarliligi icin). +- `-WhatIf` ile dry-run yapilabilir. +- Basarili oldugunda Gitea release linkini ekrana yazar. + +## CI (Gitea Actions) +- Workflow: `.gitea/workflows/ci.yml` — `build`, `test`, `release` job'lari. +- `windows` etiketli runner gerekir (WPF sadece Windows'ta derlenir). +- NuGet cache adimi `continue-on-error: true` — cache servisi yoksa build kirilmaz. +- `release` job'u sadece `v*` tag'lerinde calisir, `-SkipSigning` ile paketler. +- Gerekli repo secret: `VPK_TOKEN` (Gitea API token, release yazma yetkili). diff --git a/scripts/release/package-velopack.ps1 b/scripts/release/package-velopack.ps1 new file mode 100644 index 0000000..1e7f6c4 --- /dev/null +++ b/scripts/release/package-velopack.ps1 @@ -0,0 +1,271 @@ +[CmdletBinding()] +param( + [string]$Project = "src/RgbLedStripController/RgbLedStripController.csproj", + [string]$Configuration = "Release", + [string]$Runtime = "win-x64", + [string]$Channel = "win-x64-stable", + [string]$PackId = "Fristy.RgbLedStripController", + [string]$PackTitle = "RGB Led Strip Controller", + [string]$PackAuthors = "fristy", + [string]$GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller", + [string]$InstallerIcon = "src/RgbLedStripController/Assets/AppIcon.ico", + [string]$PfxPath = "", + [string]$SignToolPath = "signtool.exe", + [int]$SignParallel = 1, + [switch]$SkipSigning, + [switch]$SkipCloudCheck +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) +$projectPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Project)) +$nugetConfigPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot "NuGet.Config")) + +if (-not (Test-Path -LiteralPath $projectPath)) { + throw "Project file not found: $projectPath" +} + +[xml]$projectXml = Get-Content -Raw -LiteralPath $projectPath +$versionNode = $null +foreach ($propertyGroup in @($projectXml.Project.PropertyGroup)) { + if ($propertyGroup.PSObject.Properties.Name -contains "Version" -and -not [string]::IsNullOrWhiteSpace($propertyGroup.Version)) { + $versionNode = $propertyGroup.Version + break + } +} + +$version = [string]$versionNode + +if ([string]::IsNullOrWhiteSpace($version)) { + throw "No <Version> found in project file: $projectPath" +} + +function Assert-WorkspacePath { + param([string]$Path) + + $fullPath = [System.IO.Path]::GetFullPath($Path) + if (-not $fullPath.StartsWith($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use path outside workspace: $fullPath" + } + + return $fullPath +} + +function Resolve-AbsolutePath { + param([string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "Path value cannot be empty." + } + + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + + return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) +} + +function Get-GiteaReleaseTags { + param([string]$RepoUrl) + + $uri = [Uri]$RepoUrl + $segments = $uri.AbsolutePath.Trim("/").Split("/") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ($segments.Count -lt 2) { + throw "Could not parse owner/repo from GiteaRepoUrl: $RepoUrl" + } + + $owner = $segments[-2] + $repo = $segments[-1] + $apiUrl = "{0}://{1}/api/v1/repos/{2}/{3}/releases?draft=false&limit=50" -f $uri.Scheme, $uri.Authority, $owner, $repo + + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $releases = Invoke-RestMethod -Uri $apiUrl -Method Get -ErrorAction Stop + } + catch { + throw "Gitea releases could not be read: $apiUrl`n$($_.Exception.Message)" + } + + return @($releases | ForEach-Object { $_.tag_name } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} + +function Get-MaxVersionFromTags { + param([string[]]$Tags) + + $versions = @() + foreach ($tag in $Tags) { + $clean = $tag.TrimStart("v", "V") + $parsed = $null + if ([Version]::TryParse($clean, [ref]$parsed)) { + $versions += $parsed + } + } + + if ($versions.Count -eq 0) { + return $null + } + + return ($versions | Sort-Object -Descending | Select-Object -First 1) +} + +function Read-SecretAsPlainText { + param([string]$Prompt) + + $secureString = Read-Host -Prompt $Prompt -AsSecureString + if ($secureString.Length -eq 0) { + throw "Empty password is not allowed." + } + + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureString) + try { + return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } + finally { + if ($bstr -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } +} + +function Resolve-SignToolExecutable { + param([string]$RequestedPath) + + if (Test-Path -LiteralPath $RequestedPath) { + return [System.IO.Path]::GetFullPath($RequestedPath) + } + + $command = Get-Command -Name $RequestedPath -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + throw "signtool.exe could not be resolved. Pass -SignToolPath with an absolute path if needed." +} + +$publishDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\publish\$Runtime\$version") +$releaseDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\velopack\$Channel") +$mainExe = "RgbLedStripController.exe" +$dotnet = "dotnet" +$signingEnabled = (-not $SkipSigning.IsPresent) -and (-not [string]::IsNullOrWhiteSpace($PfxPath)) +if ((-not $SkipSigning.IsPresent) -and [string]::IsNullOrWhiteSpace($PfxPath)) { + Write-Warning "PfxPath not supplied; Authenticode signing will be skipped. Pass -PfxPath to enable it." +} +$signToolExecutable = $null +$signToolDirectory = $null +$plainPassword = $null +$resolvedPfxPath = $null +$resolvedInstallerIconPath = Resolve-AbsolutePath -Path $InstallerIcon +$signParams = $null + +if (-not (Test-Path -LiteralPath $resolvedInstallerIconPath)) { + throw "Installer icon file not found: $resolvedInstallerIconPath" +} + +$env:DOTNET_CLI_HOME = Join-Path $repoRoot ".dotnet" +$env:APPDATA = Join-Path $repoRoot ".appdata" +$env:NUGET_PACKAGES = Join-Path $repoRoot ".nuget\packages" +$env:NUGET_HTTP_CACHE_PATH = Join-Path $repoRoot ".nuget\http-cache" + +$localVersion = [Version]$version +$cloudVersion = $null +if (-not $SkipCloudCheck.IsPresent) { + $cloudVersion = Get-MaxVersionFromTags -Tags (Get-GiteaReleaseTags -RepoUrl $GiteaRepoUrl) +} + +if ($null -ne $cloudVersion) { + if ($localVersion -lt $cloudVersion) { + throw "Local version $version is older than Gitea version $cloudVersion. Bump the project version before packaging." + } + + if ($localVersion -eq $cloudVersion) { + throw "Gitea already contains version $version. Packaging with the same version is blocked." + } +} + +if ($signingEnabled) { + $resolvedPfxPath = Resolve-AbsolutePath -Path $PfxPath + if (-not (Test-Path -LiteralPath $resolvedPfxPath)) { + throw "PFX file not found: $resolvedPfxPath" + } + + if ($SignParallel -lt 1) { + throw "SignParallel must be at least 1." + } + + $signToolExecutable = Resolve-SignToolExecutable -RequestedPath $SignToolPath + $signToolDirectory = Split-Path -Parent $signToolExecutable + if (-not [string]::IsNullOrWhiteSpace($signToolDirectory)) { + $env:PATH = $signToolDirectory + [System.IO.Path]::PathSeparator + $env:PATH + } + + $plainPassword = Read-SecretAsPlainText -Prompt "PFX password" + $signParams = "/fd SHA256 /f `"$resolvedPfxPath`" /p `"$plainPassword`"" +} + +if (Test-Path -LiteralPath $publishDir) { + Remove-Item -LiteralPath $publishDir -Recurse -Force +} + +New-Item -ItemType Directory -Force -Path $publishDir | Out-Null +New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + +$publishArgs = @( + "publish", + $projectPath, + "-c", $Configuration, + "-r", $Runtime, + "--self-contained", "true", + "-p:PublishSingleFile=true", + "-o", $publishDir, + "--configfile", $nugetConfigPath +) + +& $dotnet @publishArgs +if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed with exit code $LASTEXITCODE" +} + +$packArgs = @( + "tool", "run", "vpk", "--", + "pack", + "--packId", $PackId, + "--packVersion", $version, + "--packDir", $publishDir, + "--mainExe", $mainExe, + "--packTitle", $PackTitle, + "--packAuthors", $PackAuthors, + "--runtime", $Runtime, + "--channel", $Channel, + "--icon", $resolvedInstallerIconPath, + "--outputDir", $releaseDir +) + +if ($signingEnabled) { + $packArgs += @( + "--signParams", $signParams, + "--signParallel", $SignParallel.ToString() + ) +} + +& $dotnet @packArgs +if ($LASTEXITCODE -ne 0) { + throw "vpk pack failed with exit code $LASTEXITCODE" +} + +Write-Host "" +Write-Host "Velopack package build complete." -ForegroundColor Green +Write-Host "Version: $version" +Write-Host "Gitea: $(if ($null -ne $cloudVersion) { $cloudVersion.ToString() } else { 'none' })" +Write-Host "Signing: $(if ($signingEnabled) { 'enabled' } else { 'skipped' })" +if ($signingEnabled -and -not [string]::IsNullOrWhiteSpace($signToolExecutable)) { + Write-Host "SignTool: $signToolExecutable" +} +Write-Host "Icon: $resolvedInstallerIconPath" +Write-Host "PublishDir: $publishDir" +Write-Host "ReleaseDir: $releaseDir" +Write-Host "Gitea repo: $GiteaRepoUrl" +Write-Host "Tag: v$version" +Write-Host "" +Write-Host "Upload with: scripts/release/upload-feed.ps1 (vpk upload gitea)." -ForegroundColor Yellow diff --git a/scripts/release/upload-feed.ps1 b/scripts/release/upload-feed.ps1 new file mode 100644 index 0000000..c66ca26 --- /dev/null +++ b/scripts/release/upload-feed.ps1 @@ -0,0 +1,141 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$Channel = "win-x64-stable", + [string]$GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller", + [string]$Tag = "", + [string]$Token = $env:VPK_TOKEN, + [string]$ExpectedTag = "", + [switch]$Prerelease, + [switch]$Merge +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) + +if ([string]::IsNullOrWhiteSpace($Token)) { + throw "Gitea token is required. Pass -Token or set the VPK_TOKEN environment variable." +} + +function Assert-WorkspacePath { + param([string]$Path) + + $fullPath = [System.IO.Path]::GetFullPath($Path) + if (-not $fullPath.StartsWith($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use path outside workspace: $fullPath" + } + + return $fullPath +} + +function Get-GiteaReleaseTagNames { + param( + [string]$RepoUrl, + [string]$AccessToken + ) + + $uri = [Uri]$RepoUrl + $segments = $uri.AbsolutePath.Trim("/").Split("/") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ($segments.Count -lt 2) { + throw "Could not parse owner/repo from GiteaRepoUrl: $RepoUrl" + } + + $owner = $segments[-2] + $repo = $segments[-1] + $apiUrl = "{0}://{1}/api/v1/repos/{2}/{3}/releases?draft=false&limit=50" -f $uri.Scheme, $uri.Authority, $owner, $repo + + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $headers = @{ Authorization = "token $AccessToken" } + $releases = Invoke-RestMethod -Uri $apiUrl -Method Get -Headers $headers -ErrorAction Stop + } + catch { + throw "Gitea releases could not be read: $apiUrl`n$($_.Exception.Message)" + } + + return @($releases | ForEach-Object { $_.tag_name } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} + +$releaseDir = Assert-WorkspacePath (Join-Path $repoRoot "artifacts\velopack\$Channel") +$manifestPath = Join-Path $releaseDir "releases.$Channel.json" + +if (-not (Test-Path -LiteralPath $releaseDir)) { + throw "Release directory not found: $releaseDir" +} + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "Release manifest not found: $manifestPath" +} + +$localManifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json +if ($null -eq $localManifest.Assets -or $localManifest.Assets.Count -eq 0) { + throw "Release manifest does not contain any assets: $manifestPath" +} + +$packageId = $localManifest.Assets[0].PackageId +$localVersions = @( + $localManifest.Assets | + Where-Object { $_.PackageId -eq $packageId -and -not [string]::IsNullOrWhiteSpace($_.Version) } | + ForEach-Object { [Version]$_.Version } +) +if ($localVersions.Count -eq 0) { + throw "Could not determine local package version from: $manifestPath" +} + +$localVersion = ($localVersions | Sort-Object -Descending | Select-Object -First 1) +$tag = if ([string]::IsNullOrWhiteSpace($Tag)) { "v$($localVersion.ToString())" } else { $Tag } + +if (-not [string]::IsNullOrWhiteSpace($ExpectedTag) -and -not [string]::Equals($tag, $ExpectedTag, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Derived tag $tag does not match expected tag $ExpectedTag. Check the pushed tag against the project version." +} + +$setupFiles = @(Get-ChildItem -LiteralPath $releaseDir -Filter "*-Setup.exe" -File) +$portableFiles = @(Get-ChildItem -LiteralPath $releaseDir -Filter "*-Portable.zip" -File) +if ($setupFiles.Count -eq 0) { + throw "No Setup.exe file found in release directory: $releaseDir" +} + +if ($portableFiles.Count -eq 0) { + throw "No Portable.zip file found in release directory: $releaseDir" +} + +$existingTags = Get-GiteaReleaseTagNames -RepoUrl $GiteaRepoUrl -AccessToken $Token +if ($existingTags -contains $tag -and -not $Merge.IsPresent) { + throw "Gitea already contains tag $tag. Upload is blocked (pass -Merge to merge into the existing release)." +} + +$releaseUrl = $GiteaRepoUrl.TrimEnd("/") + "/releases/tag/" + $tag +$vpkArgs = @( + "tool", "run", "vpk", "--", + "upload", "gitea", + "--outputDir", $releaseDir, + "--channel", $Channel, + "--repoUrl", $GiteaRepoUrl, + "--token", $Token, + "--tag", $tag, + "--publish" +) +if ($Prerelease.IsPresent) { + $vpkArgs += "--pre" +} + +if ($Merge.IsPresent) { + $vpkArgs += "--merge" +} + +if ($PSCmdlet.ShouldProcess("$GiteaRepoUrl@$tag", "Upload Velopack release")) { + & "dotnet" @vpkArgs + if ($LASTEXITCODE -ne 0) { + throw "vpk upload failed with exit code $LASTEXITCODE" + } +} + +Write-Host "" +Write-Host "Velopack feed upload complete." -ForegroundColor Green +Write-Host "Version: $($localVersion.ToString())" +Write-Host "Tag: $tag" +Write-Host "Channel: $Channel" +Write-Host "ReleaseDir: $releaseDir" +Write-Host "Release: $releaseUrl" +Write-Host "Prerelease: $(if ($Prerelease.IsPresent) { 'yes' } else { 'no' })" diff --git a/scripts/version/bump-version.ps1 b/scripts/version/bump-version.ps1 new file mode 100644 index 0000000..2c61d81 --- /dev/null +++ b/scripts/version/bump-version.ps1 @@ -0,0 +1,67 @@ +[CmdletBinding(DefaultParameterSetName = "Patch")] +param( + [Parameter(Mandatory = $true, ParameterSetName = "Major")] + [switch]$Major, + + [Parameter(Mandatory = $true, ParameterSetName = "Minor")] + [switch]$Minor, + + [Parameter(Mandatory = $true, ParameterSetName = "Patch")] + [switch]$Patch, + + [string]$Project = "src/RgbLedStripController/RgbLedStripController.csproj" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) +$projectPath = if ([System.IO.Path]::IsPathRooted($Project)) { + $Project +} else { + Join-Path $repoRoot $Project +} + +$projectPath = [System.IO.Path]::GetFullPath($projectPath) +if (-not (Test-Path -LiteralPath $projectPath)) { + throw "Project file not found: $projectPath" +} + +$content = Get-Content -Raw -LiteralPath $projectPath +$versionMatch = [System.Text.RegularExpressions.Regex]::Match($content, "<Version>(?<value>\d+\.\d+\.\d+)</Version>") +if (-not $versionMatch.Success) { + throw "No semantic <Version> value found in project file: $projectPath" +} + +$currentVersion = $versionMatch.Groups["value"].Value +$parts = $currentVersion.Split(".") | ForEach-Object { [int]$_ } + +$newVersion = switch ($PSCmdlet.ParameterSetName) { + "Major" { "{0}.0.0" -f ($parts[0] + 1) } + "Minor" { "{0}.{1}.0" -f $parts[0], ($parts[1] + 1) } + default { "{0}.{1}.{2}" -f $parts[0], $parts[1], ($parts[2] + 1) } +} + +$newAssemblyVersion = "$newVersion.0" + +function Replace-FirstMatch { + param( + [string]$Text, + [string]$Pattern, + [string]$Replacement + ) + + $regex = [System.Text.RegularExpressions.Regex]::new($Pattern) + return $regex.Replace($Text, $Replacement, 1) +} + +$content = Replace-FirstMatch $content "<Version>[^<]+</Version>" "<Version>$newVersion</Version>" +$content = Replace-FirstMatch $content "<AssemblyVersion>[^<]+</AssemblyVersion>" "<AssemblyVersion>$newAssemblyVersion</AssemblyVersion>" +$content = Replace-FirstMatch $content "<FileVersion>[^<]+</FileVersion>" "<FileVersion>$newAssemblyVersion</FileVersion>" +$content = Replace-FirstMatch $content "<InformationalVersion>[^<]+</InformationalVersion>" "<InformationalVersion>$newVersion</InformationalVersion>" + +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText($projectPath, $content, $utf8NoBom) + +Write-Host ("Version updated: {0} -> {1}" -f $currentVersion, $newVersion) -ForegroundColor Green +Write-Host ("Project file: {0}" -f $projectPath) diff --git a/src/RgbLedStripController/App.xaml b/src/RgbLedStripController/App.xaml new file mode 100644 index 0000000..ac8497d --- /dev/null +++ b/src/RgbLedStripController/App.xaml @@ -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> diff --git a/src/RgbLedStripController/App.xaml.cs b/src/RgbLedStripController/App.xaml.cs new file mode 100644 index 0000000..6737081 --- /dev/null +++ b/src/RgbLedStripController/App.xaml.cs @@ -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(); + } +} diff --git a/src/RgbLedStripController/Assets/AppIcon.ico b/src/RgbLedStripController/Assets/AppIcon.ico new file mode 100644 index 0000000000000000000000000000000000000000..928efba514b28bf47a89b3c0c5ebf69bce8cfd70 GIT binary patch literal 21793 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 literal 0 HcmV?d00001 diff --git a/src/RgbLedStripController/Assets/AppIcon.png b/src/RgbLedStripController/Assets/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..930729f85a5bed40e5e7e076f6b9cbce8148b268 GIT binary patch literal 21771 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 b>&8wfGc~mvq5TCQFx_EkcEPmT7#jB{t%pym literal 0 HcmV?d00001 diff --git a/src/RgbLedStripController/Core/Models.cs b/src/RgbLedStripController/Core/Models.cs new file mode 100644 index 0000000..f043845 --- /dev/null +++ b/src/RgbLedStripController/Core/Models.cs @@ -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; + } +} diff --git a/src/RgbLedStripController/MainWindow.xaml b/src/RgbLedStripController/MainWindow.xaml new file mode 100644 index 0000000..2b8ec35 --- /dev/null +++ b/src/RgbLedStripController/MainWindow.xaml @@ -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> diff --git a/src/RgbLedStripController/MainWindow.xaml.cs b/src/RgbLedStripController/MainWindow.xaml.cs new file mode 100644 index 0000000..762f06f --- /dev/null +++ b/src/RgbLedStripController/MainWindow.xaml.cs @@ -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)); +} diff --git a/src/RgbLedStripController/RgbLedStripController.csproj b/src/RgbLedStripController/RgbLedStripController.csproj new file mode 100644 index 0000000..86bb9b4 --- /dev/null +++ b/src/RgbLedStripController/RgbLedStripController.csproj @@ -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> diff --git a/src/RgbLedStripController/Services/BleDeviceService.cs b/src/RgbLedStripController/Services/BleDeviceService.cs new file mode 100644 index 0000000..f4a3de4 --- /dev/null +++ b/src/RgbLedStripController/Services/BleDeviceService.cs @@ -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 + }; + } +} diff --git a/src/RgbLedStripController/Services/ElkBledomProtocol.cs b/src/RgbLedStripController/Services/ElkBledomProtocol.cs new file mode 100644 index 0000000..9b2990a --- /dev/null +++ b/src/RgbLedStripController/Services/ElkBledomProtocol.cs @@ -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) + }; +} diff --git a/src/RgbLedStripController/Services/IBleTransport.cs b/src/RgbLedStripController/Services/IBleTransport.cs new file mode 100644 index 0000000..03fb65b --- /dev/null +++ b/src/RgbLedStripController/Services/IBleTransport.cs @@ -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(); +} diff --git a/src/RgbLedStripController/Services/MultiDeviceController.cs b/src/RgbLedStripController/Services/MultiDeviceController.cs new file mode 100644 index 0000000..b79b899 --- /dev/null +++ b/src/RgbLedStripController/Services/MultiDeviceController.cs @@ -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; } + } +} diff --git a/src/RgbLedStripController/Services/SettingsStore.cs b/src/RgbLedStripController/Services/SettingsStore.cs new file mode 100644 index 0000000..606598d --- /dev/null +++ b/src/RgbLedStripController/Services/SettingsStore.cs @@ -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; } + } +}