commit c9e643d6172fc1edac42cb10bd883c3bf95a83e7 Author: fristyxd Date: Fri Sep 4 23:31:11 2026 +0300 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 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 0000000..928efba Binary files /dev/null and b/src/RgbLedStripController/Assets/AppIcon.ico differ diff --git a/src/RgbLedStripController/Assets/AppIcon.png b/src/RgbLedStripController/Assets/AppIcon.png new file mode 100644 index 0000000..930729f Binary files /dev/null and b/src/RgbLedStripController/Assets/AppIcon.png differ 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; } + } +}