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:
40 files changed
+5411
No files matched your search
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"vpk": {
|
||||
"version": "0.0.1298",
|
||||
"commands": [
|
||||
"vpk"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,47 @@
|
||||
# .NET build outputs
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
publish/
|
||||
Releases/
|
||||
TestResults/
|
||||
|
||||
# NuGet packages and local caches
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
|
||||
# Local sandbox dirs created by scripts/release/*.ps1
|
||||
.dotnet/
|
||||
.appdata/
|
||||
|
||||
# Code signing material - NEVER commit private keys
|
||||
*.pfx
|
||||
*.p12
|
||||
*.key
|
||||
*.pem
|
||||
*.cer
|
||||
|
||||
# Secrets / local env
|
||||
.env
|
||||
*.env.local
|
||||
appsettings.*.local.json
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
|
||||
# Local agent/tooling state (regenerable)
|
||||
.opencode/
|
||||
.codegraph/
|
||||
|
||||
# Internal dev memory (stays local, not published)
|
||||
memory-bank/
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,29 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RgbLedStripController", "src\RgbLedStripController\RgbLedStripController.csproj", "{A703B02A-BF19-5053-22FA-A79628B40F49}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A703B02A-BF19-5053-22FA-A79628B40F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A703B02A-BF19-5053-22FA-A79628B40F49}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A703B02A-BF19-5053-22FA-A79628B40F49}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A703B02A-BF19-5053-22FA-A79628B40F49}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A703B02A-BF19-5053-22FA-A79628B40F49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {09247BEF-9950-43C7-91FF-2FCFA0740DA0}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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' })"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,433 @@
|
||||
<Application x:Class="RgbLedStripController.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<Color x:Key="BackgroundColor">#FF090D12</Color>
|
||||
<Color x:Key="BackgroundSecondaryColor">#FF101721</Color>
|
||||
<Color x:Key="CardColor">#FF121923</Color>
|
||||
<Color x:Key="CardMutedColor">#FF161F2B</Color>
|
||||
<Color x:Key="CardBorderColor">#1FFFFFFF</Color>
|
||||
<Color x:Key="TextColor">#FFF4F7FB</Color>
|
||||
<Color x:Key="MutedTextColor">#FF9AA8BA</Color>
|
||||
<Color x:Key="AccentColor">#FF4CC2FF</Color>
|
||||
<Color x:Key="AccentDeepColor">#FF198BB7</Color>
|
||||
<Color x:Key="AccentWarmColor">#FFFF7A59</Color>
|
||||
<Color x:Key="SuccessColor">#FF3DD598</Color>
|
||||
<Color x:Key="WarningColor">#FFF6C453</Color>
|
||||
<Color x:Key="DangerColor">#FFFF6B7A</Color>
|
||||
<Color x:Key="SecondaryColor">#FF222E3F</Color>
|
||||
<Color x:Key="InputColor">#FF0E151F</Color>
|
||||
<Color x:Key="InputHoverColor">#FF121B28</Color>
|
||||
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}" />
|
||||
<SolidColorBrush x:Key="BackgroundSecondaryBrush" Color="{StaticResource BackgroundSecondaryColor}" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="{StaticResource CardColor}" />
|
||||
<SolidColorBrush x:Key="CardMutedBrush" Color="{StaticResource CardMutedColor}" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="{StaticResource CardBorderColor}" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}" />
|
||||
<SolidColorBrush x:Key="MutedTextBrush" Color="{StaticResource MutedTextColor}" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="AccentDeepBrush" Color="{StaticResource AccentDeepColor}" />
|
||||
<SolidColorBrush x:Key="AccentWarmBrush" Color="{StaticResource AccentWarmColor}" />
|
||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource SecondaryColor}" />
|
||||
<SolidColorBrush x:Key="InputBrush" Color="{StaticResource InputColor}" />
|
||||
<SolidColorBrush x:Key="InputHoverBrush" Color="{StaticResource InputHoverColor}" />
|
||||
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.WindowBrushKey}" Color="{StaticResource InputColor}" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}" Color="{StaticResource InputColor}" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.WindowTextBrushKey}" Color="{StaticResource TextColor}" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}" Color="{StaticResource TextColor}" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="{StaticResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="#FF091018" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.GrayTextBrushKey}" Color="{StaticResource MutedTextColor}" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" Color="#33243B53" />
|
||||
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="{StaticResource TextColor}" />
|
||||
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="Background" Value="{StaticResource BackgroundBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="FontFamily" Value="Segoe UI Variable Display" />
|
||||
<Setter Property="UseLayoutRounding" Value="True" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CardBorderStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="16" />
|
||||
<Setter Property="Padding" Value="20" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PanelCardStyle" TargetType="Border" BasedOn="{StaticResource CardBorderStyle}">
|
||||
<Setter Property="Background" Value="{StaticResource CardMutedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="18" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SectionTitleStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="Bahnschrift SemiCondensed" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Foreground" Value="#FF061019" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentDeepBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="14,10" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="ButtonBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="12">
|
||||
<ContentPresenter HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="{TemplateBinding Padding}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.96" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.84" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="ButtonBorder" Property="Opacity" Value="0.40" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GhostButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Background" Value="{StaticResource SecondaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="#36FFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DangerGhostButtonStyle" TargetType="Button" BasedOn="{StaticResource GhostButtonStyle}">
|
||||
<Setter Property="Foreground" Value="#FFFFC3CB" />
|
||||
<Setter Property="BorderBrush" Value="#4DFF6B7A" />
|
||||
<Setter Property="Background" Value="#261E2431" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="WarmGhostButtonStyle" TargetType="Button" BasedOn="{StaticResource GhostButtonStyle}">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentWarmBrush}" />
|
||||
<Setter Property="BorderBrush" Value="#55FF7A59" />
|
||||
<Setter Property="Background" Value="#221E2B33" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SwatchButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Width" Value="32" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="BorderBrush" Value="#55FFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ListBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Hidden" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0,0,0,10" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="ItemBorder"
|
||||
Background="#FF151D29"
|
||||
BorderBrush="#18FFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="14">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="#FF182435" />
|
||||
<Setter TargetName="ItemBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="#FF182131" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource InputBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="BorderBrush" Value="#30FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="TextBoxBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="12">
|
||||
<ScrollViewer x:Name="PART_ContentHost"
|
||||
Margin="{TemplateBinding Padding}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="TextBoxBorder" Property="Background" Value="{StaticResource InputHoverBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="TextBoxBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Background" Value="{StaticResource InputBrush}" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="ItemBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="10">
|
||||
<ContentPresenter Margin="{TemplateBinding Padding}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="#FF1C2737" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="ItemBorder" Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="#FF081019" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Background" Value="{StaticResource InputBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="BorderBrush" Value="#30FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="10,8" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinHeight" Value="42" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="EffectComboBoxToggleStyle" TargetType="ToggleButton">
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="Background" Value="{StaticResource InputBrush}" />
|
||||
<Setter Property="BorderBrush" Value="#30FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="ToggleBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="14">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<ContentPresenter Margin="16,12,12,12"
|
||||
VerticalAlignment="Center"
|
||||
RecognizesAccessKey="True" />
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Width="34"
|
||||
Height="34"
|
||||
Margin="0,6,6,6"
|
||||
CornerRadius="10"
|
||||
Background="#16FFFFFF">
|
||||
<Path x:Name="Chevron"
|
||||
Width="10"
|
||||
Height="6"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Data="M 0 0 L 5 5 L 10 0"
|
||||
Stroke="{StaticResource TextBrush}"
|
||||
StrokeThickness="1.8"
|
||||
Stretch="Fill" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ToggleBorder" Property="Background" Value="{StaticResource InputHoverBrush}" />
|
||||
<Setter TargetName="ToggleBorder" Property="BorderBrush" Value="#46FFFFFF" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="ToggleBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter TargetName="ToggleBorder" Property="Background" Value="#FF122031" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="ToggleBorder" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="EffectComboBoxStyle" TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
|
||||
<Setter Property="Background" Value="#FF111925" />
|
||||
<Setter Property="BorderBrush" Value="#30FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MaxDropDownHeight" Value="300" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton x:Name="DropDownToggle"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
ClickMode="Press"
|
||||
Focusable="False"
|
||||
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"
|
||||
Style="{StaticResource EffectComboBoxToggleStyle}">
|
||||
<ContentPresenter Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
IsHitTestVisible="False" />
|
||||
</ToggleButton>
|
||||
|
||||
<Popup x:Name="PART_Popup"
|
||||
AllowsTransparency="True"
|
||||
Focusable="False"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
Placement="Bottom"
|
||||
PopupAnimation="Fade"
|
||||
StaysOpen="False">
|
||||
<Border x:Name="PopupBorder"
|
||||
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}"
|
||||
Margin="0,8,0,0"
|
||||
Padding="8"
|
||||
Background="#FF0F1723"
|
||||
BorderBrush="#30FFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="16">
|
||||
<ScrollViewer CanContentScroll="True"
|
||||
SnapsToDevicePixels="True"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsKeyboardFocusWithin" Value="True">
|
||||
<Setter TargetName="DropDownToggle" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="HasItems" Value="False">
|
||||
<Setter TargetName="PopupBorder" Property="MinHeight" Value="56" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="DropDownToggle" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TargetCheckBoxStyle" TargetType="CheckBox" BasedOn="{StaticResource {x:Type CheckBox}}">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<Border x:Name="Shell"
|
||||
Padding="10,8"
|
||||
Background="#FF101824"
|
||||
BorderBrush="#24FFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="10" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border x:Name="TickBox"
|
||||
Width="20"
|
||||
Height="20"
|
||||
Background="#FF0B121B"
|
||||
BorderBrush="#3AFFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="7">
|
||||
<TextBlock x:Name="CheckGlyph"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="13"
|
||||
FontWeight="Bold"
|
||||
Foreground="#FF061019"
|
||||
Text="✓"
|
||||
Visibility="Collapsed" />
|
||||
</Border>
|
||||
|
||||
<ContentPresenter Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
RecognizesAccessKey="True" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Shell" Property="BorderBrush" Value="#45FFFFFF" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Shell" Property="Background" Value="#FF142235" />
|
||||
<Setter TargetName="Shell" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter TargetName="TickBox" Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter TargetName="TickBox" Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter TargetName="CheckGlyph" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Shell" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="Margin" Value="0,10,0,0" />
|
||||
</Style>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using Velopack;
|
||||
|
||||
namespace RgbLedStripController;
|
||||
|
||||
public partial class App : System.Windows.Application
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
VelopackApp.Build().Run();
|
||||
|
||||
var app = new App();
|
||||
app.InitializeComponent();
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,491 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using MediaBrush = System.Windows.Media.Brush;
|
||||
using MediaColor = System.Windows.Media.Color;
|
||||
using SolidColorBrush = System.Windows.Media.SolidColorBrush;
|
||||
|
||||
namespace RgbLedStripController.Core;
|
||||
|
||||
public enum DeviceConnectionStatus
|
||||
{
|
||||
Disconnected,
|
||||
Scanning,
|
||||
Connecting,
|
||||
Connected,
|
||||
Faulted
|
||||
}
|
||||
|
||||
public enum LightingMode
|
||||
{
|
||||
Rgb,
|
||||
Effect,
|
||||
Grayscale,
|
||||
Temperature
|
||||
}
|
||||
|
||||
public sealed class BleScanResult : ObservableObject
|
||||
{
|
||||
private string _alias = string.Empty;
|
||||
private bool _isKnownCandidate;
|
||||
private bool _isLastConnected;
|
||||
|
||||
public required string DeviceId { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required string BluetoothAddress { get; init; }
|
||||
|
||||
public required ulong AddressValue { get; init; }
|
||||
|
||||
public short SignalStrength { get; init; }
|
||||
|
||||
public string Alias
|
||||
{
|
||||
get => _alias;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _alias, value ?? string.Empty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
OnPropertyChanged(nameof(SecondaryLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsKnownCandidate
|
||||
{
|
||||
get => _isKnownCandidate;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _isKnownCandidate, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(Badge));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLastConnected
|
||||
{
|
||||
get => _isLastConnected;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _isLastConnected, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(Badge));
|
||||
}
|
||||
}
|
||||
|
||||
public string NativeName => string.IsNullOrWhiteSpace(Name)
|
||||
? "İsimsiz BLE cihazı"
|
||||
: Name;
|
||||
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(Alias)
|
||||
? NativeName
|
||||
: Alias;
|
||||
|
||||
public string SecondaryLabel => string.IsNullOrWhiteSpace(Alias)
|
||||
? BluetoothAddress
|
||||
: $"{NativeName} - {BluetoothAddress}";
|
||||
|
||||
public string Badge => IsLastConnected
|
||||
? "Son cihaz"
|
||||
: IsKnownCandidate
|
||||
? "Aday"
|
||||
: "BLE";
|
||||
|
||||
public string SignalLabel => SignalStrength == short.MinValue
|
||||
? "RSSI bilinmiyor"
|
||||
: $"RSSI {SignalStrength} dBm";
|
||||
}
|
||||
|
||||
public sealed record DeviceProfile(
|
||||
string LocalAlias,
|
||||
string DeviceId,
|
||||
string BluetoothAddress,
|
||||
DateTimeOffset LastSeenUtc);
|
||||
|
||||
public sealed record EffectOption(byte PatternId, string Label, bool SupportsSpeed)
|
||||
{
|
||||
public string Code => $"{PatternId + 1:00}";
|
||||
}
|
||||
|
||||
public sealed record ColorPreset(string Label, string Hex)
|
||||
{
|
||||
public string ToolTipLabel => $"{Label} · {Hex}";
|
||||
}
|
||||
|
||||
public sealed record SettingsSnapshot(
|
||||
IReadOnlyList<DeviceProfile> KnownDevices,
|
||||
IReadOnlyDictionary<string, string> DeviceAliases,
|
||||
MediaColor SelectedColor,
|
||||
byte Brightness,
|
||||
byte EffectSpeed,
|
||||
LightingMode CurrentMode,
|
||||
byte GrayscaleLevel,
|
||||
byte TemperatureLevel,
|
||||
byte SelectedEffectId,
|
||||
bool IsPoweredOn);
|
||||
|
||||
public abstract class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
public sealed class ConnectedDeviceViewModel : ObservableObject
|
||||
{
|
||||
private string _alias = string.Empty;
|
||||
private bool _isControlTarget = true;
|
||||
private DeviceConnectionStatus _connectionStatus = DeviceConnectionStatus.Connecting;
|
||||
private string _statusText = "Bağlantı hazırlanıyor...";
|
||||
private MediaBrush _statusBrush = CreateFrozenBrush(MediaColor.FromRgb(59, 130, 246));
|
||||
|
||||
public required string DeviceId { get; init; }
|
||||
|
||||
public required string NativeName { get; init; }
|
||||
|
||||
public required string BluetoothAddress { get; init; }
|
||||
|
||||
public required ulong AddressValue { get; init; }
|
||||
|
||||
public short SignalStrength { get; init; }
|
||||
|
||||
public string Alias
|
||||
{
|
||||
get => _alias;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _alias, value ?? string.Empty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
OnPropertyChanged(nameof(SecondaryLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsControlTarget
|
||||
{
|
||||
get => _isControlTarget;
|
||||
set => SetProperty(ref _isControlTarget, value);
|
||||
}
|
||||
|
||||
public DeviceConnectionStatus ConnectionStatus
|
||||
{
|
||||
get => _connectionStatus;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _connectionStatus, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_statusBrush = CreateFrozenBrush(GetStatusColor(value));
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get => _statusText;
|
||||
set => SetProperty(ref _statusText, value);
|
||||
}
|
||||
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(Alias)
|
||||
? NativeName
|
||||
: Alias;
|
||||
|
||||
public string SecondaryLabel => string.IsNullOrWhiteSpace(Alias)
|
||||
? BluetoothAddress
|
||||
: $"{NativeName} - {BluetoothAddress}";
|
||||
|
||||
public string SignalLabel => SignalStrength == short.MinValue
|
||||
? "RSSI bilinmiyor"
|
||||
: $"RSSI {SignalStrength} dBm";
|
||||
|
||||
public string StatusLabel => ConnectionStatus switch
|
||||
{
|
||||
DeviceConnectionStatus.Connecting => "Bağlanıyor",
|
||||
DeviceConnectionStatus.Connected => "Hazır",
|
||||
DeviceConnectionStatus.Faulted => "Hata",
|
||||
DeviceConnectionStatus.Disconnected => "Beklemede",
|
||||
_ => "Boş"
|
||||
};
|
||||
|
||||
public MediaBrush StatusBrush => _statusBrush;
|
||||
|
||||
private static MediaColor GetStatusColor(DeviceConnectionStatus status)
|
||||
=> status switch
|
||||
{
|
||||
DeviceConnectionStatus.Connecting => MediaColor.FromRgb(59, 130, 246),
|
||||
DeviceConnectionStatus.Connected => MediaColor.FromRgb(16, 185, 129),
|
||||
DeviceConnectionStatus.Faulted => MediaColor.FromRgb(244, 63, 94),
|
||||
DeviceConnectionStatus.Disconnected => MediaColor.FromRgb(245, 158, 11),
|
||||
_ => MediaColor.FromRgb(107, 114, 128)
|
||||
};
|
||||
|
||||
private static MediaBrush CreateFrozenBrush(MediaColor color)
|
||||
{
|
||||
var brush = new SolidColorBrush(color);
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ControllerState : ObservableObject
|
||||
{
|
||||
private DeviceConnectionStatus _connectionStatus = DeviceConnectionStatus.Disconnected;
|
||||
private bool _isPoweredOn;
|
||||
private LightingMode _currentMode = LightingMode.Rgb;
|
||||
private MediaColor _selectedColor = MediaColor.FromRgb(255, 106, 78);
|
||||
private byte _brightness = 100;
|
||||
private byte _effectSpeed = 50;
|
||||
private byte _grayscaleLevel = 100;
|
||||
private byte _temperatureLevel = 50;
|
||||
private byte _selectedEffectId;
|
||||
private int _connectedDeviceCount;
|
||||
private int _controlTargetCount;
|
||||
private string _statusText = "Tarama için hazır.";
|
||||
private MediaBrush _statusBrush = CreateFrozenBrush(MediaColor.FromRgb(107, 114, 128));
|
||||
private MediaBrush _selectedBrush = CreateFrozenBrush(MediaColor.FromRgb(255, 106, 78));
|
||||
|
||||
public DeviceConnectionStatus ConnectionStatus
|
||||
{
|
||||
get => _connectionStatus;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _connectionStatus, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_statusBrush = CreateFrozenBrush(GetStatusColor(value));
|
||||
OnPropertyChanged(nameof(ConnectionLabel));
|
||||
OnPropertyChanged(nameof(StatusBrush));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPoweredOn
|
||||
{
|
||||
get => _isPoweredOn;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _isPoweredOn, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(PowerButtonLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public LightingMode CurrentMode
|
||||
{
|
||||
get => _currentMode;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _currentMode, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(CurrentModeLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public MediaColor SelectedColor
|
||||
{
|
||||
get => _selectedColor;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selectedColor, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedBrush = CreateFrozenBrush(value);
|
||||
OnPropertyChanged(nameof(SelectedBrush));
|
||||
OnPropertyChanged(nameof(SelectedHex));
|
||||
OnPropertyChanged(nameof(SelectedRgbLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public byte Brightness
|
||||
{
|
||||
get => _brightness;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _brightness, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(BrightnessLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public byte EffectSpeed
|
||||
{
|
||||
get => _effectSpeed;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _effectSpeed, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(EffectSpeedLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public byte GrayscaleLevel
|
||||
{
|
||||
get => _grayscaleLevel;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _grayscaleLevel, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(GrayscaleLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public byte TemperatureLevel
|
||||
{
|
||||
get => _temperatureLevel;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _temperatureLevel, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(TemperatureLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public byte SelectedEffectId
|
||||
{
|
||||
get => _selectedEffectId;
|
||||
set => SetProperty(ref _selectedEffectId, value);
|
||||
}
|
||||
|
||||
public int ConnectedDeviceCount
|
||||
{
|
||||
get => _connectedDeviceCount;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _connectedDeviceCount, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ConnectedSummary));
|
||||
}
|
||||
}
|
||||
|
||||
public int ControlTargetCount
|
||||
{
|
||||
get => _controlTargetCount;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _controlTargetCount, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(TargetSummary));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get => _statusText;
|
||||
set => SetProperty(ref _statusText, value);
|
||||
}
|
||||
|
||||
public string ConnectionLabel => ConnectionStatus switch
|
||||
{
|
||||
DeviceConnectionStatus.Scanning => "Taranıyor",
|
||||
DeviceConnectionStatus.Connecting => "Bağlantı kuruluyor",
|
||||
DeviceConnectionStatus.Connected => "Hazır",
|
||||
DeviceConnectionStatus.Faulted => "Dikkat gerekiyor",
|
||||
_ => "Beklemede"
|
||||
};
|
||||
|
||||
public MediaBrush StatusBrush => _statusBrush;
|
||||
|
||||
public MediaBrush SelectedBrush => _selectedBrush;
|
||||
|
||||
public string SelectedHex => $"#{SelectedColor.R:X2}{SelectedColor.G:X2}{SelectedColor.B:X2}";
|
||||
|
||||
public string SelectedRgbLabel => $"RGB {SelectedColor.R}, {SelectedColor.G}, {SelectedColor.B}";
|
||||
|
||||
public string BrightnessLabel => $"%{Brightness}";
|
||||
|
||||
public string EffectSpeedLabel => $"%{EffectSpeed}";
|
||||
|
||||
public string GrayscaleLabel => $"%{GrayscaleLevel}";
|
||||
|
||||
public string TemperatureLabel => $"%{TemperatureLevel}";
|
||||
|
||||
public string CurrentModeLabel => CurrentMode switch
|
||||
{
|
||||
LightingMode.Effect => "Efekt",
|
||||
LightingMode.Grayscale => "Gri ton",
|
||||
LightingMode.Temperature => "Beyaz sıcaklığı",
|
||||
_ => "RGB"
|
||||
};
|
||||
|
||||
public string ConnectedSummary => ConnectedDeviceCount == 0
|
||||
? "Bağlı cihaz yok"
|
||||
: $"{ConnectedDeviceCount} cihaz hazır";
|
||||
|
||||
public string TargetSummary => ControlTargetCount == 0
|
||||
? "Komut alacak cihaz seçilmedi"
|
||||
: $"{ControlTargetCount} cihaz komut alacak";
|
||||
|
||||
public string PowerButtonLabel => IsPoweredOn ? "Hedefleri kapat" : "Hedefleri aç";
|
||||
|
||||
private static MediaColor GetStatusColor(DeviceConnectionStatus status)
|
||||
=> status switch
|
||||
{
|
||||
DeviceConnectionStatus.Scanning => MediaColor.FromRgb(56, 189, 248),
|
||||
DeviceConnectionStatus.Connecting => MediaColor.FromRgb(96, 165, 250),
|
||||
DeviceConnectionStatus.Connected => MediaColor.FromRgb(16, 185, 129),
|
||||
DeviceConnectionStatus.Faulted => MediaColor.FromRgb(244, 63, 94),
|
||||
_ => MediaColor.FromRgb(107, 114, 128)
|
||||
};
|
||||
|
||||
private static MediaBrush CreateFrozenBrush(MediaColor color)
|
||||
{
|
||||
var brush = new SolidColorBrush(color);
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
<Window x:Class="RgbLedStripController.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Title="RGB Led Strip Controller"
|
||||
Height="800"
|
||||
Width="1320"
|
||||
MinWidth="1040"
|
||||
MinHeight="700"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Window.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
|
||||
|
||||
<DataTemplate x:Key="EffectOptionTemplate">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="10" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Padding="8,4"
|
||||
Background="#18FFFFFF"
|
||||
CornerRadius="8">
|
||||
<TextBlock FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding Code}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</Window.Resources>
|
||||
|
||||
<Border Margin="12"
|
||||
Padding="24"
|
||||
Background="#FF0D131B"
|
||||
BorderBrush="#18FFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="22"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="16" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="12" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" Style="{StaticResource CardBorderStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionTitleStyle}"
|
||||
FontSize="30"
|
||||
Text="RGB Led Strip Controller" />
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
FontSize="14"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Windows 10 için sade, çok fonksiyonlu multi ELK-BLEDOM protokolüne uygun rgb led şerit kontrolcüsü." />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Margin="24,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border HorizontalAlignment="Right"
|
||||
Padding="12,6"
|
||||
CornerRadius="12"
|
||||
Background="{Binding State.StatusBrush}">
|
||||
<TextBlock FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="Black"
|
||||
Text="{Binding State.ConnectionLabel}" />
|
||||
</Border>
|
||||
<TextBlock Margin="0,12,0,0"
|
||||
HorizontalAlignment="Right"
|
||||
FontSize="13"
|
||||
Text="{Binding State.ConnectedSummary}" />
|
||||
<TextBlock Margin="0,4,0,0"
|
||||
HorizontalAlignment="Right"
|
||||
FontSize="13"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding State.TargetSummary}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="320" />
|
||||
<ColumnDefinition Width="18" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Style="{StaticResource CardBorderStyle}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="14" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="14" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="12" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="12" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource SectionTitleStyle}"
|
||||
FontSize="22"
|
||||
Text="Cihazlar" />
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Click="ScanClicked"
|
||||
IsEnabled="{Binding CanScan}">
|
||||
<TextBlock FontSize="13" Text="Tara" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="2"
|
||||
ItemsSource="{Binding Devices}"
|
||||
SelectedItem="{Binding SelectedDevice, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Margin="12,0,0,0"
|
||||
Padding="8,3"
|
||||
CornerRadius="8"
|
||||
Background="#213CC7FF"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentBrush}"
|
||||
Text="{Binding Badge}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="1" Margin="0,8,0,0">
|
||||
<TextBlock FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding SecondaryLabel}" />
|
||||
<TextBlock Margin="0,3,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding SignalLabel}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Border Grid.Row="4" Style="{StaticResource PanelCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Seçili cihaz" />
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding SelectedDeviceSummaryDisplay}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Margin="0,10,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding LastKnownDeviceCaptionDisplay}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding AliasEditorText, UpdateSourceTrigger=PropertyChanged}" />
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Click="SaveAliasClicked"
|
||||
IsEnabled="{Binding CanSaveAlias}">
|
||||
<TextBlock FontSize="13" Text="Takma adı kaydet" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Button Grid.Column="0"
|
||||
Click="ConnectClicked"
|
||||
IsEnabled="{Binding CanConnect}">
|
||||
<TextBlock FontSize="14" Text="Bağlan" />
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource GhostButtonStyle}"
|
||||
Click="DisconnectAllClicked"
|
||||
IsEnabled="{Binding CanDisconnectAll}">
|
||||
<TextBlock FontSize="14" Text="Tümünü kes" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="2">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="16" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="14" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource SectionTitleStyle}"
|
||||
FontSize="22"
|
||||
Text="Bağlı cihazlar" />
|
||||
|
||||
<ListBox Grid.Row="2"
|
||||
ItemsSource="{Binding ConnectedDevices}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="182" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Margin="12,0,0,0"
|
||||
Padding="8,3"
|
||||
CornerRadius="8"
|
||||
Background="{Binding StatusBrush}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock FontSize="11"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="Black"
|
||||
Text="{Binding StatusLabel}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding SecondaryLabel}" />
|
||||
<TextBlock Margin="0,3,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding SignalLabel}" />
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
FontSize="13"
|
||||
Text="{Binding StatusText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center">
|
||||
<CheckBox Content="Komuta dahil"
|
||||
IsChecked="{Binding IsControlTarget, Mode=TwoWay}"
|
||||
Margin="0,0,0,10"
|
||||
Style="{StaticResource TargetCheckBoxStyle}" />
|
||||
<Button Style="{StaticResource DangerGhostButtonStyle}"
|
||||
Click="DisconnectConnectedClicked"
|
||||
Tag="{Binding}">
|
||||
<TextBlock FontSize="13" Text="Bağlantıyı kes" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2" Style="{StaticResource CardBorderStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="1.15*" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="0.95*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Style="{StaticResource PanelCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionTitleStyle}"
|
||||
FontSize="20"
|
||||
Text="Güç ve parlaklık" />
|
||||
<Button Margin="0,16,0,0"
|
||||
Click="PowerClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="14" Text="{Binding State.PowerButtonLabel}" />
|
||||
</Button>
|
||||
<TextBlock Margin="0,22,0,0"
|
||||
FontSize="13"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Parlaklık" />
|
||||
<DockPanel Margin="0,6,0,0">
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding State.BrightnessLabel}" />
|
||||
</DockPanel>
|
||||
<Slider x:Name="BrightnessSlider"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
TickFrequency="5"
|
||||
Value="{Binding State.Brightness}"
|
||||
IsEnabled="{Binding CanUseBrightnessControl}"
|
||||
LostMouseCapture="BrightnessCommitLostMouseCapture"
|
||||
PreviewKeyUp="BrightnessCommitKeyUp"
|
||||
PreviewMouseLeftButtonUp="BrightnessCommitMouseUp"
|
||||
ValueChanged="BrightnessChanged" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Style="{StaticResource PanelCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionTitleStyle}"
|
||||
FontSize="20"
|
||||
Text="Renk" />
|
||||
<StackPanel Orientation="Horizontal" Margin="0,16,0,0">
|
||||
<Border Width="74"
|
||||
Height="74"
|
||||
CornerRadius="12"
|
||||
Background="{Binding State.SelectedBrush}" />
|
||||
<StackPanel Margin="14,4,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding State.SelectedHex}" />
|
||||
<TextBlock Margin="0,4,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding State.SelectedRgbLabel}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Margin="0,18,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Hızlı renkler" />
|
||||
<ItemsControl Margin="0,14,0,0"
|
||||
ItemsSource="{Binding ColorPresets}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="7" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Width="38"
|
||||
Height="38"
|
||||
Margin="0,0,10,10"
|
||||
Background="{Binding Hex}"
|
||||
Tag="{Binding Hex}"
|
||||
ToolTip="{Binding ToolTipLabel}"
|
||||
Click="PresetColorClicked"
|
||||
Style="{StaticResource SwatchButtonStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Margin="0,4,0,0"
|
||||
Style="{StaticResource WarmGhostButtonStyle}"
|
||||
Click="SelectColorClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="14" Text="Renk seçici" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="4" Style="{StaticResource PanelCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource SectionTitleStyle}"
|
||||
FontSize="20"
|
||||
Text="Mod" />
|
||||
<TextBlock Margin="0,16,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Aktif mod" />
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding State.CurrentModeLabel}" />
|
||||
<UniformGrid Margin="0,14,0,0"
|
||||
Columns="4">
|
||||
<Button Margin="0,0,8,0"
|
||||
Click="SwitchRgbModeClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="13" Text="RGB" />
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Click="SwitchEffectModeClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="13" Text="Efekt" />
|
||||
</Button>
|
||||
<Button Click="SwitchGrayscaleModeClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="13" Text="Gri ton" />
|
||||
</Button>
|
||||
<Button Margin="8,0,0,0"
|
||||
Click="SwitchTemperatureModeClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="13" Text="Beyaz" />
|
||||
</Button>
|
||||
</UniformGrid>
|
||||
<StackPanel Margin="0,18,0,0"
|
||||
Visibility="{Binding CanShowEffectControls, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||
<TextBlock FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Hazır efekt" />
|
||||
<ComboBox x:Name="EffectsComboBox"
|
||||
Margin="0,10,0,0"
|
||||
Style="{StaticResource EffectComboBoxStyle}"
|
||||
ItemTemplate="{StaticResource EffectOptionTemplate}"
|
||||
ItemsSource="{Binding Effects}"
|
||||
SelectedValuePath="PatternId"
|
||||
SelectedValue="{Binding State.SelectedEffectId}"
|
||||
SelectionChanged="EffectChanged"
|
||||
IsEnabled="{Binding CanUseControls}" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,18,0,0"
|
||||
Visibility="{Binding CanShowEffectSpeed, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||
<TextBlock FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Efekt hızı" />
|
||||
<DockPanel Margin="0,6,0,0">
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding State.EffectSpeedLabel}" />
|
||||
</DockPanel>
|
||||
<Slider x:Name="EffectSpeedSlider"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
TickFrequency="5"
|
||||
Value="{Binding State.EffectSpeed}"
|
||||
IsEnabled="{Binding CanUseControls}"
|
||||
LostMouseCapture="EffectSpeedCommitLostMouseCapture"
|
||||
PreviewKeyUp="EffectSpeedCommitKeyUp"
|
||||
PreviewMouseLeftButtonUp="EffectSpeedCommitMouseUp"
|
||||
ValueChanged="EffectSpeedChanged" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,18,0,0"
|
||||
Visibility="{Binding CanShowGrayscaleControls, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||
<TextBlock FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Gri ton" />
|
||||
<DockPanel Margin="0,6,0,0">
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding State.GrayscaleLabel}" />
|
||||
</DockPanel>
|
||||
<Slider x:Name="GrayscaleSlider"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
TickFrequency="5"
|
||||
Value="{Binding State.GrayscaleLevel}"
|
||||
IsEnabled="{Binding CanUseControls}"
|
||||
LostMouseCapture="GrayscaleCommitLostMouseCapture"
|
||||
PreviewKeyUp="GrayscaleCommitKeyUp"
|
||||
PreviewMouseLeftButtonUp="GrayscaleCommitMouseUp"
|
||||
ValueChanged="GrayscaleChanged" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,18,0,0"
|
||||
Visibility="{Binding CanShowTemperatureControls, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||
<TextBlock FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Beyaz sıcaklığı" />
|
||||
<DockPanel Margin="0,6,0,0">
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding State.TemperatureLabel}" />
|
||||
</DockPanel>
|
||||
<Slider x:Name="TemperatureSlider"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
TickFrequency="5"
|
||||
Value="{Binding State.TemperatureLevel}"
|
||||
IsEnabled="{Binding CanUseControls}"
|
||||
LostMouseCapture="TemperatureCommitLostMouseCapture"
|
||||
PreviewKeyUp="TemperatureCommitKeyUp"
|
||||
PreviewMouseLeftButtonUp="TemperatureCommitMouseUp"
|
||||
ValueChanged="TemperatureChanged" />
|
||||
<UniformGrid Margin="0,14,0,0"
|
||||
Columns="3">
|
||||
<Button Margin="0,0,8,0"
|
||||
Tag="0"
|
||||
Click="TemperaturePresetClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="12" Text="Soğuk" />
|
||||
</Button>
|
||||
<Button Margin="0,0,8,0"
|
||||
Tag="50"
|
||||
Click="TemperaturePresetClicked"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="12" Text="Doğal" />
|
||||
</Button>
|
||||
<Button Click="TemperaturePresetClicked"
|
||||
Tag="100"
|
||||
IsEnabled="{Binding CanUseControls}">
|
||||
<TextBlock FontSize="12" Text="Sıcak" />
|
||||
</Button>
|
||||
</UniformGrid>
|
||||
</StackPanel>
|
||||
<TextBlock Margin="0,20,0,0"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="Durum" />
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding State.StatusText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="4"
|
||||
Padding="14,12"
|
||||
Background="#99111822"
|
||||
BorderBrush="#18FFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}">
|
||||
<Run Text="Made by " />
|
||||
<Hyperlink NavigateUri="https://fristy.net"
|
||||
RequestNavigate="FooterLinkClicked">
|
||||
<Run Text="@fristyxd" />
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding VersionDisplay}" />
|
||||
<TextBlock Margin="0,4,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{StaticResource MutedTextBrush}"
|
||||
Text="{Binding UpdateStatusText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="4"
|
||||
Click="CheckForUpdatesClicked"
|
||||
IsEnabled="{Binding CanCheckForUpdates}">
|
||||
<TextBlock FontSize="13"
|
||||
Text="{Binding UpdateButtonLabel}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,860 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Navigation;
|
||||
using Forms = System.Windows.Forms;
|
||||
using RgbLedStripController.Core;
|
||||
using RgbLedStripController.Services;
|
||||
using Velopack;
|
||||
using Velopack.Sources;
|
||||
using MediaColor = System.Windows.Media.Color;
|
||||
using WpfButton = System.Windows.Controls.Button;
|
||||
|
||||
namespace RgbLedStripController;
|
||||
|
||||
public partial class MainWindow : Window, INotifyPropertyChanged
|
||||
{
|
||||
private const string GiteaRepoUrl = "https://git.fristy.net/fristy/rgb-led-strip-controller";
|
||||
private readonly MultiDeviceController _controller;
|
||||
private bool _effectSpeedCommitPending;
|
||||
private bool _grayscaleCommitPending;
|
||||
private bool _isBusy;
|
||||
private bool _isCheckingForUpdates;
|
||||
private bool _brightnessCommitPending;
|
||||
private bool _temperatureCommitPending;
|
||||
private bool _isInitializing = true;
|
||||
private bool _updatePendingRestart;
|
||||
private BleScanResult? _selectedDevice;
|
||||
private string _aliasEditorText = string.Empty;
|
||||
private string _updateStatusText = "G\u00fcncelleme kontrol\u00fc haz\u0131r.";
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
TrySetWindowIcon();
|
||||
|
||||
State = new ControllerState();
|
||||
Effects = ElkBledomProtocol.CreateEffectOptions();
|
||||
ColorPresets = CreateColorPresets();
|
||||
Devices = new ObservableCollection<BleScanResult>();
|
||||
ConnectedDevices = new ObservableCollection<ConnectedDeviceViewModel>();
|
||||
ConnectedDevices.CollectionChanged += HandleConnectedDevicesCollectionChanged;
|
||||
|
||||
_controller = new MultiDeviceController(new SettingsStore(), State);
|
||||
State.PropertyChanged += HandleStateChanged;
|
||||
|
||||
DataContext = this;
|
||||
Loaded += HandleLoaded;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public ControllerState State { get; }
|
||||
|
||||
public ObservableCollection<BleScanResult> Devices { get; }
|
||||
|
||||
public ObservableCollection<ConnectedDeviceViewModel> ConnectedDevices { get; }
|
||||
|
||||
public IReadOnlyList<EffectOption> Effects { get; }
|
||||
|
||||
public IReadOnlyList<ColorPreset> ColorPresets { get; }
|
||||
|
||||
public string VersionDisplay { get; } = $"v{GetApplicationVersion()}";
|
||||
|
||||
public string UpdateStatusText
|
||||
{
|
||||
get => _updateStatusText;
|
||||
private set => SetProperty(ref _updateStatusText, value);
|
||||
}
|
||||
|
||||
public bool CanCheckForUpdates => !_isCheckingForUpdates;
|
||||
|
||||
public string UpdateButtonLabel => _isCheckingForUpdates
|
||||
? "Kontrol ediliyor..."
|
||||
: _updatePendingRestart
|
||||
? "G\u00fcncellemeyi uygula"
|
||||
: "G\u00fcncellemeleri kontrol et";
|
||||
|
||||
public BleScanResult? SelectedDevice
|
||||
{
|
||||
get => _selectedDevice;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selectedDevice, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AliasEditorText = value?.Alias ?? string.Empty;
|
||||
NotifySelectedDeviceChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string AliasEditorText
|
||||
{
|
||||
get => _aliasEditorText;
|
||||
set => SetProperty(ref _aliasEditorText, value);
|
||||
}
|
||||
|
||||
public bool CanScan => !_isBusy;
|
||||
|
||||
public bool CanConnect => !_isBusy
|
||||
&& SelectedDevice is not null
|
||||
&& ConnectedDevices.All(device => !string.Equals(device.BluetoothAddress, SelectedDevice.BluetoothAddress, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public bool CanSaveAlias => !_isBusy && SelectedDevice is not null;
|
||||
|
||||
public bool CanUseControls => !_isBusy && State.ControlTargetCount > 0;
|
||||
|
||||
public bool CanUseBrightnessControl => CanUseControls && State.CurrentMode != LightingMode.Grayscale;
|
||||
|
||||
public bool CanDisconnectAll => !_isBusy && ConnectedDevices.Count > 0;
|
||||
|
||||
public bool CanShowEffectControls => State.CurrentMode == LightingMode.Effect;
|
||||
|
||||
public bool CanShowEffectSpeed => CanShowEffectControls && Effects.Any(item => item.PatternId == State.SelectedEffectId && item.SupportsSpeed);
|
||||
|
||||
public bool CanShowGrayscaleControls => State.CurrentMode == LightingMode.Grayscale;
|
||||
|
||||
public bool CanShowTemperatureControls => State.CurrentMode == LightingMode.Temperature;
|
||||
|
||||
public string LastKnownDeviceCaptionDisplay => _controller.LastKnownDevice is null
|
||||
? "Henüz kayıtlı cihaz yok."
|
||||
: $"Son kayıt: {_controller.LastKnownDevice.LocalAlias} ({_controller.LastKnownDevice.BluetoothAddress})";
|
||||
|
||||
public string SelectedDeviceSummaryDisplay => SelectedDevice is null
|
||||
? "Bir cihaz seç."
|
||||
: $"{SelectedDevice.DisplayName} - {SelectedDevice.BluetoothAddress}";
|
||||
|
||||
public string LastKnownDeviceCaption => _controller.LastKnownDevice is null
|
||||
? "Henüz cihaz hafızası yok."
|
||||
: $"{_controller.LastKnownDevice.LocalAlias} ({_controller.LastKnownDevice.BluetoothAddress})";
|
||||
|
||||
public string SelectedDeviceSummary => SelectedDevice is null
|
||||
? "Seçim yapılmadı."
|
||||
: $"{SelectedDevice.DisplayName} - {SelectedDevice.BluetoothAddress}";
|
||||
|
||||
protected override async void OnClosed(EventArgs e)
|
||||
{
|
||||
State.PropertyChanged -= HandleStateChanged;
|
||||
ConnectedDevices.CollectionChanged -= HandleConnectedDevicesCollectionChanged;
|
||||
await _controller.DisposeAsync();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private async void HandleLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
await _controller.InitializeAsync();
|
||||
EffectsComboBox.SelectedValue = State.SelectedEffectId;
|
||||
BrightnessSlider.Value = State.Brightness;
|
||||
EffectSpeedSlider.Value = State.EffectSpeed;
|
||||
GrayscaleSlider.Value = State.GrayscaleLevel;
|
||||
TemperatureSlider.Value = State.TemperatureLevel;
|
||||
NotifyLastKnownDeviceChanged();
|
||||
}, treatErrorAsFault: true);
|
||||
|
||||
_isInitializing = false;
|
||||
_ = CheckForUpdatesAsync(isManualCheck: false);
|
||||
}
|
||||
|
||||
private async void ScanClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
var scanned = await _controller.ScanForDevicesAsync(TimeSpan.FromSeconds(10));
|
||||
Devices.Clear();
|
||||
foreach (var device in scanned)
|
||||
{
|
||||
Devices.Add(device);
|
||||
}
|
||||
|
||||
SelectedDevice = Devices.FirstOrDefault(device => device.IsLastConnected) ?? Devices.FirstOrDefault();
|
||||
});
|
||||
}
|
||||
|
||||
private async void ConnectClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SelectedDevice is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
var connected = await _controller.ConnectAsync(SelectedDevice);
|
||||
if (ConnectedDevices.All(device => !string.Equals(device.BluetoothAddress, connected.BluetoothAddress, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
ConnectedDevices.Add(connected);
|
||||
}
|
||||
|
||||
SelectedDevice.IsLastConnected = true;
|
||||
NotifyLastKnownDeviceChanged();
|
||||
}, treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void SaveAliasClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SelectedDevice is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
await _controller.SetAliasAsync(SelectedDevice, AliasEditorText);
|
||||
NotifySelectedDeviceChanged();
|
||||
NotifyLastKnownDeviceChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async void DisconnectConnectedClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not WpfButton { Tag: ConnectedDeviceViewModel device })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
await _controller.DisconnectAsync(device.BluetoothAddress);
|
||||
ConnectedDevices.Remove(device);
|
||||
});
|
||||
}
|
||||
|
||||
private async void DisconnectAllClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
await _controller.DisconnectAllAsync();
|
||||
ConnectedDevices.Clear();
|
||||
});
|
||||
}
|
||||
|
||||
private async void PowerClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyPowerAsync(!State.IsPoweredOn),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void SelectColorClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!CanUseControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var colorDialog = new Forms.ColorDialog
|
||||
{
|
||||
FullOpen = true,
|
||||
Color = System.Drawing.Color.FromArgb(State.SelectedColor.R, State.SelectedColor.G, State.SelectedColor.B)
|
||||
};
|
||||
|
||||
if (colorDialog.ShowDialog() != Forms.DialogResult.OK)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var color = MediaColor.FromRgb(colorDialog.Color.R, colorDialog.Color.G, colorDialog.Color.B);
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.QueueColorAsync(color),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void PresetColorClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!CanUseControls || sender is not WpfButton { Tag: string tag })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var color = ParseHexColor(tag);
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.QueueColorAsync(color),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private void BrightnessChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State.Brightness = (byte)Math.Round(e.NewValue);
|
||||
if (!CanUseBrightnessControl)
|
||||
{
|
||||
_brightnessCommitPending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_brightnessCommitPending = true;
|
||||
}
|
||||
|
||||
private async void BrightnessCommitMouseUp(object sender, MouseButtonEventArgs e)
|
||||
=> await CommitBrightnessIfPendingAsync();
|
||||
|
||||
private async void BrightnessCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
=> await CommitBrightnessIfPendingAsync();
|
||||
|
||||
private async void BrightnessCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
=> await CommitBrightnessIfPendingAsync();
|
||||
|
||||
private async void EffectChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EffectsComboBox.SelectedValue is not byte patternId || !CanUseControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State.SelectedEffectId = patternId;
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyEffectAsync(patternId),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void SwitchRgbModeClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!CanUseControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.QueueColorAsync(State.SelectedColor),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void SwitchEffectModeClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!CanUseControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyEffectAsync(State.SelectedEffectId),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void SwitchGrayscaleModeClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!CanUseControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyGrayscaleModeAsync(),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void SwitchTemperatureModeClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!CanUseControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyTemperatureModeAsync(),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private void EffectSpeedChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State.EffectSpeed = (byte)Math.Round(e.NewValue);
|
||||
if (!CanUseControls || !CanShowEffectSpeed)
|
||||
{
|
||||
_effectSpeedCommitPending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_effectSpeedCommitPending = true;
|
||||
}
|
||||
|
||||
private async void EffectSpeedCommitMouseUp(object sender, MouseButtonEventArgs e)
|
||||
=> await CommitEffectSpeedIfPendingAsync();
|
||||
|
||||
private async void EffectSpeedCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
=> await CommitEffectSpeedIfPendingAsync();
|
||||
|
||||
private async void EffectSpeedCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
=> await CommitEffectSpeedIfPendingAsync();
|
||||
|
||||
private void GrayscaleChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State.GrayscaleLevel = (byte)Math.Round(e.NewValue);
|
||||
if (!CanUseControls || !CanShowGrayscaleControls)
|
||||
{
|
||||
_grayscaleCommitPending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_grayscaleCommitPending = true;
|
||||
}
|
||||
|
||||
private async void GrayscaleCommitMouseUp(object sender, MouseButtonEventArgs e)
|
||||
=> await CommitGrayscaleIfPendingAsync();
|
||||
|
||||
private async void GrayscaleCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
=> await CommitGrayscaleIfPendingAsync();
|
||||
|
||||
private async void GrayscaleCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
=> await CommitGrayscaleIfPendingAsync();
|
||||
|
||||
private void TemperatureChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State.TemperatureLevel = (byte)Math.Round(e.NewValue);
|
||||
if (!CanUseControls || !CanShowTemperatureControls)
|
||||
{
|
||||
_temperatureCommitPending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_temperatureCommitPending = true;
|
||||
}
|
||||
|
||||
private async void TemperatureCommitMouseUp(object sender, MouseButtonEventArgs e)
|
||||
=> await CommitTemperatureIfPendingAsync();
|
||||
|
||||
private async void TemperatureCommitLostMouseCapture(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
=> await CommitTemperatureIfPendingAsync();
|
||||
|
||||
private async void TemperatureCommitKeyUp(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
=> await CommitTemperatureIfPendingAsync();
|
||||
|
||||
private async void TemperaturePresetClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!CanUseControls || sender is not WpfButton { Tag: var rawTag } || !byte.TryParse(rawTag?.ToString(), out var level))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State.TemperatureLevel = level;
|
||||
TemperatureSlider.Value = level;
|
||||
_temperatureCommitPending = false;
|
||||
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyTemperatureLevelAsync(level),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async void CheckForUpdatesClicked(object sender, RoutedEventArgs e)
|
||||
=> await CheckForUpdatesAsync(isManualCheck: true);
|
||||
|
||||
private async Task RunBusyAsync(Func<Task> action, bool useBusyFlag = true, bool treatErrorAsFault = false)
|
||||
{
|
||||
if (useBusyFlag && _isBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (useBusyFlag)
|
||||
{
|
||||
_isBusy = true;
|
||||
NotifyBusyDependentProperties();
|
||||
}
|
||||
|
||||
await action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (treatErrorAsFault)
|
||||
{
|
||||
State.ConnectionStatus = DeviceConnectionStatus.Faulted;
|
||||
}
|
||||
|
||||
State.StatusText = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (useBusyFlag)
|
||||
{
|
||||
_isBusy = false;
|
||||
NotifyBusyDependentProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStateChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(e.PropertyName)
|
||||
|| string.Equals(e.PropertyName, nameof(ControllerState.ControlTargetCount), StringComparison.Ordinal))
|
||||
{
|
||||
OnPropertyChanged(nameof(CanUseControls));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(e.PropertyName)
|
||||
|| string.Equals(e.PropertyName, nameof(ControllerState.SelectedEffectId), StringComparison.Ordinal)
|
||||
|| string.Equals(e.PropertyName, nameof(ControllerState.CurrentMode), StringComparison.Ordinal))
|
||||
{
|
||||
if (!CanShowEffectSpeed)
|
||||
{
|
||||
_effectSpeedCommitPending = false;
|
||||
}
|
||||
|
||||
if (!CanShowGrayscaleControls)
|
||||
{
|
||||
_grayscaleCommitPending = false;
|
||||
}
|
||||
|
||||
if (!CanShowTemperatureControls)
|
||||
{
|
||||
_temperatureCommitPending = false;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(CanUseBrightnessControl));
|
||||
OnPropertyChanged(nameof(CanShowEffectControls));
|
||||
OnPropertyChanged(nameof(CanShowEffectSpeed));
|
||||
OnPropertyChanged(nameof(CanShowGrayscaleControls));
|
||||
OnPropertyChanged(nameof(CanShowTemperatureControls));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConnectedDevicesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanConnect));
|
||||
OnPropertyChanged(nameof(CanDisconnectAll));
|
||||
}
|
||||
|
||||
private void NotifyBusyDependentProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanScan));
|
||||
OnPropertyChanged(nameof(CanConnect));
|
||||
OnPropertyChanged(nameof(CanSaveAlias));
|
||||
OnPropertyChanged(nameof(CanUseControls));
|
||||
OnPropertyChanged(nameof(CanUseBrightnessControl));
|
||||
OnPropertyChanged(nameof(CanDisconnectAll));
|
||||
}
|
||||
|
||||
private void NotifySelectedDeviceChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanConnect));
|
||||
OnPropertyChanged(nameof(CanSaveAlias));
|
||||
OnPropertyChanged(nameof(SelectedDeviceSummary));
|
||||
OnPropertyChanged(nameof(SelectedDeviceSummaryDisplay));
|
||||
}
|
||||
|
||||
private void NotifyLastKnownDeviceChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(LastKnownDeviceCaption));
|
||||
OnPropertyChanged(nameof(LastKnownDeviceCaptionDisplay));
|
||||
}
|
||||
|
||||
private async Task CommitBrightnessIfPendingAsync()
|
||||
{
|
||||
if (!_brightnessCommitPending || _isInitializing || !CanUseBrightnessControl)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_brightnessCommitPending = false;
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.QueueBrightnessAsync(State.Brightness),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async Task CommitEffectSpeedIfPendingAsync()
|
||||
{
|
||||
if (!_effectSpeedCommitPending || _isInitializing || !CanUseControls || !CanShowEffectSpeed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_effectSpeedCommitPending = false;
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyEffectSpeedAsync(State.EffectSpeed),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async Task CommitGrayscaleIfPendingAsync()
|
||||
{
|
||||
if (!_grayscaleCommitPending || _isInitializing || !CanUseControls || !CanShowGrayscaleControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_grayscaleCommitPending = false;
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyGrayscaleLevelAsync(State.GrayscaleLevel),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private async Task CommitTemperatureIfPendingAsync()
|
||||
{
|
||||
if (!_temperatureCommitPending || _isInitializing || !CanUseControls || !CanShowTemperatureControls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_temperatureCommitPending = false;
|
||||
await RunBusyAsync(
|
||||
async () => await _controller.ApplyTemperatureLevelAsync(State.TemperatureLevel),
|
||||
useBusyFlag: false,
|
||||
treatErrorAsFault: true);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ColorPreset> CreateColorPresets()
|
||||
=> new[]
|
||||
{
|
||||
new ColorPreset("Kırmızı", "#FF0000"),
|
||||
new ColorPreset("Turuncu", "#FF9500"),
|
||||
new ColorPreset("Amber", "#FFCC00"),
|
||||
new ColorPreset("Sarı", "#FFD60A"),
|
||||
new ColorPreset("Lime", "#A8E10C"),
|
||||
new ColorPreset("Yeşil", "#34C759"),
|
||||
new ColorPreset("Turkuaz", "#30D5C8"),
|
||||
new ColorPreset("Camgöbeği", "#32ADE6"),
|
||||
new ColorPreset("Mavi", "#007AFF"),
|
||||
new ColorPreset("Mor", "#5856D6"),
|
||||
new ColorPreset("Macenta", "#FF00FF"),
|
||||
new ColorPreset("Pembe", "#FF2D55"),
|
||||
new ColorPreset("Buz mavisi", "#ADD8E6"),
|
||||
new ColorPreset("Sıcak beyaz", "#FDF4E3")
|
||||
};
|
||||
|
||||
private static MediaColor ParseHexColor(string hex)
|
||||
{
|
||||
var normalized = hex.TrimStart('#');
|
||||
if (normalized.Length != 6)
|
||||
{
|
||||
return MediaColor.FromRgb(255, 106, 78);
|
||||
}
|
||||
|
||||
return MediaColor.FromRgb(
|
||||
Convert.ToByte(normalized.Substring(0, 2), 16),
|
||||
Convert.ToByte(normalized.Substring(2, 2), 16),
|
||||
Convert.ToByte(normalized.Substring(4, 2), 16));
|
||||
}
|
||||
|
||||
private void FooterLinkClicked(object sender, RequestNavigateEventArgs e)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = e.Uri.AbsoluteUri,
|
||||
UseShellExecute = true
|
||||
});
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void TrySetWindowIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
Icon = BitmapFrame.Create(new Uri("pack://application:,,,/Assets/AppIcon.ico", UriKind.Absolute));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Window icon is cosmetic; startup should continue even if the icon resource is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesAsync(bool isManualCheck)
|
||||
{
|
||||
if (_isCheckingForUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_isCheckingForUpdates = true;
|
||||
NotifyUpdateProperties();
|
||||
|
||||
var updateManager = new UpdateManager(new GiteaSource(GiteaRepoUrl, null, false));
|
||||
if (!updateManager.IsInstalled)
|
||||
{
|
||||
_updatePendingRestart = false;
|
||||
UpdateStatusText = "Kurulu Velopack s\u00fcr\u00fcm\u00fc alg\u0131lanmad\u0131.";
|
||||
NotifyUpdateProperties();
|
||||
|
||||
if (isManualCheck)
|
||||
{
|
||||
System.Windows.MessageBox.Show(
|
||||
this,
|
||||
"G\u00fcncelleme denetimi sadece Setup.exe ile kurulan s\u00fcr\u00fcmlerde kullan\u0131labilir.",
|
||||
"G\u00fcncelleme",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateManager.UpdatePendingRestart is { } pendingAsset)
|
||||
{
|
||||
_updatePendingRestart = true;
|
||||
UpdateStatusText = "\u0130ndirilen g\u00fcncelleme uygulanmay\u0131 bekliyor.";
|
||||
NotifyUpdateProperties();
|
||||
|
||||
var applyPending = System.Windows.MessageBox.Show(
|
||||
this,
|
||||
"\u0130ndirilen bir g\u00fcncelleme haz\u0131r. Uygulay\u0131p uygulamay\u0131 yeniden ba\u015flatmak ister misin?",
|
||||
"G\u00fcncelleme haz\u0131r",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question);
|
||||
|
||||
if (applyPending == MessageBoxResult.Yes)
|
||||
{
|
||||
UpdateStatusText = "G\u00fcncelleme uygulan\u0131yor...";
|
||||
await updateManager.WaitExitThenApplyUpdatesAsync(pendingAsset, restart: true);
|
||||
Close();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_updatePendingRestart = false;
|
||||
UpdateStatusText = isManualCheck
|
||||
? "G\u00fcncellemeler kontrol ediliyor..."
|
||||
: "Ba\u015flang\u0131\u00e7 g\u00fcncelleme kontrol\u00fc yap\u0131l\u0131yor...";
|
||||
NotifyUpdateProperties();
|
||||
|
||||
var update = await updateManager.CheckForUpdatesAsync();
|
||||
if (update is null)
|
||||
{
|
||||
UpdateStatusText = "G\u00fcncel s\u00fcr\u00fcm kullan\u0131l\u0131yor.";
|
||||
if (isManualCheck)
|
||||
{
|
||||
System.Windows.MessageBox.Show(
|
||||
this,
|
||||
"Yeni bir s\u00fcr\u00fcm bulunamad\u0131.",
|
||||
"G\u00fcncelleme",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var targetVersion = update.TargetFullRelease.Version.ToString();
|
||||
UpdateStatusText = $"Yeni s\u00fcr\u00fcm haz\u0131r: v{targetVersion}";
|
||||
|
||||
var shouldApply = System.Windows.MessageBox.Show(
|
||||
this,
|
||||
$"v{targetVersion} bulundu. \u015eimdi indirip uygulamak ister misin?",
|
||||
"G\u00fcncelleme bulundu",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question);
|
||||
|
||||
if (shouldApply != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateStatusText = $"v{targetVersion} indiriliyor...";
|
||||
await updateManager.DownloadUpdatesAsync(update, progress =>
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
UpdateStatusText = $"v{targetVersion} indiriliyor... %{progress}";
|
||||
});
|
||||
});
|
||||
|
||||
_updatePendingRestart = true;
|
||||
UpdateStatusText = "G\u00fcncelleme uygulan\u0131yor...";
|
||||
NotifyUpdateProperties();
|
||||
await updateManager.WaitExitThenApplyUpdatesAsync(update.TargetFullRelease, restart: true);
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_updatePendingRestart = false;
|
||||
UpdateStatusText = $"G\u00fcncelleme hatas\u0131: {ex.Message}";
|
||||
NotifyUpdateProperties();
|
||||
|
||||
if (isManualCheck)
|
||||
{
|
||||
System.Windows.MessageBox.Show(
|
||||
this,
|
||||
ex.Message,
|
||||
"G\u00fcncelleme hatas\u0131",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isCheckingForUpdates = false;
|
||||
NotifyUpdateProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyUpdateProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanCheckForUpdates));
|
||||
OnPropertyChanged(nameof(UpdateButtonLabel));
|
||||
}
|
||||
|
||||
private static string GetApplicationVersion()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
if (!string.IsNullOrWhiteSpace(informationalVersion))
|
||||
{
|
||||
return informationalVersion;
|
||||
}
|
||||
|
||||
return assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
}
|
||||
|
||||
private bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<Version>0.3.0</Version>
|
||||
<AssemblyVersion>0.3.0.0</AssemblyVersion>
|
||||
<FileVersion>0.3.0.0</FileVersion>
|
||||
<InformationalVersion>0.3.0</InformationalVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<StartupObject>RgbLedStripController.App</StartupObject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Remove="App.xaml" />
|
||||
<Page Include="App.xaml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\AppIcon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Velopack" Version="0.0.1298" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,427 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using RgbLedStripController.Core;
|
||||
using Windows.Devices.Bluetooth;
|
||||
using Windows.Devices.Bluetooth.Advertisement;
|
||||
using Windows.Devices.Bluetooth.GenericAttributeProfile;
|
||||
using Windows.Devices.Enumeration;
|
||||
using Windows.Foundation;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace RgbLedStripController.Services;
|
||||
|
||||
public sealed class BleDeviceService : IBleTransport
|
||||
{
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly List<GattCharacteristic> _writeCharacteristics = [];
|
||||
private BleScanResult? _targetDevice;
|
||||
private BluetoothLEDevice? _device;
|
||||
private GattDeviceService? _service;
|
||||
private GattCharacteristic? _preferredCharacteristic;
|
||||
private bool _isManualDisconnect;
|
||||
|
||||
public event EventHandler<DeviceConnectionStatus>? ConnectionStatusChanged;
|
||||
|
||||
public async Task<IReadOnlyList<BleScanResult>> ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var captures = new ConcurrentDictionary<ulong, ScanCapture>();
|
||||
var activeDuration = duration <= TimeSpan.FromSeconds(4)
|
||||
? duration
|
||||
: TimeSpan.FromMilliseconds(duration.TotalMilliseconds * 0.65);
|
||||
var passiveDuration = duration - activeDuration;
|
||||
|
||||
await RunScanPassAsync(captures, BluetoothLEScanningMode.Active, activeDuration, cancellationToken);
|
||||
if (passiveDuration > TimeSpan.Zero)
|
||||
{
|
||||
await RunScanPassAsync(captures, BluetoothLEScanningMode.Passive, passiveDuration, cancellationToken);
|
||||
}
|
||||
|
||||
await MergeWindowsDeviceInventoryAsync(captures);
|
||||
|
||||
return captures.Values
|
||||
.Select(capture => new BleScanResult
|
||||
{
|
||||
DeviceId = FormatAddress(capture.Address),
|
||||
Name = string.IsNullOrWhiteSpace(capture.Name) ? "İsimsiz BLE cihazı" : capture.Name,
|
||||
BluetoothAddress = FormatAddress(capture.Address),
|
||||
AddressValue = capture.Address,
|
||||
IsKnownCandidate = IsKnownCandidate(capture.Name),
|
||||
IsLastConnected = false,
|
||||
SignalStrength = capture.SignalStrength,
|
||||
Alias = string.Empty
|
||||
})
|
||||
.OrderByDescending(item => item.IsKnownCandidate)
|
||||
.ThenByDescending(item => item.SignalStrength)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_targetDevice = device;
|
||||
_isManualDisconnect = false;
|
||||
RaiseConnectionStatus(DeviceConnectionStatus.Connecting);
|
||||
await EnsureCharacteristicReadyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DiscoverAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureCharacteristicReadyAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WriteAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureCharacteristicReadyAsync(cancellationToken);
|
||||
await WriteCoreAsync(payload, cancellationToken, allowRetry: true);
|
||||
}
|
||||
|
||||
public Task DisconnectAsync()
|
||||
{
|
||||
_isManualDisconnect = true;
|
||||
ClearDevice(clearTarget: true);
|
||||
RaiseConnectionStatus(DeviceConnectionStatus.Disconnected);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
_connectionLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task EnsureCharacteristicReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_targetDevice is null)
|
||||
{
|
||||
throw new InvalidOperationException("Target BLE device is not selected.");
|
||||
}
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_device is null)
|
||||
{
|
||||
await CreateDeviceAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (_device is null)
|
||||
{
|
||||
throw new InvalidOperationException("BluetoothLEDevice could not be opened.");
|
||||
}
|
||||
|
||||
if (_preferredCharacteristic is not null || _writeCharacteristics.Count > 0)
|
||||
{
|
||||
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_device.ConnectionStatus != BluetoothConnectionStatus.Connected)
|
||||
{
|
||||
await CreateDeviceAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var serviceResult = await _device.GetGattServicesForUuidAsync(
|
||||
ElkBledomProtocol.ServiceUuid,
|
||||
BluetoothCacheMode.Uncached);
|
||||
|
||||
if (serviceResult.Status != GattCommunicationStatus.Success || serviceResult.Services.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("fff0 service was not found.");
|
||||
}
|
||||
|
||||
ClearGatt();
|
||||
_service = serviceResult.Services[0];
|
||||
|
||||
var characteristicResult = await _service.GetCharacteristicsAsync(BluetoothCacheMode.Uncached);
|
||||
if (characteristicResult.Status != GattCommunicationStatus.Success || characteristicResult.Characteristics.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No writable BLE characteristic was found.");
|
||||
}
|
||||
|
||||
_writeCharacteristics.Clear();
|
||||
_writeCharacteristics.AddRange(
|
||||
characteristicResult.Characteristics
|
||||
.Where(IsWritableCharacteristic)
|
||||
.OrderByDescending(characteristic => characteristic.Uuid == ElkBledomProtocol.CharacteristicUuid));
|
||||
|
||||
if (_writeCharacteristics.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No writable BLE characteristic was found.");
|
||||
}
|
||||
|
||||
_preferredCharacteristic = _writeCharacteristics[0];
|
||||
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteCoreAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken, bool allowRetry)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_preferredCharacteristic is null && _writeCharacteristics.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("BLE characteristic is not ready.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = payload.ToArray();
|
||||
Exception? lastError = null;
|
||||
|
||||
foreach (var characteristic in EnumerateWriteTargets())
|
||||
{
|
||||
try
|
||||
{
|
||||
var writer = new DataWriter();
|
||||
writer.WriteBytes(payloadBytes);
|
||||
|
||||
var preferredOption = characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.WriteWithoutResponse)
|
||||
? GattWriteOption.WriteWithoutResponse
|
||||
: GattWriteOption.WriteWithResponse;
|
||||
|
||||
var status = await characteristic.WriteValueAsync(writer.DetachBuffer(), preferredOption);
|
||||
if (status != GattCommunicationStatus.Success)
|
||||
{
|
||||
lastError = new InvalidOperationException($"BLE write failed: {status}");
|
||||
continue;
|
||||
}
|
||||
|
||||
_preferredCharacteristic = characteristic;
|
||||
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new InvalidOperationException("BLE write failed.");
|
||||
}
|
||||
catch when (allowRetry)
|
||||
{
|
||||
ClearGatt();
|
||||
await EnsureCharacteristicReadyAsync(cancellationToken);
|
||||
await WriteCoreAsync(payload, cancellationToken, allowRetry: false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateDeviceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_targetDevice is null)
|
||||
{
|
||||
throw new InvalidOperationException("Target BLE device is not selected.");
|
||||
}
|
||||
|
||||
ClearDevice(clearTarget: false);
|
||||
|
||||
_device = await BluetoothLEDevice.FromBluetoothAddressAsync(_targetDevice.AddressValue);
|
||||
if (_device is null)
|
||||
{
|
||||
throw new InvalidOperationException("Bluetooth device could not be opened.");
|
||||
}
|
||||
|
||||
_device.ConnectionStatusChanged += HandleConnectionStatusChanged;
|
||||
}
|
||||
|
||||
private void HandleConnectionStatusChanged(BluetoothLEDevice sender, object args)
|
||||
{
|
||||
if (sender.ConnectionStatus == BluetoothConnectionStatus.Disconnected)
|
||||
{
|
||||
ClearGatt();
|
||||
|
||||
if (!_isManualDisconnect)
|
||||
{
|
||||
RaiseConnectionStatus(DeviceConnectionStatus.Disconnected);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_isManualDisconnect = false;
|
||||
RaiseConnectionStatus(DeviceConnectionStatus.Connected);
|
||||
}
|
||||
|
||||
private void ClearGatt()
|
||||
{
|
||||
_preferredCharacteristic = null;
|
||||
_writeCharacteristics.Clear();
|
||||
_service?.Dispose();
|
||||
_service = null;
|
||||
}
|
||||
|
||||
private void ClearDevice(bool clearTarget)
|
||||
{
|
||||
ClearGatt();
|
||||
|
||||
if (_device is not null)
|
||||
{
|
||||
_device.ConnectionStatusChanged -= HandleConnectionStatusChanged;
|
||||
_device.Dispose();
|
||||
_device = null;
|
||||
}
|
||||
|
||||
if (clearTarget)
|
||||
{
|
||||
_targetDevice = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseConnectionStatus(DeviceConnectionStatus status)
|
||||
=> ConnectionStatusChanged?.Invoke(this, status);
|
||||
|
||||
private static bool IsKnownCandidate(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = name.ToUpperInvariant();
|
||||
return normalized.Contains("ELK-BLEDOM")
|
||||
|| normalized.Contains("ELK-BLEDOB")
|
||||
|| normalized.Contains("LOTUS")
|
||||
|| normalized.Contains("DUOCO");
|
||||
}
|
||||
|
||||
private static async Task RunScanPassAsync(
|
||||
ConcurrentDictionary<ulong, ScanCapture> captures,
|
||||
BluetoothLEScanningMode mode,
|
||||
TimeSpan duration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var watcher = new BluetoothLEAdvertisementWatcher
|
||||
{
|
||||
ScanningMode = mode,
|
||||
AllowExtendedAdvertisements = true
|
||||
};
|
||||
|
||||
TypedEventHandler<BluetoothLEAdvertisementWatcher, BluetoothLEAdvertisementReceivedEventArgs> receivedHandler = (_, args) =>
|
||||
{
|
||||
captures.AddOrUpdate(
|
||||
args.BluetoothAddress,
|
||||
_ => new ScanCapture(args.BluetoothAddress, args.Advertisement.LocalName, args.RawSignalStrengthInDBm),
|
||||
(_, existing) => existing.Merge(args.Advertisement.LocalName, args.RawSignalStrengthInDBm));
|
||||
};
|
||||
|
||||
watcher.Received += receivedHandler;
|
||||
watcher.Start();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(duration, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
watcher.Stop();
|
||||
watcher.Received -= receivedHandler;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MergeWindowsDeviceInventoryAsync(ConcurrentDictionary<ulong, ScanCapture> captures)
|
||||
{
|
||||
try
|
||||
{
|
||||
var additionalProperties = new[]
|
||||
{
|
||||
"System.Devices.Aep.DeviceAddress"
|
||||
};
|
||||
|
||||
var devices = await DeviceInformation.FindAllAsync(
|
||||
BluetoothLEDevice.GetDeviceSelector(),
|
||||
additionalProperties);
|
||||
|
||||
foreach (var device in devices)
|
||||
{
|
||||
if (!IsKnownCandidate(device.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!device.Properties.TryGetValue("System.Devices.Aep.DeviceAddress", out var addressValue)
|
||||
|| addressValue is not string addressText
|
||||
|| !TryParseBluetoothAddress(addressText, out var address))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
captures.AddOrUpdate(
|
||||
address,
|
||||
_ => new ScanCapture(address, device.Name, short.MinValue),
|
||||
(_, existing) => existing.Merge(device.Name, short.MinValue));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWritableCharacteristic(GattCharacteristic characteristic)
|
||||
=> characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.Write)
|
||||
|| characteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.WriteWithoutResponse);
|
||||
|
||||
private IEnumerable<GattCharacteristic> EnumerateWriteTargets()
|
||||
{
|
||||
if (_preferredCharacteristic is not null)
|
||||
{
|
||||
yield return _preferredCharacteristic;
|
||||
}
|
||||
|
||||
foreach (var characteristic in _writeCharacteristics)
|
||||
{
|
||||
if (!ReferenceEquals(characteristic, _preferredCharacteristic))
|
||||
{
|
||||
yield return characteristic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatAddress(ulong address)
|
||||
{
|
||||
var bytes = BitConverter.GetBytes(address).Take(6).Reverse().ToArray();
|
||||
return string.Join(":", bytes.Select(item => item.ToString("X2")));
|
||||
}
|
||||
|
||||
private static bool TryParseBluetoothAddress(string? value, out ulong address)
|
||||
{
|
||||
address = 0;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Replace(":", string.Empty).Replace("-", string.Empty).Trim();
|
||||
return ulong.TryParse(normalized, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out address);
|
||||
}
|
||||
|
||||
private sealed record ScanCapture(ulong Address, string Name, short SignalStrength)
|
||||
{
|
||||
public ScanCapture Merge(string? name, short signalStrength)
|
||||
=> this with
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(name) ? Name : name!,
|
||||
SignalStrength = signalStrength == short.MinValue ? SignalStrength : signalStrength
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using RgbLedStripController.Core;
|
||||
using MediaColor = System.Windows.Media.Color;
|
||||
|
||||
namespace RgbLedStripController.Services;
|
||||
|
||||
public static class ElkBledomProtocol
|
||||
{
|
||||
public static readonly Guid ServiceUuid = Guid.Parse("0000fff0-0000-1000-8000-00805f9b34fb");
|
||||
public static readonly Guid CharacteristicUuid = Guid.Parse("0000fff3-0000-1000-8000-00805f9b34fb");
|
||||
|
||||
public static byte[] BuildPower(bool isOn)
|
||||
{
|
||||
var flag = isOn ? (byte)0x01 : (byte)0x00;
|
||||
return new byte[] { 0x7E, 0x04, 0x04, flag, 0x00, flag, 0xFF, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildLegacyPower(bool isOn)
|
||||
{
|
||||
var flag = isOn ? (byte)0x01 : (byte)0x00;
|
||||
return new byte[] { 0x7E, 0x00, 0x04, flag, 0x00, 0x00, 0x00, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildColor(MediaColor color)
|
||||
=> new byte[] { 0x7E, 0x07, 0x05, 0x03, color.R, color.G, color.B, 0x10, 0xEF };
|
||||
|
||||
public static byte[] BuildLegacyColor(MediaColor color)
|
||||
=> new byte[] { 0x7E, 0x00, 0x05, 0x03, color.R, color.G, color.B, 0x00, 0xEF };
|
||||
|
||||
public static byte[] BuildBrightness(byte brightness)
|
||||
{
|
||||
var value = (byte)Math.Clamp((int)brightness, 0, 100);
|
||||
return new byte[] { 0x7E, 0x04, 0x01, value, 0xFF, 0xFF, 0xFF, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildLegacyBrightness(byte brightness)
|
||||
{
|
||||
var value = (byte)Math.Clamp((int)brightness, 0, 100);
|
||||
return new byte[] { 0x7E, 0x00, 0x01, value, 0x00, 0x00, 0x00, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildEffect(byte patternId)
|
||||
{
|
||||
var clamped = (byte)Math.Clamp((int)patternId, 0, 28);
|
||||
return new byte[] { 0x7E, 0x05, 0x03, (byte)(clamped + 128), 0x03, 0xFF, 0xFF, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildEffectSpeed(byte speed)
|
||||
{
|
||||
var value = (byte)Math.Clamp((int)speed, 0, 100);
|
||||
return new byte[] { 0x7E, 0x00, 0x02, value, 0x00, 0x00, 0x00, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildGrayscaleMode()
|
||||
=> new byte[] { 0x7E, 0x00, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xEF };
|
||||
|
||||
public static byte[] BuildGrayscaleLevel(byte level)
|
||||
{
|
||||
var value = (byte)Math.Clamp((int)level, 0, 100);
|
||||
return new byte[] { 0x7E, 0x00, 0x05, 0x01, value, 0x00, 0x00, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildTemperatureMode(byte level)
|
||||
{
|
||||
var mapped = (byte)(0x80 + Math.Round(Math.Clamp((double)level, 0, 100) * 10d / 100d));
|
||||
return new byte[] { 0x7E, 0x00, 0x03, mapped, 0x02, 0x00, 0x00, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildTemperatureLevel(byte level)
|
||||
{
|
||||
var value = (byte)Math.Clamp((int)level, 0, 100);
|
||||
return new byte[] { 0x7E, 0x00, 0x05, 0x02, value, 0x00, 0x00, 0x00, 0xEF };
|
||||
}
|
||||
|
||||
public static byte[] BuildRgbModeReset()
|
||||
=> new byte[] { 0x7E, 0x00, 0x03, 0x00, 0x05, 0x00, 0x00, 0x00, 0xEF };
|
||||
|
||||
public static bool EffectSupportsSpeed(byte patternId)
|
||||
=> patternId >= 0x07 && patternId <= 0x1C;
|
||||
|
||||
public static IReadOnlyList<EffectOption> CreateEffectOptions()
|
||||
=> new[]
|
||||
{
|
||||
new EffectOption(0x00, "Tek Renk Kırmızı", false),
|
||||
new EffectOption(0x01, "Tek Renk Yeşil", false),
|
||||
new EffectOption(0x02, "Tek Renk Mavi", false),
|
||||
new EffectOption(0x03, "Tek Renk Sarı", false),
|
||||
new EffectOption(0x04, "Tek Renk Camgöbeği", false),
|
||||
new EffectOption(0x05, "Tek Renk Macenta", false),
|
||||
new EffectOption(0x06, "Tek Renk Beyaz", false),
|
||||
new EffectOption(0x07, "Atlama RGB", true),
|
||||
new EffectOption(0x08, "Atlama Gökkuşağı", true),
|
||||
new EffectOption(0x09, "Geçiş RGB", true),
|
||||
new EffectOption(0x0A, "Geçiş Gökkuşağı", true),
|
||||
new EffectOption(0x0B, "Geçiş Kırmızı", true),
|
||||
new EffectOption(0x0C, "Geçiş Yeşil", true),
|
||||
new EffectOption(0x0D, "Geçiş Mavi", true),
|
||||
new EffectOption(0x0E, "Geçiş Sarı", true),
|
||||
new EffectOption(0x0F, "Geçiş Camgöbeği", true),
|
||||
new EffectOption(0x10, "Geçiş Macenta", true),
|
||||
new EffectOption(0x11, "Geçiş Beyaz", true),
|
||||
new EffectOption(0x12, "Geçiş Kırmızı-Yeşil", true),
|
||||
new EffectOption(0x13, "Geçiş Kırmızı-Mavi", true),
|
||||
new EffectOption(0x14, "Geçiş Yeşil-Mavi", true),
|
||||
new EffectOption(0x15, "Yanıp Sön Gökkuşağı", true),
|
||||
new EffectOption(0x16, "Yanıp Sön Kırmızı", true),
|
||||
new EffectOption(0x17, "Yanıp Sön Yeşil", true),
|
||||
new EffectOption(0x18, "Yanıp Sön Mavi", true),
|
||||
new EffectOption(0x19, "Yanıp Sön Sarı", true),
|
||||
new EffectOption(0x1A, "Yanıp Sön Camgöbeği", true),
|
||||
new EffectOption(0x1B, "Yanıp Sön Macenta", true),
|
||||
new EffectOption(0x1C, "Yanıp Sön Beyaz", true)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using RgbLedStripController.Core;
|
||||
|
||||
namespace RgbLedStripController.Services;
|
||||
|
||||
public interface IBleTransport : IAsyncDisposable
|
||||
{
|
||||
event EventHandler<DeviceConnectionStatus>? ConnectionStatusChanged;
|
||||
|
||||
Task<IReadOnlyList<BleScanResult>> ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default);
|
||||
|
||||
Task ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DiscoverAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task WriteAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DisconnectAsync();
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
using System.ComponentModel;
|
||||
using RgbLedStripController.Core;
|
||||
using MediaColor = System.Windows.Media.Color;
|
||||
|
||||
namespace RgbLedStripController.Services;
|
||||
|
||||
public sealed class MultiDeviceController : IAsyncDisposable
|
||||
{
|
||||
private readonly SettingsStore _settingsStore;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly object _stateLock = new();
|
||||
private readonly object _pendingLock = new();
|
||||
private readonly Dictionary<string, DeviceConnectionContext> _connections = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _aliases = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<DeviceProfile> _knownDevices = [];
|
||||
private MediaColor? _pendingColor;
|
||||
private byte? _pendingBrightness;
|
||||
private bool _pendingEnsurePoweredOn;
|
||||
|
||||
public MultiDeviceController(SettingsStore settingsStore, ControllerState state)
|
||||
{
|
||||
_settingsStore = settingsStore;
|
||||
State = state;
|
||||
}
|
||||
|
||||
public ControllerState State { get; }
|
||||
|
||||
public DeviceProfile? LastKnownDevice
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _knownDevices
|
||||
.OrderByDescending(device => device.LastSeenUtc)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await _settingsStore.LoadAsync(cancellationToken);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_aliases.Clear();
|
||||
foreach (var pair in snapshot.DeviceAliases)
|
||||
{
|
||||
_aliases[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
_knownDevices.Clear();
|
||||
_knownDevices.AddRange(snapshot.KnownDevices);
|
||||
}
|
||||
|
||||
State.SelectedColor = snapshot.SelectedColor;
|
||||
State.Brightness = snapshot.Brightness;
|
||||
State.EffectSpeed = snapshot.EffectSpeed;
|
||||
State.CurrentMode = snapshot.CurrentMode;
|
||||
State.GrayscaleLevel = snapshot.GrayscaleLevel;
|
||||
State.TemperatureLevel = snapshot.TemperatureLevel;
|
||||
State.SelectedEffectId = snapshot.SelectedEffectId;
|
||||
State.IsPoweredOn = snapshot.IsPoweredOn;
|
||||
State.StatusText = LastKnownDevice is null
|
||||
? "Tarama için hazır."
|
||||
: $"Son bilinen cihaz: {LastKnownDevice.LocalAlias}";
|
||||
|
||||
RecalculateAggregateState();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BleScanResult>> ScanForDevicesAsync(TimeSpan duration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
State.ConnectionStatus = DeviceConnectionStatus.Scanning;
|
||||
State.StatusText = "BLE cihazları taranıyor...";
|
||||
|
||||
await using var scanner = new BleDeviceService();
|
||||
var results = await scanner.ScanAsync(duration, cancellationToken);
|
||||
foreach (var item in results)
|
||||
{
|
||||
var alias = GetAlias(item.BluetoothAddress);
|
||||
item.Alias = alias;
|
||||
item.IsLastConnected = string.Equals(item.BluetoothAddress, LastKnownDevice?.BluetoothAddress, StringComparison.OrdinalIgnoreCase);
|
||||
item.IsKnownCandidate = item.IsKnownCandidate || !string.IsNullOrWhiteSpace(alias);
|
||||
}
|
||||
|
||||
var candidateCount = results.Count(item => item.IsKnownCandidate);
|
||||
State.StatusText = results.Count == 0
|
||||
? "Tarama bitti, uygun cihaz bulunamadı."
|
||||
: $"{results.Count} BLE cihazı bulundu, {candidateCount} tanesi aday görünüyor.";
|
||||
|
||||
RecalculateAggregateState();
|
||||
return results
|
||||
.OrderByDescending(device => device.IsLastConnected)
|
||||
.ThenByDescending(device => device.IsKnownCandidate)
|
||||
.ThenByDescending(device => device.SignalStrength)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<ConnectedDeviceViewModel> ConnectAsync(BleScanResult device, CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeviceConnectionContext? existing;
|
||||
lock (_stateLock)
|
||||
{
|
||||
_connections.TryGetValue(device.BluetoothAddress, out existing);
|
||||
}
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
await EnsureConnectedAsync(existing, cancellationToken);
|
||||
return existing.ViewModel;
|
||||
}
|
||||
|
||||
var viewModel = new ConnectedDeviceViewModel
|
||||
{
|
||||
DeviceId = device.DeviceId,
|
||||
NativeName = device.NativeName,
|
||||
BluetoothAddress = device.BluetoothAddress,
|
||||
AddressValue = device.AddressValue,
|
||||
SignalStrength = device.SignalStrength,
|
||||
Alias = GetAlias(device.BluetoothAddress),
|
||||
ConnectionStatus = DeviceConnectionStatus.Connecting,
|
||||
StatusText = "Bağlantı kuruluyor..."
|
||||
};
|
||||
|
||||
var transport = new BleDeviceService();
|
||||
var context = new DeviceConnectionContext(device, viewModel, transport);
|
||||
transport.ConnectionStatusChanged += HandleConnectionStatusChanged;
|
||||
viewModel.PropertyChanged += HandleConnectedDevicePropertyChanged;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_connections[device.BluetoothAddress] = context;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureConnectedAsync(context, cancellationToken);
|
||||
viewModel.StatusText = "Komut almaya hazır.";
|
||||
UpsertKnownDevice(device);
|
||||
await PersistStateAsync(cancellationToken);
|
||||
RecalculateAggregateState();
|
||||
return viewModel;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RemoveConnectionAsync(device.BluetoothAddress, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(string bluetoothAddress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RemoveConnectionAsync(bluetoothAddress, cancellationToken);
|
||||
State.StatusText = "Seçili cihaz bağlantıdan çıkarıldı.";
|
||||
RecalculateAggregateState();
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisconnectAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string[] addresses;
|
||||
lock (_stateLock)
|
||||
{
|
||||
addresses = _connections.Keys.ToArray();
|
||||
}
|
||||
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
await RemoveConnectionAsync(address, cancellationToken);
|
||||
}
|
||||
|
||||
State.StatusText = "Tüm cihaz bağlantıları kapatıldı.";
|
||||
RecalculateAggregateState();
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SetAliasAsync(BleScanResult device, string alias, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trimmed = alias.Trim();
|
||||
DeviceConnectionContext? context;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
_aliases.Remove(device.BluetoothAddress);
|
||||
device.Alias = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
_aliases[device.BluetoothAddress] = trimmed;
|
||||
device.Alias = trimmed;
|
||||
}
|
||||
|
||||
_connections.TryGetValue(device.BluetoothAddress, out context);
|
||||
}
|
||||
|
||||
if (context is not null)
|
||||
{
|
||||
context.ViewModel.Alias = device.Alias;
|
||||
}
|
||||
|
||||
UpsertKnownDevice(device);
|
||||
State.StatusText = string.IsNullOrWhiteSpace(device.Alias)
|
||||
? $"{device.NativeName} için varsayılan isim kullanılacak."
|
||||
: $"{device.NativeName} için takma ad kaydedildi.";
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ApplyPowerAsync(bool isOn, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await BroadcastAsync(
|
||||
async transport => await transport.WriteAsync(ElkBledomProtocol.BuildPower(isOn), cancellationToken),
|
||||
successMessage: isOn ? "Işıklar açıldı." : "Işıklar kapatıldı.",
|
||||
cancellationToken);
|
||||
|
||||
State.IsPoweredOn = isOn;
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task QueueColorAsync(MediaColor color)
|
||||
{
|
||||
EnsureTargetsAvailable();
|
||||
var shouldPowerOn = !State.IsPoweredOn;
|
||||
|
||||
State.CurrentMode = LightingMode.Rgb;
|
||||
State.SelectedColor = color;
|
||||
State.IsPoweredOn = true;
|
||||
State.StatusText = "Renk uygulanıyor...";
|
||||
|
||||
lock (_pendingLock)
|
||||
{
|
||||
_pendingColor = color;
|
||||
_pendingEnsurePoweredOn |= shouldPowerOn;
|
||||
}
|
||||
|
||||
await DrainPendingWritesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task QueueBrightnessAsync(byte brightness)
|
||||
{
|
||||
EnsureTargetsAvailable();
|
||||
var shouldPowerOn = !State.IsPoweredOn && brightness > 0;
|
||||
|
||||
State.Brightness = brightness;
|
||||
if (brightness > 0)
|
||||
{
|
||||
State.IsPoweredOn = true;
|
||||
}
|
||||
|
||||
State.StatusText = "Parlaklık uygulanıyor...";
|
||||
|
||||
lock (_pendingLock)
|
||||
{
|
||||
_pendingBrightness = brightness;
|
||||
_pendingEnsurePoweredOn |= shouldPowerOn;
|
||||
}
|
||||
|
||||
await DrainPendingWritesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task ApplyEffectAsync(byte patternId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureTargetsAvailable();
|
||||
await DrainPendingWritesAsync(cancellationToken);
|
||||
|
||||
State.CurrentMode = LightingMode.Effect;
|
||||
State.SelectedEffectId = patternId;
|
||||
State.IsPoweredOn = true;
|
||||
|
||||
await BroadcastAsync(
|
||||
async transport =>
|
||||
{
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildEffect(patternId), cancellationToken);
|
||||
if (ElkBledomProtocol.EffectSupportsSpeed(patternId))
|
||||
{
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildEffectSpeed(State.EffectSpeed), cancellationToken);
|
||||
}
|
||||
},
|
||||
$"Efekt {patternId + 1:00} uygulandı.",
|
||||
cancellationToken);
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ApplyGrayscaleModeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureTargetsAvailable();
|
||||
State.CurrentMode = LightingMode.Grayscale;
|
||||
State.IsPoweredOn = true;
|
||||
|
||||
await BroadcastAsync(
|
||||
async transport =>
|
||||
{
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildGrayscaleMode(), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildGrayscaleLevel(State.GrayscaleLevel), cancellationToken);
|
||||
},
|
||||
"Gri ton modu uygulandı.",
|
||||
cancellationToken);
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ApplyGrayscaleLevelAsync(byte level, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureTargetsAvailable();
|
||||
State.CurrentMode = LightingMode.Grayscale;
|
||||
State.GrayscaleLevel = level;
|
||||
State.IsPoweredOn = true;
|
||||
State.StatusText = "Gri ton uygulanıyor...";
|
||||
|
||||
await BroadcastAsync(
|
||||
async transport => await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildGrayscaleLevel(level), cancellationToken),
|
||||
"Gri ton uygulandı.",
|
||||
cancellationToken);
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ApplyTemperatureModeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureTargetsAvailable();
|
||||
State.CurrentMode = LightingMode.Temperature;
|
||||
State.IsPoweredOn = true;
|
||||
|
||||
await BroadcastAsync(
|
||||
async transport =>
|
||||
{
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildTemperatureMode(State.TemperatureLevel), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildTemperatureLevel(State.TemperatureLevel), cancellationToken);
|
||||
},
|
||||
"Beyaz sıcaklığı modu uygulandı.",
|
||||
cancellationToken);
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ApplyTemperatureLevelAsync(byte level, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureTargetsAvailable();
|
||||
State.CurrentMode = LightingMode.Temperature;
|
||||
State.TemperatureLevel = level;
|
||||
State.IsPoweredOn = true;
|
||||
State.StatusText = "Beyaz sıcaklığı uygulanıyor...";
|
||||
|
||||
await BroadcastAsync(
|
||||
async transport => await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildTemperatureLevel(level), cancellationToken),
|
||||
"Beyaz sıcaklığı uygulandı.",
|
||||
cancellationToken);
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ApplyEffectSpeedAsync(byte speed, CancellationToken cancellationToken = default)
|
||||
{
|
||||
State.EffectSpeed = speed;
|
||||
if (!ElkBledomProtocol.EffectSupportsSpeed(State.SelectedEffectId))
|
||||
{
|
||||
await PersistStateAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureTargetsAvailable();
|
||||
State.StatusText = "Efekt hızı uygulanıyor...";
|
||||
|
||||
await BroadcastAsync(
|
||||
async transport => await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildEffectSpeed(speed), cancellationToken),
|
||||
"Efekt hızı uygulandı.",
|
||||
cancellationToken);
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
CancelPendingFlush();
|
||||
|
||||
DeviceConnectionContext[] contexts;
|
||||
lock (_stateLock)
|
||||
{
|
||||
contexts = _connections.Values.ToArray();
|
||||
_connections.Clear();
|
||||
}
|
||||
|
||||
foreach (var context in contexts)
|
||||
{
|
||||
context.Transport.ConnectionStatusChanged -= HandleConnectionStatusChanged;
|
||||
context.ViewModel.PropertyChanged -= HandleConnectedDevicePropertyChanged;
|
||||
await context.Transport.DisposeAsync();
|
||||
}
|
||||
|
||||
_writeLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task EnsureConnectedAsync(DeviceConnectionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connecting;
|
||||
context.ViewModel.StatusText = "Bağlantı kuruluyor...";
|
||||
await context.Transport.ConnectAsync(context.Device, cancellationToken);
|
||||
var discovered = await context.Transport.DiscoverAsync(cancellationToken);
|
||||
if (!discovered)
|
||||
{
|
||||
context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Faulted;
|
||||
context.ViewModel.StatusText = "fff0 / fff3 bulunamadı.";
|
||||
throw new InvalidOperationException("Hedef cihazda fff0 / fff3 bulunamadı.");
|
||||
}
|
||||
|
||||
context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connected;
|
||||
context.ViewModel.StatusText = "Komut almaya hazır.";
|
||||
}
|
||||
|
||||
private async Task BroadcastAsync(Func<IBleTransport, Task> sendAsync, string successMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
var targets = GetTargetConnections();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Komut gönderebilmek için en az bir hedef cihaz seçmelisin.");
|
||||
}
|
||||
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var successCount = 0;
|
||||
var failureCount = 0;
|
||||
var firstFailureMessage = string.Empty;
|
||||
|
||||
foreach (var context in targets)
|
||||
{
|
||||
context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connecting;
|
||||
context.ViewModel.StatusText = "Komut gönderiliyor...";
|
||||
|
||||
try
|
||||
{
|
||||
await sendAsync(context.Transport);
|
||||
context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Connected;
|
||||
context.ViewModel.StatusText = successMessage;
|
||||
UpsertKnownDevice(context.Device);
|
||||
successCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.ViewModel.ConnectionStatus = DeviceConnectionStatus.Faulted;
|
||||
context.ViewModel.StatusText = $"Hata: {ex.Message}";
|
||||
failureCount++;
|
||||
if (string.IsNullOrWhiteSpace(firstFailureMessage))
|
||||
{
|
||||
firstFailureMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(25, cancellationToken);
|
||||
}
|
||||
|
||||
State.StatusText = BuildStatusMessage(successMessage, targets.Count, successCount, failureCount, firstFailureMessage);
|
||||
RecalculateAggregateState();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainPendingWritesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
MediaColor? color;
|
||||
byte? brightness;
|
||||
bool ensurePoweredOn;
|
||||
|
||||
lock (_pendingLock)
|
||||
{
|
||||
color = _pendingColor;
|
||||
brightness = _pendingBrightness;
|
||||
ensurePoweredOn = _pendingEnsurePoweredOn;
|
||||
_pendingColor = null;
|
||||
_pendingBrightness = null;
|
||||
_pendingEnsurePoweredOn = false;
|
||||
}
|
||||
|
||||
if (!color.HasValue && !brightness.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await BroadcastAsync(
|
||||
async transport =>
|
||||
{
|
||||
var shouldResetRgbMode = color.HasValue || brightness.HasValue;
|
||||
var shouldPowerOnBeforeStaticWrite = color.HasValue || brightness.GetValueOrDefault() > 0 || ensurePoweredOn;
|
||||
var targetColor = color ?? State.SelectedColor;
|
||||
var targetBrightness = brightness ?? State.Brightness;
|
||||
if (shouldResetRgbMode)
|
||||
{
|
||||
if (shouldPowerOnBeforeStaticWrite)
|
||||
{
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildPower(true), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildLegacyPower(true), cancellationToken);
|
||||
}
|
||||
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildRgbModeReset(), cancellationToken);
|
||||
}
|
||||
|
||||
if (color.HasValue || brightness.HasValue)
|
||||
{
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildColor(targetColor), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildLegacyColor(targetColor), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildBrightness(targetBrightness), cancellationToken);
|
||||
await WriteWithPacingAsync(transport, ElkBledomProtocol.BuildLegacyBrightness(targetBrightness), cancellationToken);
|
||||
}
|
||||
},
|
||||
"Değerler uygulandı.",
|
||||
cancellationToken);
|
||||
|
||||
await PersistStateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void CancelPendingFlush()
|
||||
{
|
||||
lock (_pendingLock)
|
||||
{
|
||||
_pendingColor = null;
|
||||
_pendingBrightness = null;
|
||||
_pendingEnsurePoweredOn = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveConnectionAsync(string bluetoothAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
DeviceConnectionContext? context;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_connections.TryGetValue(bluetoothAddress, out context))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connections.Remove(bluetoothAddress);
|
||||
}
|
||||
|
||||
if (context is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Transport.ConnectionStatusChanged -= HandleConnectionStatusChanged;
|
||||
context.ViewModel.PropertyChanged -= HandleConnectedDevicePropertyChanged;
|
||||
await context.Transport.DisconnectAsync();
|
||||
await context.Transport.DisposeAsync();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
private async Task PersistStateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
List<DeviceProfile> knownDevicesSnapshot;
|
||||
Dictionary<string, string> aliasesSnapshot;
|
||||
lock (_stateLock)
|
||||
{
|
||||
knownDevicesSnapshot = _knownDevices
|
||||
.OrderByDescending(device => device.LastSeenUtc)
|
||||
.ToList();
|
||||
aliasesSnapshot = new Dictionary<string, string>(_aliases, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var snapshot = new SettingsSnapshot(
|
||||
knownDevicesSnapshot,
|
||||
aliasesSnapshot,
|
||||
State.SelectedColor,
|
||||
State.Brightness,
|
||||
State.EffectSpeed,
|
||||
State.CurrentMode,
|
||||
State.GrayscaleLevel,
|
||||
State.TemperatureLevel,
|
||||
State.SelectedEffectId,
|
||||
State.IsPoweredOn);
|
||||
|
||||
await _settingsStore.SaveAsync(snapshot, cancellationToken);
|
||||
}
|
||||
|
||||
private void UpsertKnownDevice(BleScanResult device)
|
||||
{
|
||||
var alias = GetAlias(device.BluetoothAddress);
|
||||
var profile = new DeviceProfile(
|
||||
string.IsNullOrWhiteSpace(alias) ? device.NativeName : alias,
|
||||
device.DeviceId,
|
||||
device.BluetoothAddress,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
var existingIndex = _knownDevices.FindIndex(item => string.Equals(item.BluetoothAddress, device.BluetoothAddress, StringComparison.OrdinalIgnoreCase));
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
_knownDevices[existingIndex] = profile;
|
||||
}
|
||||
else
|
||||
{
|
||||
_knownDevices.Add(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAlias(string bluetoothAddress)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _aliases.TryGetValue(bluetoothAddress, out var alias)
|
||||
? alias
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<DeviceConnectionContext> GetTargetConnections()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _connections.Values
|
||||
.Where(context => context.ViewModel.IsControlTarget)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConnectionStatusChanged(object? sender, DeviceConnectionStatus status)
|
||||
{
|
||||
DeviceConnectionContext? context;
|
||||
lock (_stateLock)
|
||||
{
|
||||
context = _connections.Values.FirstOrDefault(item => ReferenceEquals(item.Transport, sender));
|
||||
}
|
||||
|
||||
if (context is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ViewModel.ConnectionStatus = status;
|
||||
context.ViewModel.StatusText = status switch
|
||||
{
|
||||
DeviceConnectionStatus.Connected => "Komut almaya hazır.",
|
||||
DeviceConnectionStatus.Disconnected => "Bağlantı koptu, sonraki yazmada tekrar kurulacak.",
|
||||
DeviceConnectionStatus.Connecting => "Bağlantı kuruluyor...",
|
||||
DeviceConnectionStatus.Faulted => "Bağlantı hata verdi.",
|
||||
_ => context.ViewModel.StatusText
|
||||
};
|
||||
|
||||
RecalculateAggregateState();
|
||||
}
|
||||
|
||||
private void HandleConnectedDevicePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ConnectedDeviceViewModel.IsControlTarget))
|
||||
{
|
||||
RecalculateAggregateState();
|
||||
}
|
||||
}
|
||||
|
||||
private void RecalculateAggregateState()
|
||||
{
|
||||
DeviceConnectionContext[] connections;
|
||||
lock (_stateLock)
|
||||
{
|
||||
connections = _connections.Values.ToArray();
|
||||
}
|
||||
|
||||
State.ConnectedDeviceCount = connections.Length;
|
||||
State.ControlTargetCount = connections.Count(context => context.ViewModel.IsControlTarget);
|
||||
State.ConnectionStatus = connections.Any(context => context.ViewModel.ConnectionStatus == DeviceConnectionStatus.Connected)
|
||||
? DeviceConnectionStatus.Connected
|
||||
: connections.Any(context => context.ViewModel.ConnectionStatus == DeviceConnectionStatus.Connecting)
|
||||
? DeviceConnectionStatus.Connecting
|
||||
: connections.Any(context => context.ViewModel.ConnectionStatus == DeviceConnectionStatus.Faulted)
|
||||
? DeviceConnectionStatus.Faulted
|
||||
: DeviceConnectionStatus.Disconnected;
|
||||
}
|
||||
|
||||
private void EnsureTargetsAvailable()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_connections.Values.Any(context => context.ViewModel.IsControlTarget))
|
||||
{
|
||||
throw new InvalidOperationException("Komut gönderebilmek için en az bir hedef cihaz seçmelisin.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteWithPacingAsync(IBleTransport transport, ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
|
||||
{
|
||||
await transport.WriteAsync(payload, cancellationToken);
|
||||
await Task.Delay(90, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildStatusMessage(string successMessage, int totalCount, int successCount, int failureCount, string firstFailureMessage)
|
||||
{
|
||||
if (failureCount == 0)
|
||||
{
|
||||
return successMessage;
|
||||
}
|
||||
|
||||
if (successCount == 0)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(firstFailureMessage)
|
||||
? $"{totalCount} hedefte işlem başarısız oldu."
|
||||
: $"İşlem başarısız oldu: {firstFailureMessage}";
|
||||
}
|
||||
|
||||
return $"{successMessage} {successCount}/{totalCount} hedef başarılı, {failureCount} hata.";
|
||||
}
|
||||
|
||||
private sealed class DeviceConnectionContext
|
||||
{
|
||||
public DeviceConnectionContext(BleScanResult device, ConnectedDeviceViewModel viewModel, IBleTransport transport)
|
||||
{
|
||||
Device = device;
|
||||
ViewModel = viewModel;
|
||||
Transport = transport;
|
||||
}
|
||||
|
||||
public BleScanResult Device { get; }
|
||||
|
||||
public ConnectedDeviceViewModel ViewModel { get; }
|
||||
|
||||
public IBleTransport Transport { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using RgbLedStripController.Core;
|
||||
using MediaColor = System.Windows.Media.Color;
|
||||
|
||||
namespace RgbLedStripController.Services;
|
||||
|
||||
public sealed class SettingsStore
|
||||
{
|
||||
private readonly string _settingsPath;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public SettingsStore()
|
||||
{
|
||||
var settingsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"RgbLedStripController");
|
||||
|
||||
_settingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public async Task<SettingsSnapshot> LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
{
|
||||
return CreateDefaultSnapshot();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(_settingsPath);
|
||||
var stored = await JsonSerializer.DeserializeAsync<StoredSettings>(stream, _jsonOptions, cancellationToken);
|
||||
|
||||
return new SettingsSnapshot(
|
||||
stored?.KnownDevices ?? [],
|
||||
new Dictionary<string, string>(stored?.DeviceAliases ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase),
|
||||
ParseColor(stored?.SelectedColorHex),
|
||||
stored?.Brightness ?? 100,
|
||||
stored?.EffectSpeed ?? 50,
|
||||
stored?.CurrentMode ?? LightingMode.Rgb,
|
||||
stored?.GrayscaleLevel ?? 100,
|
||||
stored?.TemperatureLevel ?? 50,
|
||||
stored?.SelectedEffectId ?? 0,
|
||||
stored?.IsPoweredOn ?? false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return CreateDefaultSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(SettingsSnapshot snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await using var stream = File.Create(_settingsPath);
|
||||
var stored = new StoredSettings
|
||||
{
|
||||
KnownDevices = snapshot.KnownDevices.ToList(),
|
||||
DeviceAliases = new Dictionary<string, string>(snapshot.DeviceAliases, StringComparer.OrdinalIgnoreCase),
|
||||
SelectedColorHex = $"#{snapshot.SelectedColor.R:X2}{snapshot.SelectedColor.G:X2}{snapshot.SelectedColor.B:X2}",
|
||||
Brightness = snapshot.Brightness,
|
||||
EffectSpeed = snapshot.EffectSpeed,
|
||||
CurrentMode = snapshot.CurrentMode,
|
||||
GrayscaleLevel = snapshot.GrayscaleLevel,
|
||||
TemperatureLevel = snapshot.TemperatureLevel,
|
||||
SelectedEffectId = snapshot.SelectedEffectId,
|
||||
IsPoweredOn = snapshot.IsPoweredOn
|
||||
};
|
||||
|
||||
await JsonSerializer.SerializeAsync(stream, stored, _jsonOptions, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static SettingsSnapshot CreateDefaultSnapshot()
|
||||
=> new(
|
||||
[],
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
|
||||
MediaColor.FromRgb(255, 106, 78),
|
||||
100,
|
||||
50,
|
||||
LightingMode.Rgb,
|
||||
100,
|
||||
50,
|
||||
0,
|
||||
false);
|
||||
|
||||
private static MediaColor ParseColor(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length != 7 || value[0] != '#')
|
||||
{
|
||||
return MediaColor.FromRgb(255, 106, 78);
|
||||
}
|
||||
|
||||
return MediaColor.FromRgb(
|
||||
Convert.ToByte(value.Substring(1, 2), 16),
|
||||
Convert.ToByte(value.Substring(3, 2), 16),
|
||||
Convert.ToByte(value.Substring(5, 2), 16));
|
||||
}
|
||||
|
||||
private sealed class StoredSettings
|
||||
{
|
||||
public List<DeviceProfile> KnownDevices { get; set; } = [];
|
||||
|
||||
public Dictionary<string, string> DeviceAliases { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string? SelectedColorHex { get; set; }
|
||||
|
||||
public byte Brightness { get; set; }
|
||||
|
||||
public byte EffectSpeed { get; set; }
|
||||
|
||||
public LightingMode CurrentMode { get; set; }
|
||||
|
||||
public byte GrayscaleLevel { get; set; }
|
||||
|
||||
public byte TemperatureLevel { get; set; }
|
||||
|
||||
public byte SelectedEffectId { get; set; }
|
||||
|
||||
public bool IsPoweredOn { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user