Initial commit: RGB LED Strip Controller v0.3.0

- WPF + WinRT BLE controller for ELK-BLEDOM compatible LED strips

- Multi-device control, effects, grayscale and temperature modes

- Velopack updates via Gitea Releases, Gitea Actions CI
This commit is contained in:
fristy committed 2026-09-04 23:31:11 +03:00
commit c9e643d617
40 files changed
+5411

No files matched your search

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

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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