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