Compare commits
111 Commits
73562cd525
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 62fdb093d6 | |||
| 67860b02ac | |||
| eeb51fa4e7 | |||
| 250ebcd105 | |||
| 93943dc1fa | |||
| 34f142ee61 | |||
| 7380b33b9b | |||
| f2871319cb | |||
| 07bb89e9b7 | |||
| 5fa851618b | |||
| 2240471b67 | |||
| 81b275979b | |||
| 47c696bae3 | |||
| 43fbc1eff5 | |||
| 997ff2fd70 | |||
| 55772b58dd | |||
| 968046d96b | |||
| 122e95545c | |||
| f4647027d2 | |||
| cdba98813b | |||
| 1f047d6561 | |||
| 6a31814900 | |||
| ea9b05733b | |||
| 05152a0f51 | |||
| 191c988cf9 | |||
| afd4a3bc05 | |||
| be356f30eb | |||
| 8a6ffca446 | |||
| 823cb90d2d | |||
| 00c9ad3a86 | |||
| bcba5f33fc | |||
| 29b43b028d | |||
| 304fa24389 | |||
| a4a0e39b9b | |||
| bbe42ee0a2 | |||
| 0bb4d7c3aa | |||
| 0bbaf81e26 | |||
| 50c40ed13f | |||
| 014b4175b9 | |||
| 6c7b7ea7d7 | |||
| 3292e0daaf | |||
| 294d704eb0 | |||
| 7e78323c9c | |||
| d1c8324c0f | |||
| 49c2a63d68 | |||
| 46d77052ad | |||
| dd92af9913 | |||
| a922c6e052 | |||
| 6395709bb8 | |||
| 272cb69247 | |||
| 51ec0970c3 | |||
| 153972fcd5 | |||
| 8960e7dca3 | |||
| 39981fbc45 | |||
| e163575bac | |||
| 844866b489 | |||
| 5c7c2ad1b2 | |||
| b370bb7d75 | |||
| ff24ec95e6 | |||
| 18c886cbc5 | |||
| 7902d2e1f9 | |||
| a54e2ab8b0 | |||
| 6d85385dbb | |||
| bd7a315c2c | |||
| 42b5ecf1cd | |||
| fe7fd8d539 | |||
| 561229a7fe | |||
| e912019873 | |||
| 568a992a4e | |||
| 9b5686ac0a | |||
| 812d15419c | |||
| b9c71d5bb9 | |||
| 97db63824e | |||
| f2162133a8 | |||
| bebdfcf319 | |||
| 0e270685e8 | |||
| c431cb67b6 | |||
| 31a0f4b2ff | |||
| 9b0dcdb794 | |||
| b62f8b8eaa | |||
| e97ef3afa6 | |||
| 4245e81a35 | |||
| b4ab5ffe3c | |||
| 4db7cd2d27 | |||
| 012e9f5ddb | |||
| ff7b595032 | |||
| 40ea2e3b99 | |||
| 2f221f6219 | |||
| 2a73b92d4a | |||
| f6f428515a | |||
| bf910204a9 | |||
| 6a22757755 | |||
| 27884282a7 | |||
| 99d8c4b8fb | |||
| bf212c86ec | |||
| 42280f094a | |||
| d498bb72a9 | |||
| c78797ba09 | |||
| 2d847beefa | |||
| 155bbdbc24 | |||
| 040a5bbdaa | |||
| 3e7b64664a | |||
| 6349e91e0f | |||
| 304c4703b9 | |||
| ac10b53064 | |||
| 09b4a1b182 | |||
| d8e73cb2b5 | |||
| b0a769b781 | |||
| 1559440a21 | |||
| 3915573514 | |||
| ee40d99067 |
202
.gitea/workflows/release.yml
Normal file
202
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,202 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# ── Create the release first (shared by all build jobs) ────
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create.outputs.release_id }}
|
||||
steps:
|
||||
- name: Create Gitea release
|
||||
id: create
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
IS_PRE="false"
|
||||
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||
IS_PRE="true"
|
||||
fi
|
||||
|
||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"LedGrab $TAG\",
|
||||
\"body\": \"## Downloads\\n\\n| Platform | File | How to run |\\n|----------|------|------------|\\n| Windows | \`LedGrab-${TAG}-win-x64.zip\` | Unzip → run \`LedGrab.bat\` → open http://localhost:8080 |\\n| Linux | \`LedGrab-${TAG}-linux-x64.tar.gz\` | Extract → run \`./run.sh\` → open http://localhost:8080 |\\n| Docker | See below | \`docker pull\` → \`docker run\` |\\n\\n### Docker\\n\\n\`\`\`bash\\ndocker pull ${{ gitea.server_url }}/${{ gitea.repository }}:${TAG}\\ndocker run -d -p 8080:8080 ${{ gitea.server_url }}/${{ gitea.repository }}:${TAG}\\n\`\`\`\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": $IS_PRE
|
||||
}")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Created release ID: $RELEASE_ID"
|
||||
|
||||
# ── Windows portable ZIP (cross-built from Linux) ─────────
|
||||
build-windows:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends zip libportaudio2
|
||||
|
||||
- name: Cross-build Windows distribution
|
||||
run: |
|
||||
chmod +x build-dist-windows.sh
|
||||
./build-dist-windows.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: LedGrab-${{ gitea.ref_name }}-win-x64
|
||||
path: build/LedGrab-*.zip
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach ZIP to release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
|
||||
ZIP_NAME=$(basename "$ZIP_FILE")
|
||||
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$ZIP_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$ZIP_FILE"
|
||||
|
||||
echo "Uploaded: $ZIP_NAME"
|
||||
|
||||
# ── Linux tarball ──────────────────────────────────────────
|
||||
build-linux:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libportaudio2
|
||||
|
||||
- name: Build Linux distribution
|
||||
run: |
|
||||
chmod +x build-dist.sh
|
||||
./build-dist.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: LedGrab-${{ gitea.ref_name }}-linux-x64
|
||||
path: build/LedGrab-*.tar.gz
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach tarball to release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
|
||||
TAR_NAME=$(basename "$TAR_FILE")
|
||||
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$TAR_FILE"
|
||||
|
||||
echo "Uploaded: $TAR_NAME"
|
||||
|
||||
# ── Docker image ───────────────────────────────────────────
|
||||
build-docker:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ gitea.server_url }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Extract version metadata
|
||||
id: meta
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
REGISTRY="${{ gitea.server_url }}/${{ gitea.repository }}"
|
||||
# Lowercase the registry path (Docker requires it)
|
||||
REGISTRY=$(echo "$REGISTRY" | tr '[:upper:]' '[:lower:]' | sed 's|https\?://||')
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build tag list: version + latest (only for stable releases)
|
||||
TAGS="$REGISTRY:$TAG,$REGISTRY:$VERSION"
|
||||
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||
TAGS="$TAGS,$REGISTRY:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
38
.gitea/workflows/test.yml
Normal file
38
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libportaudio2
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: server
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Lint with ruff
|
||||
working-directory: server
|
||||
run: ruff check src/ tests/
|
||||
|
||||
- name: Run tests
|
||||
working-directory: server
|
||||
run: pytest --tb=short -q
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,9 @@ ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
13
.pre-commit-config.yaml
Normal file
13
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: '24.10.0'
|
||||
hooks:
|
||||
- id: black
|
||||
args: [--line-length=100, --target-version=py311]
|
||||
language_version: python3.11
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--line-length=100, --target-version=py311]
|
||||
223
CLAUDE.md
223
CLAUDE.md
@@ -1,219 +1,56 @@
|
||||
# Claude Instructions for WLED Screen Controller
|
||||
|
||||
## CRITICAL: Git Commit and Push Policy
|
||||
## Code Search
|
||||
|
||||
**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨**
|
||||
**If `ast-index` is available, use it as the PRIMARY code search tool.** It is significantly faster than grep and returns structured, accurate results. Fall back to grep/Glob only when ast-index is not installed, returns empty results, or when searching regex patterns/string literals/comments.
|
||||
|
||||
**🚨 NEVER PUSH TO REMOTE WITHOUT EXPLICIT USER APPROVAL 🚨**
|
||||
|
||||
### Strict Rules
|
||||
|
||||
1. **DO NOT** create commits automatically after making changes
|
||||
2. **DO NOT** commit without being explicitly instructed by the user
|
||||
3. **DO NOT** push to remote repository without explicit instruction
|
||||
4. **ALWAYS WAIT** for the user to review changes and ask you to commit
|
||||
5. **ALWAYS ASK** if you're unsure whether to commit
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Make code changes as requested
|
||||
2. **STOP** - Inform user that changes are complete
|
||||
3. **WAIT** - User reviews the changes
|
||||
4. **ONLY IF** user explicitly says "commit" or "create a commit":
|
||||
- Stage the files with `git add`
|
||||
- Create the commit with a descriptive message
|
||||
- **STOP** - Do NOT push
|
||||
5. **ONLY IF** user explicitly says "push" or "commit and push":
|
||||
- Push to remote repository
|
||||
|
||||
### What Counts as Explicit Approval
|
||||
|
||||
✅ **YES - These mean you can commit:**
|
||||
- "commit"
|
||||
- "create a commit"
|
||||
- "commit these changes"
|
||||
- "git commit"
|
||||
|
||||
✅ **YES - These mean you can push:**
|
||||
- "push"
|
||||
- "commit and push"
|
||||
- "push to remote"
|
||||
- "git push"
|
||||
|
||||
❌ **NO - These do NOT mean you should commit:**
|
||||
- "that looks good"
|
||||
- "thanks"
|
||||
- "perfect"
|
||||
- User silence after you make changes
|
||||
- Completing a feature/fix
|
||||
|
||||
### Example Bad Behavior (DON'T DO THIS)
|
||||
|
||||
```
|
||||
❌ User: "Fix the MSS engine test issue"
|
||||
❌ Claude: [fixes the issue]
|
||||
❌ Claude: [automatically commits without asking] <-- WRONG!
|
||||
```
|
||||
|
||||
### Example Good Behavior (DO THIS)
|
||||
|
||||
```
|
||||
✅ User: "Fix the MSS engine test issue"
|
||||
✅ Claude: [fixes the issue]
|
||||
✅ Claude: "I've fixed the MSS engine test issue by adding auto-initialization..."
|
||||
✅ [WAITS FOR USER]
|
||||
✅ User: "Looks good, commit it"
|
||||
✅ Claude: [now creates the commit]
|
||||
```
|
||||
|
||||
## IMPORTANT: Auto-Restart Server on Code Changes
|
||||
|
||||
**Whenever server-side Python code is modified** (any file under `/server/src/` **excluding** `/server/src/wled_controller/static/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart.
|
||||
|
||||
**No restart needed for frontend-only changes.** Files under `/server/src/wled_controller/static/` (HTML, JS, CSS, JSON locale files) are served directly by FastAPI's static file handler — changes take effect on the next browser page refresh without restarting the server.
|
||||
|
||||
### Restart procedure
|
||||
|
||||
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
|
||||
**IMPORTANT for subagents:** When spawning Agent subagents (Plan, Explore, general-purpose, etc.), always instruct them to use `ast-index` via Bash for code search instead of grep/Glob. Example: include "Use `ast-index search`, `ast-index class`, `ast-index usages` etc. via Bash for code search" in the agent prompt.
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
|
||||
ast-index search "Query" # Universal search
|
||||
ast-index class "ClassName" # Find class/struct/interface definitions
|
||||
ast-index usages "SymbolName" # Find all usage sites
|
||||
ast-index symbol "FunctionName" # Find any symbol
|
||||
ast-index callers "FunctionName" # Find all call sites
|
||||
ast-index outline "path/to/File.py" # Show all symbols in a file
|
||||
ast-index changed --base master # Show symbols changed in current branch
|
||||
```
|
||||
|
||||
**Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends).
|
||||
## Git Commit and Push Policy
|
||||
|
||||
## Default Config & API Key
|
||||
**NEVER commit or push without explicit user approval.** Wait for the user to review changes and explicitly say "commit" or "push". Completing a task, "looks good", or "thanks" do NOT count as approval. See the system-level instructions for the full commit workflow.
|
||||
|
||||
The server configuration is in `/server/config/default_config.yaml`. The default API key for development is `development-key-change-in-production` (label: `dev`). The server runs on port **8080** by default.
|
||||
## Auto-Restart and Rebuild Policy
|
||||
|
||||
- **Python code changes** (`server/src/` excluding `static/`): Auto-restart the server. See [contexts/server-operations.md](contexts/server-operations.md) for the restart procedure.
|
||||
- **Frontend changes** (`static/js/`, `static/css/`): Run `cd server && npm run build` to rebuild the bundle. No server restart needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
This is a monorepo containing:
|
||||
- `/server` - Python FastAPI backend (see `server/CLAUDE.md` for detailed instructions)
|
||||
- `/client` - Future frontend client (if applicable)
|
||||
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
|
||||
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
|
||||
|
||||
## Working with Server
|
||||
## Context Files
|
||||
|
||||
For detailed server-specific instructions (restart policy, testing, etc.), see:
|
||||
- `server/CLAUDE.md`
|
||||
| File | When to read |
|
||||
| ---- | ------------ |
|
||||
| [contexts/frontend.md](contexts/frontend.md) | HTML, CSS, JS/TS, i18n, modals, icons, bundling |
|
||||
| [contexts/graph-editor.md](contexts/graph-editor.md) | Visual graph editor changes |
|
||||
| [contexts/server-operations.md](contexts/server-operations.md) | Server restart, startup modes, demo mode |
|
||||
| [contexts/chrome-tools.md](contexts/chrome-tools.md) | Chrome MCP tool usage for testing |
|
||||
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
|
||||
|
||||
## UI Conventions for Dialogs
|
||||
## Task Tracking via TODO.md
|
||||
|
||||
### Hints
|
||||
Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the TodoWrite tool** — all progress tracking goes through `TODO.md`.
|
||||
|
||||
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
|
||||
## Documentation Lookup
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="my-field" data-i18n="my.label">Label:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
|
||||
<input type="text" id="my-field">
|
||||
</div>
|
||||
```
|
||||
|
||||
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
|
||||
|
||||
### Select dropdowns
|
||||
|
||||
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
|
||||
|
||||
### Enhanced selectors (IconSelect & EntitySelect)
|
||||
|
||||
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
|
||||
|
||||
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.js`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.js` for examples.
|
||||
|
||||
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.js`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.js` or `_lineSourceEntitySelect` in `advanced-calibration.js` for examples.
|
||||
|
||||
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
||||
|
||||
### Modal dirty check (discard unsaved changes)
|
||||
|
||||
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.js`:
|
||||
|
||||
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
|
||||
|
||||
```javascript
|
||||
class MyEditorModal extends Modal {
|
||||
constructor() { super('my-modal-id'); }
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('my-name').value,
|
||||
// ... all form fields
|
||||
};
|
||||
}
|
||||
onForceClose() {
|
||||
// Optional: cleanup (reset flags, clear state, etc.)
|
||||
}
|
||||
}
|
||||
const myModal = new MyEditorModal();
|
||||
```
|
||||
|
||||
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
|
||||
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
|
||||
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
|
||||
5. For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in `snapshotValues()`.
|
||||
|
||||
The base class handles: `isDirty()` comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.
|
||||
|
||||
### Card appearance
|
||||
|
||||
When creating or modifying entity cards (devices, targets, CSS sources, streams, audio/value sources, templates), **always reference existing cards** of the same or similar type for visual consistency. Cards should have:
|
||||
|
||||
- Clone (📋) and Edit (✏️) icon buttons in `.template-card-actions`
|
||||
- Delete (✕) button as `.card-remove-btn`
|
||||
- Property badges in `.stream-card-props` with emoji icons
|
||||
- **Crosslinks**: When a card references another entity (audio source, picture source, capture template, PP template, etc.), make the property badge a clickable link using the `stream-card-link` CSS class and an `onclick` handler calling `navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue)`. Only add the link when the referenced entity is found (to avoid broken navigation). Example: `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${id}')">🎵 Name</span>`
|
||||
|
||||
### Modal footer buttons
|
||||
|
||||
Use **icon-only** buttons (✓ / ✕) matching the device settings modal pattern, **not** text buttons:
|
||||
|
||||
```html
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeMyModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Slider value display
|
||||
|
||||
For range sliders, display the current value **inside the label** (not in a separate wrapper). This keeps the value visible next to the property name:
|
||||
|
||||
```html
|
||||
<label for="my-slider"><span data-i18n="my.label">Speed:</span> <span id="my-slider-display">1.0</span></label>
|
||||
...
|
||||
<input type="range" id="my-slider" min="0" max="10" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('my-slider-display').textContent = this.value">
|
||||
```
|
||||
|
||||
Do **not** use a `range-with-value` wrapper div.
|
||||
|
||||
### Tutorials
|
||||
|
||||
The app has an interactive tutorial system (`static/js/features/tutorials.js`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
|
||||
- **Getting started** (header-level walkthrough of all tabs and controls)
|
||||
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
|
||||
- **Device card tutorial** and **Calibration tutorial** (context-specific)
|
||||
|
||||
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
|
||||
|
||||
## Localization (i18n)
|
||||
|
||||
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).
|
||||
|
||||
- In JS modules: `import { t } from '../core/i18n.js';` then `showToast(t('my.key'), 'error')`
|
||||
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
|
||||
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
|
||||
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
|
||||
**Use context7 MCP tools for library/framework documentation lookups** (FastAPI, OpenCV, Pydantic, yt-dlp, etc.) instead of relying on potentially outdated training data.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Always test changes before marking as complete
|
||||
- Follow existing code style and patterns
|
||||
- Update documentation when changing behavior
|
||||
- Write clear, descriptive commit messages when explicitly instructed
|
||||
- Never make commits or pushes without explicit user approval
|
||||
|
||||
85
CONTRIBUTING.md
Normal file
85
CONTRIBUTING.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- Node.js 20+ (for frontend bundle)
|
||||
- Git
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller-mixed/server
|
||||
|
||||
# Python environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# venv\Scripts\activate # Windows
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Frontend dependencies
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Running the Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
# set PYTHONPATH=%CD%\src # Windows
|
||||
python -m wled_controller.main
|
||||
```
|
||||
|
||||
Open http://localhost:8080 to access the dashboard.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pytest
|
||||
```
|
||||
|
||||
Tests use pytest with pytest-asyncio. Coverage reports are generated automatically.
|
||||
|
||||
## Code Style
|
||||
|
||||
This project uses **black** for formatting and **ruff** for linting (both configured in `pyproject.toml` with a line length of 100).
|
||||
|
||||
```bash
|
||||
cd server
|
||||
black src/ tests/
|
||||
ruff check src/ tests/
|
||||
```
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
After modifying any file under `server/src/wled_controller/static/js/` or `static/css/`, rebuild the bundle:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run build
|
||||
```
|
||||
|
||||
The browser loads the esbuild bundle (`static/dist/`), not the source files directly.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow the [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||
|
||||
```
|
||||
feat: add new capture engine
|
||||
fix: correct LED color mapping
|
||||
refactor: extract filter pipeline
|
||||
docs: update API reference
|
||||
test: add audio source tests
|
||||
chore: update dependencies
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Create a feature branch from `master`
|
||||
2. Make your changes with tests
|
||||
3. Ensure `ruff check` and `pytest` pass
|
||||
4. Open a PR with a clear description of the change
|
||||
470
INSTALLATION.md
470
INSTALLATION.md
@@ -1,281 +1,222 @@
|
||||
# Installation Guide
|
||||
|
||||
Complete installation guide for WLED Screen Controller server and Home Assistant integration.
|
||||
Complete installation guide for LED Grab (WLED Screen Controller) server and Home Assistant integration.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Server Installation](#server-installation)
|
||||
2. [Home Assistant Integration](#home-assistant-integration)
|
||||
3. [Quick Start](#quick-start)
|
||||
1. [Docker Installation (recommended)](#docker-installation)
|
||||
2. [Manual Installation](#manual-installation)
|
||||
3. [First-Time Setup](#first-time-setup)
|
||||
4. [Home Assistant Integration](#home-assistant-integration)
|
||||
5. [Configuration Reference](#configuration-reference)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Server Installation
|
||||
## Docker Installation
|
||||
|
||||
### Option 1: Python (Development/Testing)
|
||||
The fastest way to get running. Requires [Docker](https://docs.docker.com/get-docker/) with Compose.
|
||||
|
||||
**Requirements:**
|
||||
- Python 3.11 or higher
|
||||
- Windows, Linux, or macOS
|
||||
1. **Clone and start:**
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Verify:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
# → {"status":"healthy", ...}
|
||||
```
|
||||
|
||||
3. **Open the dashboard:** <http://localhost:8080>
|
||||
|
||||
4. **View logs:**
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
5. **Stop / restart:**
|
||||
|
||||
```bash
|
||||
docker compose down # stop
|
||||
docker compose up -d # start again (data is persisted)
|
||||
```
|
||||
|
||||
### Docker manual build (without Compose)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
docker build -t ledgrab .
|
||||
|
||||
docker run -d \
|
||||
--name wled-screen-controller \
|
||||
-p 8080:8080 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
-v $(pwd)/config:/app/config:ro \
|
||||
ledgrab
|
||||
```
|
||||
|
||||
### Linux screen capture in Docker
|
||||
|
||||
Screen capture from inside a container requires X11 access. Uncomment `network_mode: host` in `docker-compose.yml` and ensure the `DISPLAY` variable is set. Wayland is not currently supported for in-container capture.
|
||||
|
||||
---
|
||||
|
||||
## Manual Installation
|
||||
|
||||
### Requirements
|
||||
|
||||
| Dependency | Version | Purpose |
|
||||
| ---------- | ------- | ------- |
|
||||
| Python | 3.11+ | Backend server |
|
||||
| Node.js | 18+ | Frontend build (esbuild) |
|
||||
| pip | latest | Python package installer |
|
||||
| npm | latest | Node package manager |
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
```
|
||||
|
||||
2. **Create virtual environment:**
|
||||
2. **Build the frontend bundle:**
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
This compiles TypeScript and bundles JS/CSS into `src/wled_controller/static/dist/`.
|
||||
|
||||
3. **Create a virtual environment:**
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
|
||||
# Windows
|
||||
# Linux / macOS
|
||||
source venv/bin/activate
|
||||
|
||||
# Windows (cmd)
|
||||
venv\Scripts\activate
|
||||
|
||||
# Linux/Mac
|
||||
source venv/bin/activate
|
||||
# Windows (PowerShell)
|
||||
venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
4. **Install Python dependencies:**
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
4. **Configure (optional):**
|
||||
Edit `config/default_config.yaml` to customize settings.
|
||||
Optional extras:
|
||||
|
||||
5. **Run the server:**
|
||||
```bash
|
||||
# Set PYTHONPATH
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
set PYTHONPATH=%CD%\src # Windows
|
||||
pip install ".[camera]" # Webcam capture via OpenCV
|
||||
pip install ".[perf]" # DXCam, BetterCam, WGC (Windows only)
|
||||
pip install ".[notifications]" # OS notification capture
|
||||
pip install ".[dev]" # pytest, black, ruff (development)
|
||||
```
|
||||
|
||||
# Start server
|
||||
5. **Set PYTHONPATH and start the server:**
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
export PYTHONPATH=$(pwd)/src
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
|
||||
# Windows (cmd)
|
||||
set PYTHONPATH=%CD%\src
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
6. **Verify:**
|
||||
Open http://localhost:8080/docs in your browser.
|
||||
6. **Verify:** open <http://localhost:8080> in your browser.
|
||||
|
||||
### Option 2: Docker (Recommended for Production)
|
||||
---
|
||||
|
||||
**Requirements:**
|
||||
- Docker
|
||||
- Docker Compose
|
||||
## First-Time Setup
|
||||
|
||||
**Steps:**
|
||||
### Change the default API key
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
cd wled-screen-controller/server
|
||||
```
|
||||
The server ships with a development API key (`development-key-change-in-production`). **Change it before exposing the server on your network.**
|
||||
|
||||
2. **Start with Docker Compose:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
Option A -- edit the config file:
|
||||
|
||||
3. **View logs:**
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
```yaml
|
||||
# server/config/default_config.yaml
|
||||
auth:
|
||||
api_keys:
|
||||
main: "your-secure-key-here" # replace the dev key
|
||||
```
|
||||
|
||||
4. **Verify:**
|
||||
Open http://localhost:8080/docs in your browser.
|
||||
|
||||
### Option 3: Docker (Manual Build)
|
||||
Option B -- set an environment variable:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
docker build -t wled-screen-controller .
|
||||
|
||||
docker run -d \
|
||||
--name wled-controller \
|
||||
-p 8080:8080 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
--network host \
|
||||
wled-screen-controller
|
||||
export WLED_AUTH__API_KEYS__main="your-secure-key-here"
|
||||
```
|
||||
|
||||
Generate a random key:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### Configure CORS for LAN access
|
||||
|
||||
By default the server only allows requests from `http://localhost:8080`. To access the dashboard from another machine on your LAN, add its origin:
|
||||
|
||||
```yaml
|
||||
# server/config/default_config.yaml
|
||||
server:
|
||||
cors_origins:
|
||||
- "http://localhost:8080"
|
||||
- "http://192.168.1.100:8080" # your server's LAN IP
|
||||
```
|
||||
|
||||
Or via environment variable:
|
||||
|
||||
```bash
|
||||
WLED_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
|
||||
```
|
||||
|
||||
### Discover devices
|
||||
|
||||
Open the dashboard and go to the **Devices** tab. Click **Discover** to find WLED devices on your network via mDNS. You can also add devices manually by IP address.
|
||||
|
||||
---
|
||||
|
||||
## Home Assistant Integration
|
||||
|
||||
### Option 1: HACS (Recommended)
|
||||
### Option 1: HACS (recommended)
|
||||
|
||||
1. **Install HACS** if not already installed:
|
||||
- Follow instructions at https://hacs.xyz/docs/setup/download
|
||||
1. Install [HACS](https://hacs.xyz/docs/setup/download) if you have not already.
|
||||
2. Open HACS in Home Assistant.
|
||||
3. Click the three-dot menu, then **Custom repositories**.
|
||||
4. Add URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed`
|
||||
5. Set category to **Integration** and click **Add**.
|
||||
6. Search for "WLED Screen Controller" in HACS and click **Download**.
|
||||
7. Restart Home Assistant.
|
||||
8. Go to **Settings > Devices & Services > Add Integration** and search for "WLED Screen Controller".
|
||||
9. Enter your server URL (e.g., `http://192.168.1.100:8080`) and API key.
|
||||
|
||||
2. **Add Custom Repository:**
|
||||
- Open HACS in Home Assistant
|
||||
- Click the three dots menu → Custom repositories
|
||||
- Add URL: `https://github.com/yourusername/wled-screen-controller`
|
||||
- Category: Integration
|
||||
- Click Add
|
||||
### Option 2: Manual
|
||||
|
||||
3. **Install Integration:**
|
||||
- In HACS, search for "WLED Screen Controller"
|
||||
- Click Download
|
||||
- Restart Home Assistant
|
||||
Copy the `custom_components/wled_screen_controller/` folder from this repository into your Home Assistant `config/custom_components/` directory, then restart Home Assistant and add the integration as above.
|
||||
|
||||
4. **Configure Integration:**
|
||||
- Go to Settings → Devices & Services
|
||||
- Click "+ Add Integration"
|
||||
- Search for "WLED Screen Controller"
|
||||
- Enter your server URL (e.g., `http://192.168.1.100:8080`)
|
||||
- Click Submit
|
||||
|
||||
### Option 2: Manual Installation
|
||||
|
||||
1. **Download Integration:**
|
||||
```bash
|
||||
cd /config # Your Home Assistant config directory
|
||||
mkdir -p custom_components
|
||||
```
|
||||
|
||||
2. **Copy Files:**
|
||||
Copy the `custom_components/wled_screen_controller` folder to your Home Assistant `custom_components` directory.
|
||||
|
||||
3. **Restart Home Assistant**
|
||||
|
||||
4. **Configure Integration:**
|
||||
- Go to Settings → Devices & Services
|
||||
- Click "+ Add Integration"
|
||||
- Search for "WLED Screen Controller"
|
||||
- Enter your server URL
|
||||
- Click Submit
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Server
|
||||
|
||||
```bash
|
||||
cd wled-screen-controller/server
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Attach Your WLED Device
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Living Room TV",
|
||||
"url": "http://192.168.1.100",
|
||||
"led_count": 150
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. Configure in Home Assistant
|
||||
|
||||
1. Add the integration (see above)
|
||||
2. Your WLED devices will appear automatically
|
||||
3. Use the switch to turn processing on/off
|
||||
4. Use the select to choose display
|
||||
5. Monitor FPS and status via sensors
|
||||
|
||||
### 4. Start Processing
|
||||
|
||||
Either via API:
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/devices/{device_id}/start
|
||||
```
|
||||
|
||||
Or via Home Assistant:
|
||||
- Turn on the "{Device Name} Processing" switch
|
||||
|
||||
### 5. Enjoy Ambient Lighting!
|
||||
|
||||
Your WLED strip should now sync with your screen content!
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
**Check Python version:**
|
||||
```bash
|
||||
python --version # Should be 3.11+
|
||||
```
|
||||
|
||||
**Check dependencies:**
|
||||
```bash
|
||||
pip list | grep fastapi
|
||||
```
|
||||
|
||||
**Check logs:**
|
||||
```bash
|
||||
# Docker
|
||||
docker-compose logs -f
|
||||
|
||||
# Python
|
||||
tail -f logs/wled_controller.log
|
||||
```
|
||||
|
||||
### Home Assistant Integration Not Appearing
|
||||
|
||||
1. Check HACS installation
|
||||
2. Clear browser cache
|
||||
3. Restart Home Assistant
|
||||
4. Check Home Assistant logs:
|
||||
- Settings → System → Logs
|
||||
- Search for "wled_screen_controller"
|
||||
|
||||
### Can't Connect to Server from Home Assistant
|
||||
|
||||
1. Verify server is running:
|
||||
```bash
|
||||
curl http://YOUR_SERVER_IP:8080/health
|
||||
```
|
||||
|
||||
2. Check firewall rules
|
||||
3. Ensure Home Assistant can reach server IP
|
||||
4. Try http:// not https://
|
||||
|
||||
### WLED Device Not Responding
|
||||
|
||||
1. Check WLED device is powered on
|
||||
2. Verify IP address is correct
|
||||
3. Test WLED directly:
|
||||
```bash
|
||||
curl http://YOUR_WLED_IP/json/info
|
||||
```
|
||||
|
||||
4. Check network connectivity
|
||||
|
||||
### Low FPS / Performance Issues
|
||||
|
||||
1. Reduce target FPS (Settings → Devices)
|
||||
2. Reduce `border_width` in settings
|
||||
3. Check CPU usage on server
|
||||
4. Consider reducing LED count
|
||||
|
||||
---
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Server Environment Variables
|
||||
|
||||
```bash
|
||||
# Docker .env file
|
||||
WLED_SERVER__HOST=0.0.0.0
|
||||
WLED_SERVER__PORT=8080
|
||||
WLED_SERVER__LOG_LEVEL=INFO
|
||||
WLED_PROCESSING__DEFAULT_FPS=30
|
||||
WLED_PROCESSING__BORDER_WIDTH=10
|
||||
```
|
||||
|
||||
### Home Assistant Automation Example
|
||||
### Automation example
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Auto Start WLED on TV On"
|
||||
- alias: "Start ambient lighting when TV turns on"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
@@ -285,7 +226,7 @@ automation:
|
||||
target:
|
||||
entity_id: switch.living_room_tv_processing
|
||||
|
||||
- alias: "Auto Stop WLED on TV Off"
|
||||
- alias: "Stop ambient lighting when TV turns off"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: media_player.living_room_tv
|
||||
@@ -298,8 +239,89 @@ automation:
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
The server reads configuration from three sources (in order of priority):
|
||||
|
||||
1. **Environment variables** -- prefix `WLED_`, double underscore as nesting delimiter (e.g., `WLED_SERVER__PORT=9090`)
|
||||
2. **YAML config file** -- `server/config/default_config.yaml` (or set `WLED_CONFIG_PATH` to override)
|
||||
3. **Built-in defaults**
|
||||
|
||||
See [`server/.env.example`](server/.env.example) for every available variable with descriptions.
|
||||
|
||||
### Key settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `WLED_SERVER__PORT` | `8080` | HTTP listen port |
|
||||
| `WLED_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
| `WLED_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
|
||||
| `WLED_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
|
||||
| `WLED_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
|
||||
| `WLED_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
|
||||
| `WLED_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server will not start
|
||||
|
||||
**Check Python version:**
|
||||
|
||||
```bash
|
||||
python --version # must be 3.11+
|
||||
```
|
||||
|
||||
**Check the frontend bundle exists:**
|
||||
|
||||
```bash
|
||||
ls server/src/wled_controller/static/dist/app.bundle.js
|
||||
```
|
||||
|
||||
If missing, run `cd server && npm ci && npm run build`.
|
||||
|
||||
**Check logs:**
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs -f
|
||||
|
||||
# Manual install
|
||||
tail -f logs/wled_controller.log
|
||||
```
|
||||
|
||||
### Cannot access the dashboard from another machine
|
||||
|
||||
1. Verify the server is reachable: `curl http://SERVER_IP:8080/health`
|
||||
2. Check your firewall allows inbound traffic on port 8080.
|
||||
3. Add your server's LAN IP to `cors_origins` (see [Configure CORS](#configure-cors-for-lan-access) above).
|
||||
|
||||
### Home Assistant integration not appearing
|
||||
|
||||
1. Verify HACS installed the component: check that `config/custom_components/wled_screen_controller/` exists.
|
||||
2. Clear your browser cache.
|
||||
3. Restart Home Assistant.
|
||||
4. Check logs at **Settings > System > Logs** and search for `wled_screen_controller`.
|
||||
|
||||
### WLED device not responding
|
||||
|
||||
1. Confirm the device is powered on and connected to Wi-Fi.
|
||||
2. Test it directly: `curl http://DEVICE_IP/json/info`
|
||||
3. Check that the server and the device are on the same subnet.
|
||||
4. Try restarting the WLED device.
|
||||
|
||||
### Low FPS or high latency
|
||||
|
||||
1. Lower the target FPS in the stream settings.
|
||||
2. Reduce `border_width` to decrease the number of sampled pixels.
|
||||
3. Check CPU usage on the server (`htop` or Task Manager).
|
||||
4. On Windows, install the `perf` extra for GPU-accelerated capture: `pip install ".[perf]"`
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [API Documentation](docs/API.md)
|
||||
- [Calibration Guide](docs/CALIBRATION.md)
|
||||
- [GitHub Issues](https://github.com/yourusername/wled-screen-controller/issues)
|
||||
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
|
||||
|
||||
32
README.md
32
README.md
@@ -84,26 +84,42 @@ A Home Assistant integration exposes devices as entities for smart home automati
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/wled-screen-controller.git
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
Requires Python 3.11+ and Node.js 18+.
|
||||
|
||||
```bash
|
||||
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
|
||||
cd wled-screen-controller/server
|
||||
|
||||
# Option A: Docker (recommended)
|
||||
docker-compose up -d
|
||||
# Build the frontend bundle
|
||||
npm ci && npm run build
|
||||
|
||||
# Option B: Python
|
||||
# Create a virtual environment and install
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# venv\Scripts\activate # Windows
|
||||
pip install .
|
||||
|
||||
# Start the server
|
||||
export PYTHONPATH=$(pwd)/src # Linux/Mac
|
||||
# set PYTHONPATH=%CD%\src # Windows
|
||||
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
Open `http://localhost:8080` to access the dashboard. The default API key for development is `development-key-change-in-production`.
|
||||
Open **http://localhost:8080** to access the dashboard.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including Docker manual builds and Home Assistant setup.
|
||||
> **Important:** The default API key is `development-key-change-in-production`. Change it before exposing the server outside localhost. See [INSTALLATION.md](INSTALLATION.md) for details.
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -146,7 +162,7 @@ wled-screen-controller/
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `server/config/default_config.yaml` or use environment variables with the `LED_GRAB_` prefix:
|
||||
Edit `server/config/default_config.yaml` or use environment variables with the `WLED_` prefix:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -168,7 +184,7 @@ logging:
|
||||
max_size_mb: 100
|
||||
```
|
||||
|
||||
Environment variable override example: `LED_GRAB_SERVER__PORT=9090`.
|
||||
Environment variable override example: `WLED_SERVER__PORT=9090`.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
138
REVIEW.md
138
REVIEW.md
@@ -1,138 +0,0 @@
|
||||
# Codebase Review Report
|
||||
|
||||
_Generated 2026-03-09_
|
||||
|
||||
---
|
||||
|
||||
## 1. Bugs (Critical)
|
||||
|
||||
### Thread Safety / Race Conditions
|
||||
|
||||
| Issue | Location | Description |
|
||||
|-------|----------|-------------|
|
||||
| **Dict mutation during iteration** | `composite_stream.py:121`, `mapped_stream.py:102` | `update_source()` calls `_sub_streams.clear()` from the API thread while `_processing_loop` iterates the dict on a background thread. **Will crash with `RuntimeError: dictionary changed size during iteration`.** |
|
||||
| **Clock ref-count corruption** | `color_strip_stream_manager.py:286-304` | On clock hot-swap, `_release_clock` reads the *new* clock_id from the store (already updated), so it releases the newly acquired clock instead of the old one. Leaks the old runtime, destroys the new one. |
|
||||
| **SyncClockRuntime race** | `sync_clock_runtime.py:42-49` | `get_time()` reads `_running`, `_offset`, `_epoch` without `_lock`, while `pause()`/`resume()`/`reset()` modify them under `_lock`. Compound read can double-count elapsed time. |
|
||||
| **SyncClockManager unprotected dicts** | `sync_clock_manager.py:26-54` | `_runtimes` and `_ref_counts` are plain dicts mutated from both the async event loop and background threads with no lock. |
|
||||
|
||||
### Silent Failures
|
||||
|
||||
| Issue | Location | Description |
|
||||
|-------|----------|-------------|
|
||||
| **Crashed streams go undetected** | `mapped_stream.py:214`, `composite_stream.py` | When the processing loop dies, `get_latest_colors()` permanently returns stale data. The target keeps sending frozen colors to LEDs with no indicator anything is wrong. |
|
||||
| **Crash doesn't fire state_change event** | `wled_target_processor.py:900` | Fatal exception path sets `_is_running = False` without firing `state_change` event (only `stop()` fires it). Dashboard doesn't learn about crashes via WebSocket. |
|
||||
| **WebSocket broadcast client mismatch** | `kc_target_processor.py:481-485` | `zip(self._ws_clients, results)` pairs results with the live list, but clients can be removed between scheduling `gather` and collecting results, causing wrong clients to be dropped. |
|
||||
|
||||
### Security
|
||||
|
||||
| Issue | Location | Description |
|
||||
|-------|----------|-------------|
|
||||
| **Incomplete path traversal guard** | `auto_backup.py` | Filename validation uses string checks (`".." in filename`) instead of `Path.resolve().is_relative_to()`. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Performance
|
||||
|
||||
### High Impact (Hot Path)
|
||||
|
||||
| Issue | Location | Impact |
|
||||
|-------|----------|--------|
|
||||
| **Per-frame `np.array()` from list** | `ddp_client.py:195` | Allocates a new numpy array from a Python list every frame. Should use pre-allocated buffer. |
|
||||
| **Triple FFT for mono audio** | `analysis.py:168-174` | When audio is mono (common for system loopback), runs 3 identical FFTs. 2x wasted CPU. |
|
||||
| **`frame_time = 1.0/fps` in every loop iteration** | 8 stream files | Recomputed every frame despite `_fps` only changing on consumer subscribe. Should be cached. |
|
||||
| **4x deque traversals per frame for metrics** | `kc_target_processor.py:413-416` | Full traversal of metrics deques every frame to compute avg/min/max. |
|
||||
| **3x spectrum `.copy()` per audio chunk** | `analysis.py:195-201` | ~258 array allocations/sec for read-only consumers. Could use non-writable views. |
|
||||
|
||||
### Medium Impact
|
||||
|
||||
| Issue | Location |
|
||||
|-------|----------|
|
||||
| `getattr` + dict lookup per composite layer per frame | `composite_stream.py:299-304` |
|
||||
| Unconditional `self.*=` attribute writes every frame in audio stream | `audio_stream.py:255-261` |
|
||||
| `JSON.parse(localStorage)` on every collapsed-section call | `dashboard.js` `_getCollapsedSections` |
|
||||
| Effect/composite/mapped streams hardcoded to 30 FPS | `effect_stream.py`, `composite_stream.py:37`, `mapped_stream.py:33` |
|
||||
| Double `querySelectorAll` on card reconcile | `card-sections.js:229-232` |
|
||||
| Module import inside per-second sampling function | `metrics_history.py:21,35` |
|
||||
| `datetime.utcnow()` twice per frame | `kc_target_processor.py:420,464` |
|
||||
| Redundant `bytes()` copy of bytes slice | `ddp_client.py:222` |
|
||||
| Unnecessary `.copy()` of temp interp result | `audio_stream.py:331,342` |
|
||||
| Multiple intermediate numpy allocs for luminance | `value_stream.py:486-494` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Quality
|
||||
|
||||
### Architecture
|
||||
|
||||
| Issue | Description |
|
||||
|-------|-------------|
|
||||
| **12 store classes with duplicated boilerplate** | All JSON stores repeat the same load/save/CRUD pattern with no base class. A `BaseJsonStore[T]` would eliminate ~60% of each store file. |
|
||||
| **`DeviceStore.save()` uses unsafe temp file** | Fixed-path temp file instead of `atomic_write_json` used by all other stores. |
|
||||
| **`scene_activator.py` accesses `ProcessorManager._processors` directly** | Lines 33, 68, 90, 110 — bypasses public API, breaks encapsulation. |
|
||||
| **Route code directly mutates `ProcessorManager` internals** | `devices.py` accesses `manager._devices` and `manager._color_strip_stream_manager` in 13+ places. |
|
||||
| **`color-strips.js` is 1900+ lines** | Handles 11 CSS source types, gradient editor, composite layers, mapped zones, card rendering, overlay control — should be split. |
|
||||
| **No `DataCache` for color strip sources** | Every other entity uses `DataCache`. CSS sources are fetched with raw `fetchWithAuth` in 5+ places with no deduplication. |
|
||||
|
||||
### Consistency / Hygiene
|
||||
|
||||
| Issue | Location |
|
||||
|-------|----------|
|
||||
| `Dict[str, any]` (lowercase `any`) — invalid type annotation | `template_store.py:138,187`, `audio_template_store.py:126,155` |
|
||||
| `datetime.utcnow()` deprecated — 88 call sites in 42 files | Project-wide |
|
||||
| `_icon` SVG helper duplicated verbatim in 3 JS files | `color-strips.js:293`, `automations.js:41`, `kc-targets.js:49` |
|
||||
| `hexToRgbArray` private to one file, pattern inlined elsewhere | `color-strips.js:471` vs line 1403 |
|
||||
| Hardcoded English fallback in `showToast` | `color-strips.js:1593` |
|
||||
| `ColorStripStore.create_source` silently creates wrong type for unknown `source_type` | `color_strip_store.py:92-332` |
|
||||
| `update_source` clock_id clearing uses undocumented empty-string sentinel | `color_strip_store.py:394-395` |
|
||||
| `DeviceStore._load` lacks per-item error isolation (unlike all other stores) | `device_store.py:122-138` |
|
||||
| No unit tests | Zero test files. Highest-risk: `CalibrationConfig`/`PixelMapper` geometry, DDP packets, automation conditions. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Features & Suggestions
|
||||
|
||||
### High Impact / Low Effort
|
||||
|
||||
| Suggestion | Details |
|
||||
|------------|---------|
|
||||
| **Auto-restart crashed processing loops** | Add backoff-based restart when `_processing_loop` dies. Currently crashes are permanent until manual intervention. |
|
||||
| **Fire `state_change` on crash** | Add `finally` block in `_processing_loop` to notify the dashboard immediately. |
|
||||
| **`POST /system/auto-backup/trigger`** | ~5 lines of Python. Manual backup trigger before risky config changes. |
|
||||
| **`is_healthy` property on streams** | Let target processors detect when their color source has died. |
|
||||
| **Rotate webhook token endpoint** | `POST /automations/{id}/rotate-webhook-token` — regenerate without recreating automation. |
|
||||
| **"Start All" targets button** | "Stop All" exists but "Start All" (the more common operation after restart) is missing. |
|
||||
| **Include auto-backup settings in backup** | Currently lost on restore. |
|
||||
| **Distinguish "crashed" vs "stopped" in dashboard** | `metrics.last_error` is already populated — just surface it. |
|
||||
|
||||
### High Impact / Moderate Effort
|
||||
|
||||
| Suggestion | Details |
|
||||
|------------|---------|
|
||||
| **Home Assistant MQTT discovery** | Publish auto-discovery payloads so devices appear in HA automatically. MQTT infra already exists. |
|
||||
| **Device health WebSocket events** | Eliminates 5-30s poll latency for online/offline detection. |
|
||||
| **`GET /system/store-errors`** | Surface startup deserialization failures to the user. Currently only in logs. |
|
||||
| **Scene snapshot should capture device brightness** | `software_brightness` is not saved/restored by scenes. |
|
||||
| **Exponential backoff on events WebSocket reconnect** | Currently fixed 3s retry, generates constant logs during outages. |
|
||||
| **CSS source import/export** | Share individual sources without full config backup. |
|
||||
| **Per-target error ring buffer via API** | `GET /targets/{id}/logs` for remote debugging. |
|
||||
| **DDP socket reconnection** | UDP socket invalidated on network changes; no reconnect path exists. |
|
||||
| **Adalight serial reconnection** | COM port disconnect crashes the target permanently. |
|
||||
| **MQTT-controlled brightness and scene activation** | Direct command handler without requiring API key management. |
|
||||
|
||||
### Nice-to-Have
|
||||
|
||||
| Suggestion | Details |
|
||||
|------------|---------|
|
||||
| Configurable metrics history window (currently hardcoded 120 samples / 2 min) | |
|
||||
| Replace `window.prompt()` API key entry with proper modal | |
|
||||
| Pattern template live preview (SVG/Canvas) | |
|
||||
| Keyboard shortcuts for start/stop targets and scene activation | |
|
||||
| FPS chart auto-scaling y-axis (`Math.max(target*1.15, maxSeen*1.1)`) | |
|
||||
| WLED native preset target type (send `{"ps": id}` instead of pixels) | |
|
||||
| Configurable DDP max packet size per device | |
|
||||
| `GET /system/active-streams` unified runtime snapshot | |
|
||||
| OpenMetrics / Prometheus endpoint for Grafana integration | |
|
||||
| Configurable health check intervals (currently hardcoded 10s/60s) | |
|
||||
| Configurable backup directory path | |
|
||||
| `GET /system/logs?tail=100&level=ERROR` for in-app log viewing | |
|
||||
| Device card "currently streaming" badge | |
|
||||
56
TODO.md
56
TODO.md
@@ -1,56 +0,0 @@
|
||||
# Pending Features & Issues
|
||||
|
||||
Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
||||
|
||||
## Processing Pipeline
|
||||
|
||||
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
|
||||
- Complexity: medium — doesn't fit the PP filter model (operates on extracted LED colors, not images); needs a new param on calibration/color-strip-source config + PixelMapper changes
|
||||
- Impact: high — smooths out single-LED noise, visually cleaner ambilight on sparse strips
|
||||
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
||||
- Complexity: large — requires a new transition layer concept in ProcessorManager; must blend two live streams simultaneously during switch, coordinating start/stop timing
|
||||
- Impact: medium — polishes profile switching UX but ambient lighting rarely switches sources frequently
|
||||
|
||||
## Output Targets
|
||||
|
||||
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
|
||||
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
|
||||
- Impact: medium — opens stage/theatrical use case, niche but differentiating
|
||||
|
||||
## Automation & Integration
|
||||
|
||||
- [ ] `P2` **Webhook/MQTT trigger** — Let external systems activate profiles without HA integration
|
||||
- Complexity: low-medium — webhook: simple FastAPI endpoint calling SceneActivator; MQTT: add `asyncio-mqtt` dependency + subscription loop
|
||||
- Impact: high — key integration point for home automation users without Home Assistant
|
||||
|
||||
## Multi-Display
|
||||
|
||||
- [ ] `P2` **Investigate multimonitor support** — Research and plan support for multi-monitor setups
|
||||
- Complexity: research — audit DXGI/MSS capture engine's display enumeration; test with 2+ monitors; identify gaps in calibration UI (per-display config)
|
||||
- Impact: high — many users have multi-monitor setups; prerequisite for multi-display unification
|
||||
- [ ] `P3` **Multi-display unification** — Treat 2-3 monitors as single virtual display for seamless ambilight
|
||||
- Complexity: large — virtual display abstraction stitching multiple captures; edge-matching calibration between monitors; significant UI changes
|
||||
- Impact: high — flagship feature for multi-monitor users, but depends on investigation results
|
||||
|
||||
## Capture Engines
|
||||
|
||||
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
|
||||
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
|
||||
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
|
||||
|
||||
## Code Health
|
||||
|
||||
- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing
|
||||
- [ ] `P2` **Manual backup trigger endpoint** — `POST /system/auto-backup/trigger` (~5 lines)
|
||||
- [ ] `P2` **Scene snapshot should capture device brightness** — `software_brightness` not saved/restored
|
||||
- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard** — `metrics.last_error` is already populated
|
||||
- [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists
|
||||
- [ ] `P3` **CSS source import/export** — share individual sources without full config backup
|
||||
|
||||
## UX
|
||||
|
||||
- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default
|
||||
- [ ] `P1` **Review new CSS types (Daylight & Candlelight)** — End-to-end review: create via UI, assign to targets, verify LED rendering, check edge cases (0 candles, extreme latitude, real-time toggle)
|
||||
- [ ] `P1` **Daylight brightness value source** — New value source type that reports a 0–255 brightness level based on daylight cycle time (real-time or simulated), reusing the daylight LUT logic
|
||||
- [ ] `P1` **Tags input: move under name, remove hint/title** — Move the tags chip input directly below the name field in all entity editor modals; remove the hint toggle and section title for a cleaner layout
|
||||
- [ ] `P1` **IconSelect grid overflow & scroll jump** — Expandable icon grid sometimes renders outside the visible viewport; opening it causes the modal/page to jump scroll
|
||||
274
build-dist-windows.sh
Normal file
274
build-dist-windows.sh
Normal file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-build a portable Windows distribution of LedGrab from Linux.
|
||||
#
|
||||
# Downloads Windows embedded Python and win_amd64 wheels — no Wine or
|
||||
# Windows runner needed. Produces the same ZIP as build-dist.ps1.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-dist-windows.sh [VERSION]
|
||||
# ./build-dist-windows.sh v0.1.0-alpha.1
|
||||
#
|
||||
# Requirements: python3, pip, curl, unzip, zip, node/npm
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
DIST_NAME="LedGrab"
|
||||
DIST_DIR="$BUILD_DIR/$DIST_NAME"
|
||||
SERVER_DIR="$SCRIPT_DIR/server"
|
||||
PYTHON_DIR="$DIST_DIR/python"
|
||||
APP_DIR="$DIST_DIR/app"
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}"
|
||||
|
||||
# ── Version detection ────────────────────────────────────────
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' "$SERVER_DIR/src/wled_controller/__init__.py" 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip"
|
||||
|
||||
echo "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ==="
|
||||
echo " Embedded Python: $PYTHON_VERSION"
|
||||
echo " Output: build/$ZIP_NAME"
|
||||
echo ""
|
||||
|
||||
# ── Clean ────────────────────────────────────────────────────
|
||||
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
echo "[1/8] Cleaning previous build..."
|
||||
rm -rf "$DIST_DIR"
|
||||
fi
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# ── Download Windows embedded Python ─────────────────────────
|
||||
|
||||
PYTHON_ZIP_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip"
|
||||
PYTHON_ZIP_PATH="$BUILD_DIR/python-embed-win.zip"
|
||||
|
||||
echo "[2/8] Downloading Windows embedded Python ${PYTHON_VERSION}..."
|
||||
if [ ! -f "$PYTHON_ZIP_PATH" ]; then
|
||||
curl -sL "$PYTHON_ZIP_URL" -o "$PYTHON_ZIP_PATH"
|
||||
fi
|
||||
mkdir -p "$PYTHON_DIR"
|
||||
unzip -qo "$PYTHON_ZIP_PATH" -d "$PYTHON_DIR"
|
||||
|
||||
# ── Patch ._pth to enable site-packages ──────────────────────
|
||||
|
||||
echo "[3/8] Patching Python path configuration..."
|
||||
PTH_FILE=$(ls "$PYTHON_DIR"/python*._pth 2>/dev/null | head -1)
|
||||
if [ -z "$PTH_FILE" ]; then
|
||||
echo "ERROR: Could not find python*._pth in $PYTHON_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Uncomment 'import site' and add Lib\site-packages
|
||||
sed -i 's/^#\s*import site/import site/' "$PTH_FILE"
|
||||
if ! grep -q 'Lib\\site-packages' "$PTH_FILE"; then
|
||||
echo 'Lib\site-packages' >> "$PTH_FILE"
|
||||
fi
|
||||
echo " Patched $(basename "$PTH_FILE")"
|
||||
|
||||
# ── Download pip and install into embedded Python ────────────
|
||||
|
||||
echo "[4/8] Installing pip into embedded Python..."
|
||||
SITE_PACKAGES="$PYTHON_DIR/Lib/site-packages"
|
||||
mkdir -p "$SITE_PACKAGES"
|
||||
|
||||
# Download pip + setuptools wheels for Windows
|
||||
pip download --quiet --dest "$BUILD_DIR/pip-wheels" \
|
||||
--platform win_amd64 --python-version "3.11" \
|
||||
--implementation cp --only-binary :all: \
|
||||
pip setuptools 2>/dev/null || \
|
||||
pip download --quiet --dest "$BUILD_DIR/pip-wheels" \
|
||||
pip setuptools
|
||||
|
||||
# Unzip pip into site-packages (we just need it to exist, not to run)
|
||||
for whl in "$BUILD_DIR/pip-wheels"/pip-*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
for whl in "$BUILD_DIR/pip-wheels"/setuptools-*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
|
||||
# ── Download Windows wheels for all dependencies ─────────────
|
||||
|
||||
echo "[5/8] Downloading Windows dependencies..."
|
||||
WHEEL_DIR="$BUILD_DIR/win-wheels"
|
||||
mkdir -p "$WHEEL_DIR"
|
||||
|
||||
# Core dependencies (cross-platform, should have win_amd64 wheels)
|
||||
# We parse pyproject.toml deps and download win_amd64 wheels.
|
||||
# For packages that are pure Python, --only-binary will fail,
|
||||
# so we fall back to allowing source for those.
|
||||
DEPS=(
|
||||
"fastapi>=0.115.0"
|
||||
"uvicorn[standard]>=0.32.0"
|
||||
"httpx>=0.27.2"
|
||||
"mss>=9.0.2"
|
||||
"Pillow>=10.4.0"
|
||||
"numpy>=2.1.3"
|
||||
"pydantic>=2.9.2"
|
||||
"pydantic-settings>=2.6.0"
|
||||
"PyYAML>=6.0.2"
|
||||
"structlog>=24.4.0"
|
||||
"python-json-logger>=3.1.0"
|
||||
"python-dateutil>=2.9.0"
|
||||
"python-multipart>=0.0.12"
|
||||
"jinja2>=3.1.0"
|
||||
"zeroconf>=0.131.0"
|
||||
"pyserial>=3.5"
|
||||
"psutil>=5.9.0"
|
||||
"nvidia-ml-py>=12.0.0"
|
||||
"sounddevice>=0.5"
|
||||
"aiomqtt>=2.0.0"
|
||||
"openrgb-python>=0.2.15"
|
||||
# camera extra
|
||||
"opencv-python-headless>=4.8.0"
|
||||
)
|
||||
|
||||
# Windows-only deps
|
||||
WIN_DEPS=(
|
||||
"wmi>=1.5.1"
|
||||
"PyAudioWPatch>=0.2.12"
|
||||
"winsdk>=1.0.0b10"
|
||||
)
|
||||
|
||||
# Download cross-platform deps (prefer binary, allow source for pure Python)
|
||||
for dep in "${DEPS[@]}"; do
|
||||
pip download --quiet --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "3.11" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"$dep" 2>/dev/null || \
|
||||
pip download --quiet --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "3.11" \
|
||||
--implementation cp \
|
||||
"$dep" 2>/dev/null || \
|
||||
pip download --quiet --dest "$WHEEL_DIR" "$dep" 2>/dev/null || \
|
||||
echo " WARNING: Could not download $dep (skipping)"
|
||||
done
|
||||
|
||||
# Download Windows-only deps (best effort)
|
||||
for dep in "${WIN_DEPS[@]}"; do
|
||||
pip download --quiet --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "3.11" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"$dep" 2>/dev/null || \
|
||||
pip download --quiet --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "3.11" \
|
||||
--implementation cp \
|
||||
"$dep" 2>/dev/null || \
|
||||
echo " WARNING: Could not download $dep (skipping, Windows-only)"
|
||||
done
|
||||
|
||||
# Install all downloaded wheels into site-packages
|
||||
echo " Installing $(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l) wheels into site-packages..."
|
||||
for whl in "$WHEEL_DIR"/*.whl; do
|
||||
[ -f "$whl" ] && unzip -qo "$whl" -d "$SITE_PACKAGES" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Also extract any .tar.gz source packages (pure Python only)
|
||||
for sdist in "$WHEEL_DIR"/*.tar.gz; do
|
||||
[ -f "$sdist" ] || continue
|
||||
TMPDIR=$(mktemp -d)
|
||||
tar -xzf "$sdist" -C "$TMPDIR" 2>/dev/null || continue
|
||||
# Find the package directory inside and copy it
|
||||
PKG_DIR=$(find "$TMPDIR" -maxdepth 2 -name "*.py" -path "*/setup.py" -exec dirname {} \; | head -1)
|
||||
if [ -n "$PKG_DIR" ] && [ -d "$PKG_DIR/src" ]; then
|
||||
cp -r "$PKG_DIR/src/"* "$SITE_PACKAGES/" 2>/dev/null || true
|
||||
elif [ -n "$PKG_DIR" ]; then
|
||||
# Copy any Python package directories
|
||||
find "$PKG_DIR" -maxdepth 1 -type d -name "[a-z]*" -exec cp -r {} "$SITE_PACKAGES/" \; 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$TMPDIR"
|
||||
done
|
||||
|
||||
# Remove dist-info, caches, tests to reduce size
|
||||
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name test -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Remove wled_controller if it got installed
|
||||
rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true
|
||||
|
||||
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
|
||||
echo " Installed $WHEEL_COUNT packages"
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
echo "[6/8] Building frontend bundle..."
|
||||
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
|
||||
grep -v 'RemoteException' || true
|
||||
}
|
||||
|
||||
# ── Copy application files ───────────────────────────────────
|
||||
|
||||
echo "[7/8] Copying application files..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
|
||||
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
|
||||
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
|
||||
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# ── Create launcher ──────────────────────────────────────────
|
||||
|
||||
echo "[8/8] Creating launcher and packaging..."
|
||||
|
||||
cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER
|
||||
@echo off
|
||||
title LedGrab v${VERSION_CLEAN}
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Set paths
|
||||
set PYTHONPATH=%~dp0app\src
|
||||
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
||||
|
||||
:: Create data directory if missing
|
||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||
|
||||
echo.
|
||||
echo =============================================
|
||||
echo LedGrab v${VERSION_CLEAN}
|
||||
echo Open http://localhost:8080 in your browser
|
||||
echo =============================================
|
||||
echo.
|
||||
|
||||
:: Start the server (open browser after short delay)
|
||||
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
|
||||
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
|
||||
pause
|
||||
LAUNCHER
|
||||
|
||||
# Convert launcher to Windows line endings
|
||||
sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
|
||||
|
||||
# ── Create ZIP ───────────────────────────────────────────────
|
||||
|
||||
ZIP_PATH="$BUILD_DIR/$ZIP_NAME"
|
||||
rm -f "$ZIP_PATH"
|
||||
|
||||
(cd "$BUILD_DIR" && zip -rq "$ZIP_NAME" "$DIST_NAME")
|
||||
|
||||
ZIP_SIZE=$(du -h "$ZIP_PATH" | cut -f1)
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
echo " Archive: $ZIP_PATH"
|
||||
echo " Size: $ZIP_SIZE"
|
||||
echo ""
|
||||
250
build-dist.ps1
Normal file
250
build-dist.ps1
Normal file
@@ -0,0 +1,250 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build a portable Windows distribution of LedGrab.
|
||||
|
||||
.DESCRIPTION
|
||||
Downloads embedded Python, installs all dependencies, copies app code,
|
||||
builds the frontend bundle, and produces a self-contained ZIP.
|
||||
|
||||
.PARAMETER Version
|
||||
Version string (e.g. "0.1.0" or "v0.1.0"). Auto-detected from git tag
|
||||
or __init__.py if omitted.
|
||||
|
||||
.PARAMETER PythonVersion
|
||||
Embedded Python version to download. Default: 3.11.9
|
||||
|
||||
.PARAMETER SkipFrontend
|
||||
Skip npm ci + npm run build (use if frontend is already built).
|
||||
|
||||
.PARAMETER SkipPerf
|
||||
Skip installing optional [perf] extras (dxcam, bettercam, windows-capture).
|
||||
|
||||
.EXAMPLE
|
||||
.\build-dist.ps1
|
||||
.\build-dist.ps1 -Version "0.2.0"
|
||||
.\build-dist.ps1 -SkipFrontend -SkipPerf
|
||||
#>
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[string]$PythonVersion = "3.11.9",
|
||||
[switch]$SkipFrontend,
|
||||
[switch]$SkipPerf
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue' # faster downloads
|
||||
|
||||
$ScriptRoot = $PSScriptRoot
|
||||
$BuildDir = Join-Path $ScriptRoot "build"
|
||||
$DistName = "LedGrab"
|
||||
$DistDir = Join-Path $BuildDir $DistName
|
||||
$ServerDir = Join-Path $ScriptRoot "server"
|
||||
$PythonDir = Join-Path $DistDir "python"
|
||||
$AppDir = Join-Path $DistDir "app"
|
||||
|
||||
# ── Version detection ──────────────────────────────────────────
|
||||
|
||||
if (-not $Version) {
|
||||
# Try git tag
|
||||
try {
|
||||
$gitTag = git describe --tags --exact-match 2>$null
|
||||
if ($gitTag) { $Version = $gitTag }
|
||||
} catch {}
|
||||
}
|
||||
if (-not $Version) {
|
||||
# Try env var (CI)
|
||||
if ($env:GITEA_REF_NAME) { $Version = $env:GITEA_REF_NAME }
|
||||
elseif ($env:GITHUB_REF_NAME) { $Version = $env:GITHUB_REF_NAME }
|
||||
}
|
||||
if (-not $Version) {
|
||||
# Parse from __init__.py
|
||||
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
|
||||
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
|
||||
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
|
||||
}
|
||||
if (-not $Version) { $Version = "0.0.0" }
|
||||
|
||||
# Strip leading 'v' for filenames
|
||||
$VersionClean = $Version -replace '^v', ''
|
||||
$ZipName = "LedGrab-v${VersionClean}-win-x64.zip"
|
||||
|
||||
Write-Host "=== Building LedGrab v${VersionClean} ===" -ForegroundColor Cyan
|
||||
Write-Host " Python: $PythonVersion"
|
||||
Write-Host " Output: build\$ZipName"
|
||||
Write-Host ""
|
||||
|
||||
# ── Clean ──────────────────────────────────────────────────────
|
||||
|
||||
if (Test-Path $DistDir) {
|
||||
Write-Host "[1/8] Cleaning previous build..."
|
||||
Remove-Item -Recurse -Force $DistDir
|
||||
}
|
||||
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
|
||||
|
||||
# ── Download embedded Python ───────────────────────────────────
|
||||
|
||||
$PythonZipUrl = "https://www.python.org/ftp/python/${PythonVersion}/python-${PythonVersion}-embed-amd64.zip"
|
||||
$PythonZipPath = Join-Path $BuildDir "python-embed.zip"
|
||||
|
||||
Write-Host "[2/8] Downloading embedded Python ${PythonVersion}..."
|
||||
if (-not (Test-Path $PythonZipPath)) {
|
||||
Invoke-WebRequest -Uri $PythonZipUrl -OutFile $PythonZipPath
|
||||
}
|
||||
Write-Host " Extracting to python/..."
|
||||
Expand-Archive -Path $PythonZipPath -DestinationPath $PythonDir -Force
|
||||
|
||||
# ── Patch ._pth to enable site-packages ────────────────────────
|
||||
|
||||
Write-Host "[3/8] Patching Python path configuration..."
|
||||
$pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" | Select-Object -First 1
|
||||
if (-not $pthFile) { throw "Could not find python*._pth in $PythonDir" }
|
||||
|
||||
$pthContent = Get-Content $pthFile.FullName -Raw
|
||||
# Uncomment 'import site'
|
||||
$pthContent = $pthContent -replace '#\s*import site', 'import site'
|
||||
# Add Lib\site-packages if not present
|
||||
if ($pthContent -notmatch 'Lib\\site-packages') {
|
||||
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
|
||||
}
|
||||
Set-Content -Path $pthFile.FullName -Value $pthContent -NoNewline
|
||||
Write-Host " Patched $($pthFile.Name)"
|
||||
|
||||
# ── Install pip ────────────────────────────────────────────────
|
||||
|
||||
Write-Host "[4/8] Installing pip..."
|
||||
$GetPipPath = Join-Path $BuildDir "get-pip.py"
|
||||
if (-not (Test-Path $GetPipPath)) {
|
||||
Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPipPath
|
||||
}
|
||||
$python = Join-Path $PythonDir "python.exe"
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $python $GetPipPath --no-warn-script-location 2>&1 | Out-Null
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
|
||||
|
||||
# ── Install dependencies ──────────────────────────────────────
|
||||
|
||||
Write-Host "[5/8] Installing dependencies..."
|
||||
$extras = "camera,notifications"
|
||||
if (-not $SkipPerf) { $extras += ",perf" }
|
||||
|
||||
# Install the project (pulls all deps via pyproject.toml), then remove
|
||||
# the installed package itself — PYTHONPATH handles app code loading.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $python -m pip install --no-warn-script-location "${ServerDir}[${extras}]" 2>&1 | ForEach-Object {
|
||||
if ($_ -match 'ERROR|Failed') { Write-Host " $_" -ForegroundColor Red }
|
||||
}
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Remove the installed wled_controller package to avoid duplication
|
||||
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
|
||||
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Clean up caches and test files to reduce size
|
||||
Write-Host " Cleaning up caches..."
|
||||
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "tests" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "test" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# ── Build frontend ─────────────────────────────────────────────
|
||||
|
||||
if (-not $SkipFrontend) {
|
||||
Write-Host "[6/8] Building frontend bundle..."
|
||||
Push-Location $ServerDir
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& npm ci --loglevel error 2>&1 | Out-Null
|
||||
& npm run build 2>&1 | ForEach-Object {
|
||||
$line = "$_"
|
||||
if ($line -and $line -notmatch 'RemoteException') { Write-Host " $line" }
|
||||
}
|
||||
$ErrorActionPreference = 'Stop'
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Host "[6/8] Skipping frontend build (--SkipFrontend)"
|
||||
}
|
||||
|
||||
# ── Copy application files ─────────────────────────────────────
|
||||
|
||||
Write-Host "[7/8] Copying application files..."
|
||||
New-Item -ItemType Directory -Path $AppDir -Force | Out-Null
|
||||
|
||||
# Copy source code (includes static/dist bundle, templates, locales)
|
||||
$srcDest = Join-Path $AppDir "src"
|
||||
Copy-Item -Path (Join-Path $ServerDir "src") -Destination $srcDest -Recurse
|
||||
|
||||
# Copy config
|
||||
$configDest = Join-Path $AppDir "config"
|
||||
Copy-Item -Path (Join-Path $ServerDir "config") -Destination $configDest -Recurse
|
||||
|
||||
# Create empty data/ and logs/ directories
|
||||
New-Item -ItemType Directory -Path (Join-Path $DistDir "data") -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
|
||||
|
||||
# Clean up source maps and __pycache__ from app code
|
||||
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# ── Create launcher ────────────────────────────────────────────
|
||||
|
||||
Write-Host "[8/8] Creating launcher..."
|
||||
|
||||
$launcherContent = @'
|
||||
@echo off
|
||||
title LedGrab v%VERSION%
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Set paths
|
||||
set PYTHONPATH=%~dp0app\src
|
||||
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
||||
|
||||
:: Create data directory if missing
|
||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||
|
||||
echo.
|
||||
echo =============================================
|
||||
echo LedGrab v%VERSION%
|
||||
echo Open http://localhost:8080 in your browser
|
||||
echo =============================================
|
||||
echo.
|
||||
|
||||
:: Start the server (open browser after short delay)
|
||||
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
|
||||
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
|
||||
pause
|
||||
'@
|
||||
|
||||
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
|
||||
$launcherPath = Join-Path $DistDir "LedGrab.bat"
|
||||
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
|
||||
|
||||
# ── Create ZIP ─────────────────────────────────────────────────
|
||||
|
||||
$ZipPath = Join-Path $BuildDir $ZipName
|
||||
if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Creating $ZipName..." -ForegroundColor Cyan
|
||||
|
||||
# Use 7-Zip if available (faster, handles locked files), else fall back to Compress-Archive
|
||||
$7z = Get-Command 7z -ErrorAction SilentlyContinue
|
||||
if ($7z) {
|
||||
& 7z a -tzip -mx=7 $ZipPath "$DistDir\*" | Select-Object -Last 3
|
||||
} else {
|
||||
Compress-Archive -Path "$DistDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal
|
||||
}
|
||||
|
||||
$zipSize = (Get-Item $ZipPath).Length / 1MB
|
||||
Write-Host ""
|
||||
Write-Host "=== Build complete ===" -ForegroundColor Green
|
||||
Write-Host " Archive: $ZipPath"
|
||||
Write-Host " Size: $([math]::Round($zipSize, 1)) MB"
|
||||
Write-Host ""
|
||||
133
build-dist.sh
Normal file
133
build-dist.sh
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build a portable Linux distribution of LedGrab.
|
||||
# Produces a self-contained tarball with virtualenv and launcher script.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-dist.sh [VERSION]
|
||||
# ./build-dist.sh v0.1.0-alpha.1
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
DIST_NAME="LedGrab"
|
||||
DIST_DIR="$BUILD_DIR/$DIST_NAME"
|
||||
SERVER_DIR="$SCRIPT_DIR/server"
|
||||
VENV_DIR="$DIST_DIR/venv"
|
||||
APP_DIR="$DIST_DIR/app"
|
||||
|
||||
# ── Version detection ────────────────────────────────────────
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' "$SERVER_DIR/src/wled_controller/__init__.py" 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz"
|
||||
|
||||
echo "=== Building LedGrab v${VERSION_CLEAN} (Linux) ==="
|
||||
echo " Output: build/$TAR_NAME"
|
||||
echo ""
|
||||
|
||||
# ── Clean ────────────────────────────────────────────────────
|
||||
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
echo "[1/7] Cleaning previous build..."
|
||||
rm -rf "$DIST_DIR"
|
||||
fi
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# ── Create virtualenv ────────────────────────────────────────
|
||||
|
||||
echo "[2/7] Creating virtualenv..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
source "$VENV_DIR/bin/activate"
|
||||
pip install --upgrade pip --quiet
|
||||
|
||||
# ── Install dependencies ─────────────────────────────────────
|
||||
|
||||
echo "[3/7] Installing dependencies..."
|
||||
pip install --quiet "${SERVER_DIR}[camera,notifications]" 2>&1 | {
|
||||
grep -i 'error\|failed' || true
|
||||
}
|
||||
|
||||
# Remove the installed wled_controller package (PYTHONPATH handles app code)
|
||||
SITE_PACKAGES="$VENV_DIR/lib/python*/site-packages"
|
||||
rm -rf $SITE_PACKAGES/wled_controller* $SITE_PACKAGES/wled*.dist-info 2>/dev/null || true
|
||||
|
||||
# Clean up caches
|
||||
find "$VENV_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$VENV_DIR" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$VENV_DIR" -type d -name test -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
echo "[4/7] Building frontend bundle..."
|
||||
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
|
||||
grep -v 'RemoteException' || true
|
||||
}
|
||||
|
||||
# ── Copy application files ───────────────────────────────────
|
||||
|
||||
echo "[5/7] Copying application files..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
|
||||
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
|
||||
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
|
||||
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# ── Create launcher ──────────────────────────────────────────
|
||||
|
||||
echo "[6/7] Creating launcher..."
|
||||
cat > "$DIST_DIR/run.sh" << 'LAUNCHER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
export PYTHONPATH="$SCRIPT_DIR/app/src"
|
||||
export WLED_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
|
||||
|
||||
mkdir -p "$SCRIPT_DIR/data" "$SCRIPT_DIR/logs"
|
||||
|
||||
echo ""
|
||||
echo " ============================================="
|
||||
echo " LedGrab vVERSION_PLACEHOLDER"
|
||||
echo " Open http://localhost:8080 in your browser"
|
||||
echo " ============================================="
|
||||
echo ""
|
||||
|
||||
source "$SCRIPT_DIR/venv/bin/activate"
|
||||
exec python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
LAUNCHER
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh"
|
||||
chmod +x "$DIST_DIR/run.sh"
|
||||
|
||||
# ── Create tarball ───────────────────────────────────────────
|
||||
|
||||
echo "[7/7] Creating $TAR_NAME..."
|
||||
deactivate 2>/dev/null || true
|
||||
|
||||
TAR_PATH="$BUILD_DIR/$TAR_NAME"
|
||||
(cd "$BUILD_DIR" && tar -czf "$TAR_NAME" "$DIST_NAME")
|
||||
|
||||
TAR_SIZE=$(du -h "$TAR_PATH" | cut -f1)
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
echo " Archive: $TAR_PATH"
|
||||
echo " Size: $TAR_SIZE"
|
||||
echo ""
|
||||
66
contexts/chrome-tools.md
Normal file
66
contexts/chrome-tools.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Chrome Browser Tools (MCP)
|
||||
|
||||
**Read this file when using Chrome browser tools** (`mcp__claude-in-chrome__*`) for testing or debugging the frontend.
|
||||
|
||||
## Tool Loading
|
||||
|
||||
All Chrome MCP tools are deferred — they must be loaded with `ToolSearch` before first use:
|
||||
|
||||
```
|
||||
ToolSearch query="select:mcp__claude-in-chrome__<tool_name>"
|
||||
```
|
||||
|
||||
Commonly used tools:
|
||||
- `tabs_context_mcp` — get available tabs (call first in every session)
|
||||
- `navigate` — go to a URL
|
||||
- `computer` — screenshots, clicks, keyboard, scrolling, zoom
|
||||
- `read_page` — accessibility tree of page elements
|
||||
- `find` — find elements by text/selector
|
||||
- `javascript_tool` — run JS in the page console
|
||||
- `form_input` — fill form fields
|
||||
|
||||
## Browser Tricks
|
||||
|
||||
### Hard Reload (bypass cache)
|
||||
|
||||
After rebuilding the frontend bundle (`npm run build`), do a hard reload to bypass browser cache:
|
||||
|
||||
```
|
||||
computer action="key" text="ctrl+shift+r"
|
||||
```
|
||||
|
||||
This is equivalent to Ctrl+Shift+R and forces the browser to re-fetch all resources, ignoring cached versions.
|
||||
|
||||
### Zoom into UI regions
|
||||
|
||||
Use the `zoom` action to inspect small UI elements (icons, badges, text):
|
||||
|
||||
```
|
||||
computer action="zoom" region=[x0, y0, x1, y1]
|
||||
```
|
||||
|
||||
Coordinates define a rectangle from top-left to bottom-right in viewport pixels.
|
||||
|
||||
### Scroll to element
|
||||
|
||||
Use `scroll_to` with a `ref` from `read_page` to bring an element into view:
|
||||
|
||||
```
|
||||
computer action="scroll_to" ref="ref_123"
|
||||
```
|
||||
|
||||
### Console messages
|
||||
|
||||
Use `read_console_messages` to check for JS errors after page load or interactions.
|
||||
|
||||
### Network requests
|
||||
|
||||
Use `read_network_requests` to inspect API calls, check response codes, and debug loading issues.
|
||||
|
||||
## Typical Verification Workflow
|
||||
|
||||
1. Rebuild bundle: `npm run build` (from `server/` directory)
|
||||
2. Hard reload: `ctrl+shift+r`
|
||||
3. Take screenshot to verify visual changes
|
||||
4. Zoom into specific regions if needed
|
||||
5. Check console for errors
|
||||
269
contexts/frontend.md
Normal file
269
contexts/frontend.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Frontend Rules & Conventions
|
||||
|
||||
**Read this file when working on frontend tasks** (HTML, CSS, JS, locales, templates).
|
||||
|
||||
## CSS Custom Properties (Variables)
|
||||
|
||||
Defined in `server/src/wled_controller/static/css/base.css`.
|
||||
|
||||
**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.
|
||||
|
||||
### Global (`:root`)
|
||||
| Variable | Value | Usage |
|
||||
|---|---|---|
|
||||
| `--primary-color` | `#4CAF50` | **Accent/brand color** — borders, highlights, active states |
|
||||
| `--primary-hover` | `#5cb860` | Hover state for primary elements |
|
||||
| `--primary-contrast` | `#ffffff` | Text on primary background |
|
||||
| `--danger-color` | `#f44336` | Destructive actions, errors |
|
||||
| `--warning-color` | `#ff9800` | Warnings |
|
||||
| `--info-color` | `#2196F3` | Informational highlights |
|
||||
|
||||
### Theme-specific (`[data-theme="dark"]` / `[data-theme="light"]`)
|
||||
| Variable | Dark | Light | Usage |
|
||||
|---|---|---|---|
|
||||
| `--bg-color` | `#1a1a1a` | `#f5f5f5` | Page background |
|
||||
| `--bg-secondary` | `#242424` | `#eee` | Secondary background |
|
||||
| `--card-bg` | `#2d2d2d` | `#ffffff` | Card/panel background |
|
||||
| `--text-color` | `#e0e0e0` | `#333333` | Primary text |
|
||||
| `--text-secondary` | `#999` | `#666` | Secondary text |
|
||||
| `--text-muted` | `#777` | `#999` | Muted/disabled text |
|
||||
| `--border-color` | `#404040` | `#e0e0e0` | Borders, dividers |
|
||||
| `--primary-text-color` | `#66bb6a` | `#3d8b40` | Primary-colored text |
|
||||
| `--success-color` | `#28a745` | `#2e7d32` | Success indicators |
|
||||
| `--shadow-color` | `rgba(0,0,0,0.3)` | `rgba(0,0,0,0.12)` | Box shadows |
|
||||
|
||||
## UI Conventions for Dialogs
|
||||
|
||||
### Hints
|
||||
|
||||
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
|
||||
|
||||
```html
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="my-field" data-i18n="my.label">Label:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
|
||||
<input type="text" id="my-field">
|
||||
</div>
|
||||
```
|
||||
|
||||
Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffix on the label key.
|
||||
|
||||
### Select dropdowns
|
||||
|
||||
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
|
||||
|
||||
### Empty/None option format
|
||||
|
||||
When a selector has an optional entity (e.g., sync clock, processing template, brightness source), the empty option must use the format `None (<description>)` where the description explains what happens when nothing is selected. Use i18n keys, never hardcoded `—` or bare `None`.
|
||||
|
||||
Examples:
|
||||
- `None (no processing template)` — `t('common.none_no_cspt')`
|
||||
- `None (no input source)` — `t('common.none_no_input')`
|
||||
- `None (use own speed)` — `t('common.none_own_speed')`
|
||||
- `None (full brightness)` — `t('color_strip.composite.brightness.none')`
|
||||
- `None (device brightness)` — `t('targets.brightness_vs.none')`
|
||||
|
||||
For `EntitySelect` with `allowNone: true`, pass the same i18n string as `noneLabel`.
|
||||
|
||||
### Enhanced selectors (IconSelect & EntitySelect)
|
||||
|
||||
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
|
||||
|
||||
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.ts`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.ts` for examples.
|
||||
|
||||
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.ts`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.ts` or `_lineSourceEntitySelect` in `advanced-calibration.ts` for examples.
|
||||
|
||||
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
||||
|
||||
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
|
||||
|
||||
### Modal dirty check (discard unsaved changes)
|
||||
|
||||
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.ts`:
|
||||
|
||||
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
|
||||
|
||||
```javascript
|
||||
class MyEditorModal extends Modal {
|
||||
constructor() { super('my-modal-id'); }
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('my-name').value,
|
||||
// ... all form fields
|
||||
};
|
||||
}
|
||||
onForceClose() {
|
||||
// Optional: cleanup (reset flags, clear state, etc.)
|
||||
}
|
||||
}
|
||||
const myModal = new MyEditorModal();
|
||||
```
|
||||
|
||||
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
|
||||
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
|
||||
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
|
||||
5. For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in `snapshotValues()`.
|
||||
|
||||
The base class handles: `isDirty()` comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.
|
||||
|
||||
### Card appearance
|
||||
|
||||
When creating or modifying entity cards (devices, targets, CSS sources, streams, audio/value sources, templates), **always reference existing cards** of the same or similar type for visual consistency. Cards should have:
|
||||
|
||||
- Clone (📋) and Edit (✏️) icon buttons in `.template-card-actions`
|
||||
- Delete (✕) button as `.card-remove-btn`
|
||||
- Property badges in `.stream-card-props` with emoji icons
|
||||
- **Crosslinks**: When a card references another entity (audio source, picture source, capture template, PP template, etc.), make the property badge a clickable link using the `stream-card-link` CSS class and an `onclick` handler calling `navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue)`. Only add the link when the referenced entity is found (to avoid broken navigation). Example: `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${id}')">🎵 Name</span>`
|
||||
|
||||
### Modal footer buttons
|
||||
|
||||
Use **icon-only** buttons (✓ / ✕) matching the device settings modal pattern, **not** text buttons:
|
||||
|
||||
```html
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeMyModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Slider value display
|
||||
|
||||
For range sliders, display the current value **inside the label** (not in a separate wrapper). This keeps the value visible next to the property name:
|
||||
|
||||
```html
|
||||
<label for="my-slider"><span data-i18n="my.label">Speed:</span> <span id="my-slider-display">1.0</span></label>
|
||||
...
|
||||
<input type="range" id="my-slider" min="0" max="10" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('my-slider-display').textContent = this.value">
|
||||
```
|
||||
|
||||
Do **not** use a `range-with-value` wrapper div.
|
||||
|
||||
### Tutorials
|
||||
|
||||
The app has an interactive tutorial system (`static/js/features/tutorials.ts`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
|
||||
- **Getting started** (header-level walkthrough of all tabs and controls)
|
||||
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
|
||||
- **Device card tutorial** and **Calibration tutorial** (context-specific)
|
||||
|
||||
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.ts` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
|
||||
|
||||
## Icons
|
||||
|
||||
**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.**
|
||||
|
||||
- Icon SVG paths are defined in `static/js/core/icon-paths.ts` (Lucide icons, 24×24 viewBox)
|
||||
- Icon constants are exported from `static/js/core/icons.ts` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`)
|
||||
- Use `_svg(path)` wrapper from `icons.ts` to create new icon constants from paths
|
||||
|
||||
When you need a new icon:
|
||||
1. Find the Lucide icon at https://lucide.dev
|
||||
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export
|
||||
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
|
||||
4. Import and use the constant in your feature module
|
||||
|
||||
Common icons: `ICON_START` (play), `ICON_STOP` (power), `ICON_EDIT` (pencil), `ICON_CLONE` (copy), `ICON_TRASH` (trash), `ICON_SETTINGS` (gear), `ICON_TEST` (flask), `ICON_OK` (circle-check), `ICON_WARNING` (triangle-alert), `ICON_HELP` (circle-help), `ICON_LIST_CHECKS` (list-checks), `ICON_CIRCLE_OFF` (circle-off).
|
||||
|
||||
For icon-only buttons, use `btn btn-icon` CSS classes. The `.icon` class inside buttons auto-sizes to 16×16.
|
||||
|
||||
## Localization (i18n)
|
||||
|
||||
**Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.ts` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`).
|
||||
|
||||
- In JS modules: `import { t } from '../core/i18n.ts';` then `showToast(t('my.key'), 'error')`
|
||||
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.t ? t('key') : 'fallback'`
|
||||
- In HTML templates: use `data-i18n="key"` for text content, `data-i18n-title="key"` for title attributes, `data-i18n-aria-label="key"` for aria-labels
|
||||
- Keys follow dotted namespace convention: `feature.context.description` (e.g. `device.error.brightness`, `calibration.saved`)
|
||||
|
||||
### Dynamic content and language changes
|
||||
|
||||
When a feature module generates HTML with baked-in `t()` calls (e.g., toolbar button titles, legend text), that content won't update when the user switches language. To handle this, listen for the `languageChanged` event and re-render:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (_initialized) _reRender();
|
||||
});
|
||||
```
|
||||
|
||||
Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
|
||||
|
||||
## Bundling & Development Workflow
|
||||
|
||||
The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.
|
||||
|
||||
### Files
|
||||
|
||||
- **Entry points:** `static/js/app.ts` (JS), `static/css/all.css` (CSS imports all individual sheets)
|
||||
- **Output:** `static/dist/app.bundle.js` and `static/dist/app.bundle.css` (minified + source maps)
|
||||
- **Config:** `server/esbuild.mjs`
|
||||
- **HTML:** `templates/index.html` references the bundles, not individual source files
|
||||
|
||||
### Commands (from `server/` directory)
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run build` | One-shot bundle + minify (~30ms) |
|
||||
| `npm run watch` | Watch mode — auto-rebuilds on any JS/CSS file save |
|
||||
|
||||
### Development workflow
|
||||
|
||||
1. Run `npm run watch` in a terminal (stays running)
|
||||
2. Edit source files in `static/js/` or `static/css/` as usual
|
||||
3. esbuild rebuilds the bundle automatically (~30ms)
|
||||
4. Refresh the browser to see changes
|
||||
|
||||
### Dependencies
|
||||
|
||||
All JS/CSS dependencies are bundled — **no CDN or external requests** at runtime:
|
||||
|
||||
- **Chart.js** — imported in `perf-charts.ts`, exposed as `window.Chart` for `targets.ts` and `dashboard.ts`
|
||||
- **ELK.js** — imported in `graph-layout.ts` for graph auto-layout
|
||||
- **Fonts** — DM Sans (400-700) and Orbitron (700) woff2 files in `static/fonts/`, declared in `css/fonts.css`
|
||||
|
||||
When adding a new JS dependency: `npm install <pkg>` in `server/`, then `import` it in the relevant source file. esbuild bundles it automatically.
|
||||
|
||||
### Notes
|
||||
|
||||
- The `dist/` directory is gitignored — bundles are build artifacts, run `npm run build` after clone
|
||||
- Source maps are generated so browser DevTools show original source files
|
||||
- The server sets `Cache-Control: no-cache` on static JS/CSS/JSON to prevent stale browser caches during development
|
||||
- GZip compression middleware reduces transfer sizes by ~75%
|
||||
- **Do not edit files in `static/dist/`** — they are overwritten by the build
|
||||
|
||||
## Chrome Browser Tools
|
||||
|
||||
See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.
|
||||
|
||||
## Duration & Numeric Formatting
|
||||
|
||||
### Uptime / duration values
|
||||
|
||||
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
|
||||
|
||||
### Large numbers
|
||||
|
||||
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
|
||||
|
||||
### Preventing layout shift
|
||||
|
||||
Numeric/duration values that update frequently (FPS, uptime, frame counts) **must** use fixed-width styling to prevent layout reflow:
|
||||
|
||||
- `font-family: var(--font-mono, monospace)` — equal-width characters
|
||||
- `font-variant-numeric: tabular-nums` — equal-width digits in proportional fonts
|
||||
- Fixed `width` or `min-width` on the value container
|
||||
- `text-align: right` to anchor the growing edge
|
||||
|
||||
Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(--font-mono)`, `font-weight: 600`, `min-width: 48px`.
|
||||
|
||||
### FPS sparkline charts
|
||||
|
||||
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.ts`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
|
||||
|
||||
## Visual Graph Editor
|
||||
|
||||
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.
|
||||
|
||||
**IMPORTANT:** When adding or modifying entity types, subtypes, or connection fields, the graph editor files **must** be updated in sync. The graph maintains its own maps of entity colors, labels, icons, connection rules, and cache references. See the "Keeping the graph in sync with entity types" section in `graph-editor.md` for the complete checklist.
|
||||
101
contexts/graph-editor.md
Normal file
101
contexts/graph-editor.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Visual Graph Editor
|
||||
|
||||
**Read this file when working on the graph editor** (`static/js/features/graph-editor.ts` and related modules).
|
||||
|
||||
## Architecture
|
||||
|
||||
The graph editor renders all entities (devices, templates, sources, clocks, targets, scenes, automations) as SVG nodes connected by edges in a left-to-right layered layout.
|
||||
|
||||
### Core modules
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `js/features/graph-editor.ts` | Main orchestrator — toolbar, keyboard, search, filter, add-entity menu, port/node drag, minimap |
|
||||
| `js/core/graph-layout.ts` | ELK.js layout, `buildGraph()`, `computePorts()`, entity color/label maps |
|
||||
| `js/core/graph-nodes.ts` | SVG node rendering, overlay buttons, per-node color overrides |
|
||||
| `js/core/graph-edges.ts` | SVG edge rendering (bezier curves, arrowheads, flow dots) |
|
||||
| `js/core/graph-canvas.ts` | Pan/zoom controller with `zoomToPoint()` rAF animation |
|
||||
| `js/core/graph-connections.ts` | CONNECTION_MAP — which fields link entity types, drag-connect/detach logic |
|
||||
| `css/graph-editor.css` | All graph-specific styles |
|
||||
|
||||
### Data flow
|
||||
|
||||
1. `loadGraphEditor()` → `_fetchAllEntities()` fetches all caches in parallel
|
||||
2. `computeLayout(entities)` builds ELK graph, runs layout → returns `{nodes: Map, edges: Array, bounds}`
|
||||
3. `computePorts(nodeMap, edges)` assigns port positions and annotates edges with `fromPortY`/`toPortY`
|
||||
4. Manual position overrides (`_manualPositions`) applied after layout
|
||||
5. `renderEdges()` + `renderNodes()` paint SVG elements
|
||||
6. `GraphCanvas` handles pan/zoom via CSS `transform: scale() translate()`
|
||||
|
||||
### Edge rendering
|
||||
|
||||
Edges always use `_defaultBezier()` (port-aware cubic bezier) — ELK edge routing is ignored because it lacks port awareness, causing misaligned bend points. ELK is only used for node positioning.
|
||||
|
||||
### Port system
|
||||
|
||||
Nodes have input ports (left) and output ports (right), colored by edge type. Port types are ordered vertically: `template > picture > colorstrip > value > audio > clock > scene > device > default`.
|
||||
|
||||
## Keeping the graph in sync with entity types
|
||||
|
||||
**CRITICAL:** When adding or modifying entity types in the system, these graph files MUST be updated:
|
||||
|
||||
### Adding a new entity type
|
||||
|
||||
1. **`graph-layout.ts`** — `ENTITY_COLORS`, `ENTITY_LABELS`, `buildGraph()` (add node loop + edge loops)
|
||||
2. **`graph-layout.ts`** — `edgeType()` function if the new type needs a distinct edge color
|
||||
3. **`graph-nodes.ts`** — `KIND_ICONS` (default icon), `SUBTYPE_ICONS` (subtype-specific icons)
|
||||
4. **`graph-nodes.ts`** — `START_STOP_KINDS` or `TEST_KINDS` sets if the entity supports start/stop or test
|
||||
5. **`graph-connections.ts`** — `CONNECTION_MAP` for drag-connect edge creation
|
||||
6. **`graph-editor.ts`** — `ADD_ENTITY_MAP` (add-entity menu entry with window function)
|
||||
7. **`graph-editor.ts`** — `ALL_CACHES` array (for new-entity-focus watcher)
|
||||
8. **`graph-editor.ts`** — `_fetchAllEntities()` (add cache fetch + pass to `computeLayout`)
|
||||
9. **`core/state.ts`** — Add/export the new DataCache
|
||||
10. **`app.ts`** — Import and window-export the add/edit/clone functions
|
||||
|
||||
### Adding a new field/connection to an existing entity
|
||||
|
||||
1. **`graph-layout.ts`** — `buildGraph()` edges section: add `addEdge()` call
|
||||
2. **`graph-connections.ts`** — `CONNECTION_MAP`: add the field entry
|
||||
3. **`graph-edges.ts`** — `EDGE_COLORS` if a new edge type is needed
|
||||
|
||||
### Adding a new entity subtype
|
||||
|
||||
1. **`graph-nodes.ts`** — `SUBTYPE_ICONS[kind]` — add icon for the new subtype
|
||||
2. **`graph-layout.ts`** — `buildGraph()` — ensure `subtype` is extracted from the entity data
|
||||
|
||||
## Features & keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `/` | Open search |
|
||||
| `F` | Toggle filter |
|
||||
| `F11` | Toggle fullscreen |
|
||||
| `+` | Add entity menu |
|
||||
| `Escape` | Close filter → close search → deselect all |
|
||||
| `Delete` | Delete selected edge or node |
|
||||
| `Arrows / WASD` | Spatial navigation between nodes |
|
||||
| `Ctrl+A` | Select all nodes |
|
||||
|
||||
## Node color overrides
|
||||
|
||||
Per-node colors stored in `localStorage` key `graph_node_colors`. The `getNodeColor(nodeId, kind)` function returns the override or falls back to `ENTITY_COLORS[kind]`. The color bar on the left side of each node is clickable to open a native color picker.
|
||||
|
||||
## Filter system
|
||||
|
||||
The filter bar (toggled with F or toolbar button) filters nodes by name/kind/subtype. Non-matching nodes get the `.graph-filtered-out` CSS class (low opacity, no pointer events). Edges where either endpoint is filtered also dim. Minimap nodes for filtered-out entities become nearly invisible (opacity 0.07).
|
||||
|
||||
## Minimap
|
||||
|
||||
Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage.
|
||||
|
||||
## Node hover FPS tooltip
|
||||
|
||||
Running `output_target` nodes show a floating HTML tooltip on hover (300ms delay). The tooltip is an absolutely-positioned `<div class="graph-node-tooltip">` inside `.graph-container` (not SVG — needed for Chart.js canvas). It displays errors, uptime, and a FPS sparkline (reusing `createFpsSparkline` from `core/chart-utils.ts`). The sparkline is seeded from `/api/v1/system/metrics-history` for instant context.
|
||||
|
||||
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.
|
||||
|
||||
**Node titles** display the full entity name (no truncation). Native SVG `<title>` tooltips are omitted on nodes to avoid conflict with the custom tooltip.
|
||||
|
||||
## New entity focus
|
||||
|
||||
When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation.
|
||||
78
contexts/server-operations.md
Normal file
78
contexts/server-operations.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Server Operations
|
||||
|
||||
**Read this file when restarting, starting, or managing the server process.**
|
||||
|
||||
## Server Modes
|
||||
|
||||
Two independent server modes with separate configs, ports, and data directories:
|
||||
|
||||
| Mode | Command | Config | Port | API Key | Data |
|
||||
| ---- | ------- | ------ | ---- | ------- | ---- |
|
||||
| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
|
||||
|
||||
Both can run simultaneously on different ports.
|
||||
|
||||
## Restart Procedure
|
||||
|
||||
### Real server
|
||||
|
||||
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen-controller\server\restart.ps1"
|
||||
```
|
||||
|
||||
### Demo server
|
||||
|
||||
Find and kill the process on port 8081, then restart:
|
||||
|
||||
```bash
|
||||
# Find PID
|
||||
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
|
||||
# Kill it
|
||||
powershell -Command "Stop-Process -Id <PID> -Force"
|
||||
# Restart
|
||||
cd server && python -m wled_controller.demo
|
||||
```
|
||||
|
||||
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
|
||||
|
||||
**Do NOT use** bash background `&` jobs — they get killed when the shell session ends.
|
||||
|
||||
## When to Restart
|
||||
|
||||
**Restart required** for changes to:
|
||||
- API routes (`api/routes/`, `api/schemas/`)
|
||||
- Core logic (`core/*.py`)
|
||||
- Configuration (`config.py`)
|
||||
- Utilities (`utils/*.py`)
|
||||
- Data models (`storage/`)
|
||||
|
||||
**No restart needed** for:
|
||||
- Static files (`static/js/`, `static/css/`) — but **must rebuild bundle**: `cd server && npm run build`
|
||||
- Locale files (`static/locales/*.json`) — loaded by frontend
|
||||
- Documentation files (`*.md`)
|
||||
|
||||
## Auto-Reload Note
|
||||
|
||||
Auto-reload is disabled (`reload=False` in `main.py`) due to watchfiles causing an infinite reload loop. Manual restart is required after server code changes.
|
||||
|
||||
## Demo Mode Awareness
|
||||
|
||||
**When adding new entity types, engines, device providers, or stores — keep demo mode in sync:**
|
||||
|
||||
1. **New entity stores**: Add the store's file path to `StorageConfig` in `config.py` — `model_post_init()` auto-rewrites `data/` to `data/demo/` paths when demo is active.
|
||||
2. **New capture engines**: Verify demo mode filtering works — demo engines use `is_demo_mode()` gate in `is_available()`.
|
||||
3. **New audio engines**: Same as capture engines — `is_available()` must respect `is_demo_mode()`.
|
||||
4. **New device providers**: Gate discovery with `is_demo_mode()` like `DemoDeviceProvider.discover()`.
|
||||
5. **New seed data**: Update `server/src/wled_controller/core/demo_seed.py` to include sample entities.
|
||||
6. **Frontend indicators**: Demo state exposed via `GET /api/v1/version` -> `demo_mode: bool`. Frontend stores it as `demoMode` in app state and sets `document.body.dataset.demo = 'true'`.
|
||||
7. **Backup/Restore**: New stores added to `STORE_MAP` in `system.py` automatically work in demo mode since the data directory is already isolated.
|
||||
|
||||
### Key files
|
||||
|
||||
- Config flag: `server/src/wled_controller/config.py` -> `Config.demo`, `is_demo_mode()`
|
||||
- Demo engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
|
||||
- Demo devices: `core/devices/demo_provider.py`
|
||||
- Seed data: `core/demo_seed.py`
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
Platform.SENSOR,
|
||||
Platform.NUMBER,
|
||||
@@ -111,15 +114,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
}
|
||||
|
||||
# Track target and scene IDs to detect changes
|
||||
initial_target_ids = set(
|
||||
known_target_ids = set(
|
||||
coordinator.data.get("targets", {}).keys() if coordinator.data else []
|
||||
)
|
||||
initial_scene_ids = set(
|
||||
known_scene_ids = set(
|
||||
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
|
||||
)
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
"""Manage WS connections and detect target list changes."""
|
||||
nonlocal known_target_ids, known_scene_ids
|
||||
|
||||
if not coordinator.data:
|
||||
return
|
||||
|
||||
@@ -131,8 +136,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
state = target_data.get("state") or {}
|
||||
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
|
||||
if state.get("processing"):
|
||||
if target_id not in ws_manager._connections:
|
||||
hass.async_create_task(ws_manager.start_listening(target_id))
|
||||
else:
|
||||
if target_id in ws_manager._connections:
|
||||
hass.async_create_task(ws_manager.stop_listening(target_id))
|
||||
|
||||
# Reload if target or scene list changed
|
||||
@@ -140,7 +147,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
current_scene_ids = set(
|
||||
p["id"] for p in coordinator.data.get("scene_presets", [])
|
||||
)
|
||||
if current_ids != initial_target_ids or current_scene_ids != initial_scene_ids:
|
||||
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
|
||||
known_target_ids = current_ids
|
||||
known_scene_ids = current_scene_ids
|
||||
_LOGGER.info("Target or scene list changed, reloading integration")
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -148,6 +157,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
# Register set_leds service (once across all entries)
|
||||
async def handle_set_leds(call) -> None:
|
||||
"""Handle the set_leds service call."""
|
||||
source_id = call.data["source_id"]
|
||||
segments = call.data["segments"]
|
||||
# Route to the coordinator that owns this source
|
||||
for entry_data in hass.data[DOMAIN].values():
|
||||
coord = entry_data.get(DATA_COORDINATOR)
|
||||
if not coord or not coord.data:
|
||||
continue
|
||||
source_ids = {
|
||||
s["id"] for s in coord.data.get("css_sources", [])
|
||||
}
|
||||
if source_id in source_ids:
|
||||
await coord.push_segments(source_id, segments)
|
||||
return
|
||||
_LOGGER.error("No server found with source_id %s", source_id)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, "set_leds"):
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"set_leds",
|
||||
handle_set_leds,
|
||||
schema=vol.Schema({
|
||||
vol.Required("source_id"): str,
|
||||
vol.Required("segments"): list,
|
||||
}),
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
@@ -163,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
# Unregister service if no entries remain
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.services.async_remove(DOMAIN, "set_leds")
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -65,10 +65,9 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity):
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.data:
|
||||
return False
|
||||
return any(
|
||||
p["id"] == self._preset_id
|
||||
for p in self.coordinator.data.get("scene_presets", [])
|
||||
)
|
||||
return self._preset_id in {
|
||||
p["id"] for p in self.coordinator.data.get("scene_presets", [])
|
||||
}
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Activate the scene preset."""
|
||||
|
||||
@@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
self.api_key = api_key
|
||||
self.server_version = "unknown"
|
||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
|
||||
self._pattern_cache: dict[str, list[dict]] = {}
|
||||
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
kc_settings = target.get("key_colors_settings") or {}
|
||||
template_id = kc_settings.get("pattern_template_id", "")
|
||||
if template_id:
|
||||
result["rectangles"] = await self._get_rectangles(
|
||||
result["rectangles"] = await self._fetch_rectangles(
|
||||
template_id
|
||||
)
|
||||
else:
|
||||
@@ -136,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/health",
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -150,7 +150,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -161,7 +161,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
@@ -171,27 +171,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _get_rectangles(self, template_id: str) -> list[dict]:
|
||||
"""Get rectangles for a pattern template, using cache."""
|
||||
if template_id in self._pattern_cache:
|
||||
return self._pattern_cache[template_id]
|
||||
|
||||
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
|
||||
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
rectangles = data.get("rectangles", [])
|
||||
self._pattern_cache[template_id] = rectangles
|
||||
return rectangles
|
||||
return data.get("rectangles", [])
|
||||
except Exception as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch pattern template %s: %s", template_id, err
|
||||
@@ -204,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -213,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.warning("Failed to fetch devices: %s", err)
|
||||
return {}
|
||||
|
||||
devices_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for device in devices:
|
||||
# Fetch brightness for all capable devices in parallel
|
||||
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
|
||||
device_id = device["id"]
|
||||
entry: dict[str, Any] = {"info": device, "brightness": None}
|
||||
|
||||
if "brightness_control" in (device.get("capabilities") or []):
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
bri_data = await resp.json()
|
||||
@@ -234,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
"Failed to fetch brightness for device %s: %s",
|
||||
device_id, err,
|
||||
)
|
||||
return device_id, entry
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(fetch_device_entry(d) for d in devices),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
devices_data: dict[str, dict[str, Any]] = {}
|
||||
for r in results:
|
||||
if isinstance(r, Exception):
|
||||
_LOGGER.warning("Device fetch failed: %s", r)
|
||||
continue
|
||||
device_id, entry = r
|
||||
devices_data[device_id] = entry
|
||||
|
||||
return devices_data
|
||||
@@ -245,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"brightness": brightness},
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -262,7 +267,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
f"{self.server_url}/api/v1/devices/{device_id}/color",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"color": color},
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -280,7 +285,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"key_colors_settings": {"brightness": brightness_float}},
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -297,7 +302,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/color-strip-sources",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -312,7 +317,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/value-sources",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -327,7 +332,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.get(
|
||||
f"{self.server_url}/api/v1/scene-presets",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
@@ -336,12 +341,44 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.warning("Failed to fetch scene presets: %s", err)
|
||||
return []
|
||||
|
||||
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
|
||||
"""Push flat color array to an api_input CSS source."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"colors": colors},
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status not in (200, 204):
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to push colors to source %s: %s %s",
|
||||
source_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
|
||||
"""Push segment data to an api_input CSS source."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json={"segments": segments},
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status not in (200, 204):
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to push segments to source %s: %s %s",
|
||||
source_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def activate_scene(self, preset_id: str) -> None:
|
||||
"""Activate a scene preset."""
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -352,13 +389,29 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
resp.raise_for_status()
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def update_source(self, source_id: str, **kwargs: Any) -> None:
|
||||
"""Update a color strip source's fields."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json=kwargs,
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
_LOGGER.error(
|
||||
"Failed to update source %s: %s %s",
|
||||
source_id, resp.status, body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def update_target(self, target_id: str, **kwargs: Any) -> None:
|
||||
"""Update a output target's fields."""
|
||||
"""Update an output target's fields."""
|
||||
async with self.session.put(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}",
|
||||
headers={**self._auth_headers, "Content-Type": "application/json"},
|
||||
json=kwargs,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -374,7 +427,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 409:
|
||||
_LOGGER.debug("Target %s already processing", target_id)
|
||||
@@ -392,7 +445,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
async with self.session.post(
|
||||
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
|
||||
headers=self._auth_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
|
||||
timeout=self._timeout,
|
||||
) as resp:
|
||||
if resp.status == 409:
|
||||
_LOGGER.debug("Target %s already stopped", target_id)
|
||||
|
||||
151
custom_components/wled_screen_controller/light.py
Normal file
151
custom_components/wled_screen_controller/light.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Light platform for LED Screen Controller (api_input CSS sources)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_RGB_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, DATA_COORDINATOR
|
||||
from .coordinator import WLEDScreenControllerCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LED Screen Controller api_input lights."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
|
||||
|
||||
entities = []
|
||||
if coordinator.data:
|
||||
for source in coordinator.data.get("css_sources", []):
|
||||
if source.get("source_type") == "api_input":
|
||||
entities.append(
|
||||
ApiInputLight(coordinator, source, entry.entry_id)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ApiInputLight(CoordinatorEntity, LightEntity):
|
||||
"""Representation of an api_input CSS source as a light entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_color_mode = ColorMode.RGB
|
||||
_attr_supported_color_modes = {ColorMode.RGB}
|
||||
_attr_translation_key = "api_input_light"
|
||||
_attr_icon = "mdi:led-strip-variant"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: WLEDScreenControllerCoordinator,
|
||||
source: dict[str, Any],
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(coordinator)
|
||||
self._source_id: str = source["id"]
|
||||
self._source_name: str = source.get("name", self._source_id)
|
||||
self._entry_id = entry_id
|
||||
self._attr_unique_id = f"{self._source_id}_light"
|
||||
|
||||
# Restore state from fallback_color
|
||||
fallback = self._get_fallback_color()
|
||||
is_off = fallback == [0, 0, 0]
|
||||
self._is_on: bool = not is_off
|
||||
self._rgb_color: tuple[int, int, int] = (
|
||||
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
|
||||
)
|
||||
self._brightness: int = 255
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return device information — one virtual device per api_input source."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._source_id)},
|
||||
"name": self._source_name,
|
||||
"manufacturer": "WLED Screen Controller",
|
||||
"model": "API Input CSS Source",
|
||||
}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the entity name."""
|
||||
return self._source_name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the light is on."""
|
||||
return self._is_on
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int]:
|
||||
"""Return the current RGB color."""
|
||||
return self._rgb_color
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the current brightness (0-255)."""
|
||||
return self._brightness
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light, optionally setting color and brightness."""
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
self._rgb_color = kwargs[ATTR_RGB_COLOR]
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
# Scale RGB by brightness
|
||||
scale = self._brightness / 255
|
||||
r, g, b = self._rgb_color
|
||||
scaled = [round(r * scale), round(g * scale), round(b * scale)]
|
||||
|
||||
await self.coordinator.push_segments(
|
||||
self._source_id,
|
||||
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
|
||||
)
|
||||
# Update fallback_color so the color persists beyond the timeout
|
||||
await self.coordinator.update_source(
|
||||
self._source_id, fallback_color=scaled,
|
||||
)
|
||||
self._is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light by pushing black and setting fallback to black."""
|
||||
off_color = [0, 0, 0]
|
||||
await self.coordinator.push_segments(
|
||||
self._source_id,
|
||||
[{"start": 0, "length": 9999, "mode": "solid", "color": off_color}],
|
||||
)
|
||||
await self.coordinator.update_source(
|
||||
self._source_id, fallback_color=off_color,
|
||||
)
|
||||
self._is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _get_fallback_color(self) -> list[int]:
|
||||
"""Read fallback_color from the source config in coordinator data."""
|
||||
if not self.coordinator.data:
|
||||
return [0, 0, 0]
|
||||
for source in self.coordinator.data.get("css_sources", []):
|
||||
if source.get("id") == self._source_id:
|
||||
fallback = source.get("fallback_color")
|
||||
if fallback and len(fallback) >= 3:
|
||||
return list(fallback[:3])
|
||||
break
|
||||
return [0, 0, 0]
|
||||
@@ -4,9 +4,9 @@
|
||||
"codeowners": ["@alexeidolgolyov"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/yourusername/wled-screen-controller",
|
||||
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/yourusername/wled-screen-controller/issues",
|
||||
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues",
|
||||
"requirements": ["aiohttp>=3.9.0"],
|
||||
"version": "0.2.0"
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
return self._target_id in self.coordinator.data.get("targets", {})
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
source_id = self._name_to_id(option)
|
||||
source_id = self._name_to_id_map().get(option)
|
||||
if source_id is None:
|
||||
_LOGGER.error("CSS source not found: %s", option)
|
||||
return
|
||||
@@ -104,12 +104,9 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
self._target_id, color_strip_source_id=source_id
|
||||
)
|
||||
|
||||
def _name_to_id(self, name: str) -> str | None:
|
||||
def _name_to_id_map(self) -> dict[str, str]:
|
||||
sources = (self.coordinator.data or {}).get("css_sources") or []
|
||||
for s in sources:
|
||||
if s["name"] == name:
|
||||
return s["id"]
|
||||
return None
|
||||
return {s["name"]: s["id"] for s in sources}
|
||||
|
||||
|
||||
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
@@ -167,17 +164,14 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
|
||||
if option == NONE_OPTION:
|
||||
source_id = ""
|
||||
else:
|
||||
source_id = self._name_to_id(option)
|
||||
name_map = {
|
||||
s["name"]: s["id"]
|
||||
for s in (self.coordinator.data or {}).get("value_sources") or []
|
||||
}
|
||||
source_id = name_map.get(option)
|
||||
if source_id is None:
|
||||
_LOGGER.error("Value source not found: %s", option)
|
||||
return
|
||||
await self.coordinator.update_target(
|
||||
self._target_id, brightness_value_source_id=source_id
|
||||
)
|
||||
|
||||
def _name_to_id(self, name: str) -> str | None:
|
||||
sources = (self.coordinator.data or {}).get("value_sources") or []
|
||||
for s in sources:
|
||||
if s["name"] == name:
|
||||
return s["id"]
|
||||
return None
|
||||
|
||||
19
custom_components/wled_screen_controller/services.yaml
Normal file
19
custom_components/wled_screen_controller/services.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
set_leds:
|
||||
name: Set LEDs
|
||||
description: Push segment data to an api_input color strip source
|
||||
fields:
|
||||
source_id:
|
||||
name: Source ID
|
||||
description: The api_input CSS source ID (e.g., css_abc12345)
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
segments:
|
||||
name: Segments
|
||||
description: >
|
||||
List of segment objects. Each segment has: start (int), length (int),
|
||||
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
|
||||
colors ([[R,G,B],...] for per_pixel/gradient)
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
@@ -31,6 +31,11 @@
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
@@ -66,5 +71,21 @@
|
||||
"name": "Brightness Source"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_leds": {
|
||||
"name": "Set LEDs",
|
||||
"description": "Push segment data to an api_input color strip source.",
|
||||
"fields": {
|
||||
"source_id": {
|
||||
"name": "Source ID",
|
||||
"description": "The api_input CSS source ID (e.g., css_abc12345)."
|
||||
},
|
||||
"segments": {
|
||||
"name": "Segments",
|
||||
"description": "List of segment objects with start, length, mode, and color/colors fields."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Light"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Processing"
|
||||
@@ -58,9 +63,12 @@
|
||||
"name": "Brightness"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "Light"
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Color Strip Source"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Brightness Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
"name": "{scene_name}"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"api_input_light": {
|
||||
"name": "Подсветка"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"processing": {
|
||||
"name": "Обработка"
|
||||
@@ -58,9 +63,12 @@
|
||||
"name": "Яркость"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "Подсветка"
|
||||
"select": {
|
||||
"color_strip_source": {
|
||||
"name": "Источник цветовой полосы"
|
||||
},
|
||||
"brightness_source": {
|
||||
"name": "Источник яркости"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
plans/demo-mode/CONTEXT.md
Normal file
30
plans/demo-mode/CONTEXT.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Feature Context: Demo Mode
|
||||
|
||||
## Current State
|
||||
Starting implementation. No changes made yet.
|
||||
|
||||
## Key Architecture Notes
|
||||
- `EngineRegistry` (class-level dict) holds capture engines, auto-registered in `capture_engines/__init__.py`
|
||||
- `AudioEngineRegistry` (class-level dict) holds audio engines, auto-registered in `audio/__init__.py`
|
||||
- `LEDDeviceProvider` instances registered via `register_provider()` in `led_client.py`
|
||||
- Already has `MockDeviceProvider` + `MockClient` (device type "mock") for testing
|
||||
- Config is `pydantic_settings.BaseSettings` in `config.py`, loaded from YAML + env vars
|
||||
- Frontend header in `templates/index.html` line 27-31: title + version badge
|
||||
- Frontend bundle: `cd server && npm run build` (esbuild)
|
||||
- Data stored as JSON in `data/` directory, paths configured via `StorageConfig`
|
||||
|
||||
## Temporary Workarounds
|
||||
- None yet
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 1 (config flag) is foundational — all other phases depend on `is_demo_mode()`
|
||||
- Phase 2 & 3 (engines) can be done independently of each other
|
||||
- Phase 4 (seed data) depends on knowing what entities to create, which is informed by phases 2-3
|
||||
- Phase 5 (frontend) depends on the system info API field from phase 1
|
||||
- Phase 6 (engine resolution) depends on engines existing from phases 2-3
|
||||
|
||||
## Implementation Notes
|
||||
- Demo mode activated via `WLED_DEMO=true` env var or `demo: true` in YAML config
|
||||
- Isolated data directory `data/demo/` keeps demo entities separate from real config
|
||||
- Demo engines use `ENGINE_TYPE = "demo"` and are always registered but return `is_available() = True` only in demo mode
|
||||
- The existing `MockDeviceProvider`/`MockClient` can be reused or extended for demo device output
|
||||
44
plans/demo-mode/PLAN.md
Normal file
44
plans/demo-mode/PLAN.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Feature: Demo Mode
|
||||
|
||||
**Branch:** `feature/demo-mode`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-03-20
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Big Bang
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
Add a demo mode that allows users to explore and test the app without real hardware. Virtual capture engines, audio engines, and device providers replace real hardware. An isolated data directory with seed data provides a fully populated sandbox. A visual indicator in the UI makes it clear the app is running in demo mode.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build (frontend):** `cd server && npm run build`
|
||||
- **Typecheck (frontend):** `cd server && npm run typecheck`
|
||||
- **Test (backend):** `cd server && python -m pytest ../tests/ -x`
|
||||
- **Server start:** `cd server && python -m wled_controller.main`
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1: Demo Mode Config & Flag [domain: backend] → [subplan](./phase-1-config-flag.md)
|
||||
- [x] Phase 2: Virtual Capture Engine [domain: backend] → [subplan](./phase-2-virtual-capture-engine.md)
|
||||
- [x] Phase 3: Virtual Audio Engine [domain: backend] → [subplan](./phase-3-virtual-audio-engine.md)
|
||||
- [x] Phase 4: Demo Device Provider & Seed Data [domain: backend] → [subplan](./phase-4-demo-device-seed-data.md)
|
||||
- [x] Phase 5: Frontend Demo Indicator & Sandbox UX [domain: fullstack] → [subplan](./phase-5-frontend-demo-ux.md)
|
||||
- [x] Phase 6: Demo-only Engine Resolution [domain: backend] → [subplan](./phase-6-engine-resolution.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Config & Flag | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 2: Virtual Capture Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 3: Virtual Audio Engine | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 4: Demo Device & Seed Data | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 5: Frontend Demo UX | fullstack | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
| Phase 6: Engine Resolution | backend | ✅ Done | ✅ | ✅ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
42
plans/demo-mode/phase-1-config-flag.md
Normal file
42
plans/demo-mode/phase-1-config-flag.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Phase 1: Demo Mode Config & Flag
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Add a `demo` boolean flag to the application configuration and expose it to the frontend via the system info API. When demo mode is active, the server uses an isolated data directory so demo entities don't pollute real user data.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Add `demo: bool = False` field to `Config` class in `config.py`
|
||||
- [ ] Task 2: Add a module-level helper `is_demo_mode() -> bool` in `config.py` for easy import
|
||||
- [ ] Task 3: Modify `StorageConfig` path resolution: when `demo=True`, prefix all storage paths with `data/demo/` instead of `data/`
|
||||
- [ ] Task 4: Expose `demo_mode: bool` in the existing `GET /api/v1/system/info` endpoint response
|
||||
- [ ] Task 5: Add `WLED_DEMO=true` env var support (already handled by pydantic-settings env prefix `WLED_`)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/config.py` — Add `demo` field, `is_demo_mode()` helper, storage path override
|
||||
- `server/src/wled_controller/api/routes/system.py` — Add `demo_mode` to system info response
|
||||
- `server/src/wled_controller/api/schemas/system.py` — Add `demo_mode` field to response schema
|
||||
|
||||
## Acceptance Criteria
|
||||
- `Config(demo=True)` is accepted; default is `False`
|
||||
- `WLED_DEMO=true` activates demo mode
|
||||
- `is_demo_mode()` returns the correct value
|
||||
- When demo mode is on, all storage files resolve under `data/demo/`
|
||||
- `GET /api/v1/system/info` includes `demo_mode: true/false`
|
||||
|
||||
## Notes
|
||||
- The env var will be `WLED_DEMO` because of `env_prefix="WLED_"` in pydantic-settings
|
||||
- Storage path override should happen at `Config` construction time, not lazily
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
48
plans/demo-mode/phase-2-virtual-capture-engine.md
Normal file
48
plans/demo-mode/phase-2-virtual-capture-engine.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Phase 2: Virtual Capture Engine
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a `DemoCaptureEngine` that provides virtual displays and produces animated test pattern frames, allowing screen capture workflows to function in demo mode without real monitors.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/capture_engines/demo_engine.py` with `DemoCaptureEngine` and `DemoCaptureStream`
|
||||
- [ ] Task 2: `DemoCaptureEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000` (highest in demo mode)
|
||||
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||
- [ ] Task 4: `get_available_displays()` returns 3 virtual displays:
|
||||
- "Demo Display 1080p" (1920×1080)
|
||||
- "Demo Ultrawide" (3440×1440)
|
||||
- "Demo Portrait" (1080×1920)
|
||||
- [ ] Task 5: `DemoCaptureStream.capture_frame()` produces animated test patterns:
|
||||
- Horizontally scrolling rainbow gradient (simple, visually clear)
|
||||
- Uses `time.time()` for animation so frames change over time
|
||||
- Returns proper `ScreenCapture` with RGB numpy array
|
||||
- [ ] Task 6: Register `DemoCaptureEngine` in `capture_engines/__init__.py`
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/capture_engines/demo_engine.py` — New file: DemoCaptureEngine + DemoCaptureStream
|
||||
- `server/src/wled_controller/core/capture_engines/__init__.py` — Register DemoCaptureEngine
|
||||
|
||||
## Acceptance Criteria
|
||||
- `DemoCaptureEngine.is_available()` is True only in demo mode
|
||||
- Virtual displays appear in the display list API when in demo mode
|
||||
- `capture_frame()` returns valid RGB frames that change over time
|
||||
- Engine is properly registered in EngineRegistry
|
||||
|
||||
## Notes
|
||||
- Test patterns should be computationally cheap (no heavy image processing)
|
||||
- Use numpy operations for pattern generation (vectorized, fast)
|
||||
- Frame dimensions must match the virtual display dimensions
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
47
plans/demo-mode/phase-3-virtual-audio-engine.md
Normal file
47
plans/demo-mode/phase-3-virtual-audio-engine.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Phase 3: Virtual Audio Engine
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a `DemoAudioEngine` that provides virtual audio devices and produces synthetic audio data, enabling audio-reactive visualizations in demo mode.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/audio/demo_engine.py` with `DemoAudioEngine` and `DemoAudioCaptureStream`
|
||||
- [ ] Task 2: `DemoAudioEngine.ENGINE_TYPE = "demo"`, `ENGINE_PRIORITY = 1000`
|
||||
- [ ] Task 3: `is_available()` returns `True` only when `is_demo_mode()` is True
|
||||
- [ ] Task 4: `enumerate_devices()` returns 2 virtual devices:
|
||||
- "Demo Microphone" (input, not loopback)
|
||||
- "Demo System Audio" (loopback)
|
||||
- [ ] Task 5: `DemoAudioCaptureStream` implements:
|
||||
- `channels = 2`, `sample_rate = 44100`, `chunk_size = 1024`
|
||||
- `read_chunk()` produces synthetic audio: a mix of sine waves with slowly varying frequencies to simulate music-like beat patterns
|
||||
- Returns proper float32 ndarray
|
||||
- [ ] Task 6: Register `DemoAudioEngine` in `audio/__init__.py`
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/audio/demo_engine.py` — New file: DemoAudioEngine + DemoAudioCaptureStream
|
||||
- `server/src/wled_controller/core/audio/__init__.py` — Register DemoAudioEngine
|
||||
|
||||
## Acceptance Criteria
|
||||
- `DemoAudioEngine.is_available()` is True only in demo mode
|
||||
- Virtual audio devices appear in audio device enumeration when in demo mode
|
||||
- `read_chunk()` returns valid float32 audio data that varies over time
|
||||
- Audio analyzer produces non-trivial frequency band data from the synthetic signal
|
||||
|
||||
## Notes
|
||||
- Synthetic audio should produce interesting FFT results (multiple frequencies, amplitude modulation)
|
||||
- Keep it computationally lightweight
|
||||
- Must conform to `AudioCaptureStreamBase` interface exactly
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
54
plans/demo-mode/phase-4-demo-device-seed-data.md
Normal file
54
plans/demo-mode/phase-4-demo-device-seed-data.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Phase 4: Demo Device Provider & Seed Data
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Create a demo device provider that exposes discoverable virtual LED devices, and build a seed data generator that populates the demo data directory with sample entities on first run.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create `server/src/wled_controller/core/devices/demo_provider.py` — `DemoDeviceProvider` extending `LEDDeviceProvider`:
|
||||
- `device_type = "demo"`
|
||||
- `capabilities = {"manual_led_count", "power_control", "brightness_control", "static_color"}`
|
||||
- `create_client()` returns a `MockClient` (reuse existing)
|
||||
- `discover()` returns 3 pre-defined virtual devices:
|
||||
- "Demo LED Strip" (60 LEDs, ip="demo-strip")
|
||||
- "Demo LED Matrix" (256 LEDs / 16×16, ip="demo-matrix")
|
||||
- "Demo LED Ring" (24 LEDs, ip="demo-ring")
|
||||
- `check_health()` always returns online with simulated ~2ms latency
|
||||
- `validate_device()` returns `{"led_count": <from url>}`
|
||||
- [ ] Task 2: Register `DemoDeviceProvider` in `led_client.py` `_register_builtin_providers()`
|
||||
- [ ] Task 3: Create `server/src/wled_controller/core/demo_seed.py` — seed data generator:
|
||||
- Function `seed_demo_data(storage_config: StorageConfig)` that checks if demo data dir is empty and populates it
|
||||
- Seed entities: 3 devices (matching discover results), 2 output targets, 2 picture sources (using demo engine), 2 CSS sources (gradient + color_cycle), 1 audio source (using demo engine), 1 scene preset, 1 automation
|
||||
- Use proper ID formats matching existing conventions (e.g., `dev_<hex>`, `tgt_<hex>`, etc.)
|
||||
- [ ] Task 4: Call `seed_demo_data()` during server startup in `main.py` when demo mode is active (before stores are loaded)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/devices/demo_provider.py` — New: DemoDeviceProvider
|
||||
- `server/src/wled_controller/core/devices/led_client.py` — Register DemoDeviceProvider
|
||||
- `server/src/wled_controller/core/demo_seed.py` — New: seed data generator
|
||||
- `server/src/wled_controller/main.py` — Call seed on demo startup
|
||||
|
||||
## Acceptance Criteria
|
||||
- Demo devices appear in discovery results when in demo mode
|
||||
- Seed data populates `data/demo/` with valid JSON files on first demo run
|
||||
- Subsequent demo runs don't overwrite existing demo data
|
||||
- All seeded entities load correctly in stores
|
||||
|
||||
## Notes
|
||||
- Seed data must match the exact schema expected by each store (look at existing JSON files for format)
|
||||
- Use the entity dataclass `to_dict()` / store patterns to generate valid data
|
||||
- Demo discovery should NOT appear when not in demo mode
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
50
plans/demo-mode/phase-5-frontend-demo-ux.md
Normal file
50
plans/demo-mode/phase-5-frontend-demo-ux.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Phase 5: Frontend Demo Indicator & Sandbox UX
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Add visual indicators in the frontend that clearly communicate demo mode status to the user, including a badge, dismissible banner, and engine labeling.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Add `demo_mode` field to system info API response schema (if not already done in Phase 1)
|
||||
- [ ] Task 2: In frontend initialization (`app.ts` or `state.ts`), fetch system info and store `demoMode` in app state
|
||||
- [ ] Task 3: Add `<span class="demo-badge" id="demo-badge" style="display:none">DEMO</span>` next to app title in `index.html` header
|
||||
- [ ] Task 4: CSS for `.demo-badge`: amber/yellow pill shape, subtle pulse animation, clearly visible but not distracting
|
||||
- [ ] Task 5: On app load, if `demoMode` is true: show badge, set `document.body.dataset.demo = 'true'`
|
||||
- [ ] Task 6: Add a dismissible demo banner at the top of the page: "You're in demo mode — all devices and data are virtual. No real hardware is used." with a dismiss (×) button. Store dismissal in localStorage.
|
||||
- [ ] Task 7: Add i18n keys for demo badge and banner text in `en.json`, `ru.json`, `zh.json`
|
||||
- [ ] Task 8: In engine/display dropdowns, demo engines should display with "Demo: " prefix for clarity
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/templates/index.html` — Demo badge + banner HTML
|
||||
- `server/src/wled_controller/static/css/app.css` — Demo badge + banner styles
|
||||
- `server/src/wled_controller/static/js/app.ts` — Demo mode detection and UI toggle
|
||||
- `server/src/wled_controller/static/js/core/state.ts` — Store demo mode flag
|
||||
- `server/src/wled_controller/static/locales/en.json` — i18n keys
|
||||
- `server/src/wled_controller/static/locales/ru.json` — i18n keys
|
||||
- `server/src/wled_controller/static/locales/zh.json` — i18n keys
|
||||
|
||||
## Acceptance Criteria
|
||||
- Demo badge visible next to "LED Grab" title when in demo mode
|
||||
- Demo badge hidden when not in demo mode
|
||||
- Banner appears on first demo visit, can be dismissed, stays dismissed across refreshes
|
||||
- Engine dropdowns clearly label demo engines
|
||||
- All text is localized
|
||||
|
||||
## Notes
|
||||
- Badge should use `--warning-color` or a custom amber for the pill
|
||||
- Banner should be a thin strip, not intrusive
|
||||
- `localStorage` key: `demo-banner-dismissed`
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
46
plans/demo-mode/phase-6-engine-resolution.md
Normal file
46
plans/demo-mode/phase-6-engine-resolution.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Phase 6: Demo-only Engine Resolution
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** backend
|
||||
|
||||
## Objective
|
||||
Ensure demo engines are the primary/preferred engines in demo mode, and are hidden when not in demo mode. This makes demo mode act as a "virtual platform" where only demo engines resolve.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Modify `EngineRegistry.get_available_engines()` to filter out engines with `ENGINE_TYPE == "demo"` when not in demo mode (they report `is_available()=False` anyway, but belt-and-suspenders)
|
||||
- [ ] Task 2: Modify `AudioEngineRegistry.get_available_engines()` similarly
|
||||
- [ ] Task 3: In demo mode, `get_best_available_engine()` should return the demo engine (already handled by priority=1000, but verify)
|
||||
- [ ] Task 4: Modify the `GET /api/v1/config/displays` endpoint: in demo mode, default to demo engine displays if no engine_type specified
|
||||
- [ ] Task 5: Modify the audio engine listing endpoint similarly
|
||||
- [ ] Task 6: Ensure `DemoDeviceProvider.discover()` only returns devices when in demo mode
|
||||
- [ ] Task 7: End-to-end verification: start server in demo mode, verify only demo engines/devices appear in API responses
|
||||
|
||||
## Files to Modify/Create
|
||||
- `server/src/wled_controller/core/capture_engines/factory.py` — Filter demo engines
|
||||
- `server/src/wled_controller/core/audio/factory.py` — Filter demo engines
|
||||
- `server/src/wled_controller/api/routes/system.py` — Display endpoint defaults
|
||||
- `server/src/wled_controller/api/routes/audio_templates.py` — Audio engine listing
|
||||
- `server/src/wled_controller/core/devices/demo_provider.py` — Guard discover()
|
||||
|
||||
## Acceptance Criteria
|
||||
- In demo mode: demo engines are primary, real engines may also be listed but demo is default
|
||||
- Not in demo mode: demo engines are completely hidden from all API responses
|
||||
- Display list defaults to demo displays in demo mode
|
||||
- Audio device list defaults to demo devices in demo mode
|
||||
|
||||
## Notes
|
||||
- This is the "demo OS identifier" concept — demo mode acts as a virtual platform
|
||||
- Be careful not to break existing behavior when demo=False (default)
|
||||
- The demo engines already have `is_available() = is_demo_mode()`, so the main concern is UI defaults
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completion -->
|
||||
54
server/.env.example
Normal file
54
server/.env.example
Normal file
@@ -0,0 +1,54 @@
|
||||
# WLED Screen Controller — Environment Variables
|
||||
# Copy this file to .env and adjust values as needed.
|
||||
# All variables use the WLED_ prefix with __ (double underscore) as the nesting delimiter.
|
||||
|
||||
# ── Server ──────────────────────────────────────────────
|
||||
# WLED_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
|
||||
# WLED_SERVER__PORT=8080 # Listen port (default: 8080)
|
||||
# WLED_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
|
||||
# WLED_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins
|
||||
|
||||
# ── Authentication ──────────────────────────────────────
|
||||
# API keys are required. Format: JSON object {"label": "key"}.
|
||||
# WLED_AUTH__API_KEYS={"dev": "development-key-change-in-production"}
|
||||
|
||||
# ── Storage paths ───────────────────────────────────────
|
||||
# All paths are relative to the server working directory.
|
||||
# WLED_STORAGE__DEVICES_FILE=data/devices.json
|
||||
# WLED_STORAGE__TEMPLATES_FILE=data/capture_templates.json
|
||||
# WLED_STORAGE__POSTPROCESSING_TEMPLATES_FILE=data/postprocessing_templates.json
|
||||
# WLED_STORAGE__PICTURE_SOURCES_FILE=data/picture_sources.json
|
||||
# WLED_STORAGE__OUTPUT_TARGETS_FILE=data/output_targets.json
|
||||
# WLED_STORAGE__PATTERN_TEMPLATES_FILE=data/pattern_templates.json
|
||||
# WLED_STORAGE__COLOR_STRIP_SOURCES_FILE=data/color_strip_sources.json
|
||||
# WLED_STORAGE__AUDIO_SOURCES_FILE=data/audio_sources.json
|
||||
# WLED_STORAGE__AUDIO_TEMPLATES_FILE=data/audio_templates.json
|
||||
# WLED_STORAGE__VALUE_SOURCES_FILE=data/value_sources.json
|
||||
# WLED_STORAGE__AUTOMATIONS_FILE=data/automations.json
|
||||
# WLED_STORAGE__SCENE_PRESETS_FILE=data/scene_presets.json
|
||||
# WLED_STORAGE__COLOR_STRIP_PROCESSING_TEMPLATES_FILE=data/color_strip_processing_templates.json
|
||||
# WLED_STORAGE__SYNC_CLOCKS_FILE=data/sync_clocks.json
|
||||
|
||||
# ── MQTT (optional) ────────────────────────────────────
|
||||
# WLED_MQTT__ENABLED=false
|
||||
# WLED_MQTT__BROKER_HOST=localhost
|
||||
# WLED_MQTT__BROKER_PORT=1883
|
||||
# WLED_MQTT__USERNAME=
|
||||
# WLED_MQTT__PASSWORD=
|
||||
# WLED_MQTT__CLIENT_ID=ledgrab
|
||||
# WLED_MQTT__BASE_TOPIC=ledgrab
|
||||
|
||||
# ── Logging ─────────────────────────────────────────────
|
||||
# WLED_LOGGING__FORMAT=json # json or text (default: json)
|
||||
# WLED_LOGGING__FILE=logs/wled_controller.log
|
||||
# WLED_LOGGING__MAX_SIZE_MB=100
|
||||
# WLED_LOGGING__BACKUP_COUNT=5
|
||||
|
||||
# ── Demo mode ───────────────────────────────────────────
|
||||
# WLED_DEMO=false # Enable demo mode (uses data/demo/ directory)
|
||||
|
||||
# ── Config file override ───────────────────────────────
|
||||
# WLED_CONFIG_PATH= # Absolute path to a YAML config file (overrides all above)
|
||||
|
||||
# ── Docker Compose extras (not part of WLED_ prefix) ───
|
||||
# DISPLAY=:0 # X11 display for Linux screen capture
|
||||
270
server/CLAUDE.md
270
server/CLAUDE.md
@@ -1,212 +1,76 @@
|
||||
# Claude Instructions for WLED Screen Controller Server
|
||||
|
||||
## Development Workflow
|
||||
## Project Structure
|
||||
|
||||
### Server Restart Policy
|
||||
- `src/wled_controller/main.py` — FastAPI application entry point
|
||||
- `src/wled_controller/api/routes/` — REST API endpoints (one file per entity)
|
||||
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
|
||||
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
|
||||
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
|
||||
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection)
|
||||
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
|
||||
- `src/wled_controller/templates/` — Jinja2 HTML templates
|
||||
- `config/` — Configuration files (YAML)
|
||||
- `data/` — Runtime data (JSON stores, persisted state)
|
||||
|
||||
**IMPORTANT**: When making changes to server code (Python files in `src/wled_controller/`), you MUST restart the server if it's currently running to ensure the changes take effect.
|
||||
## Entity & Storage Pattern
|
||||
|
||||
**NOTE**: Auto-reload is currently disabled (`reload=False` in `main.py`) due to watchfiles causing an infinite reload loop. Changes to server code will NOT be automatically picked up - manual server restart is required.
|
||||
|
||||
#### When to restart:
|
||||
- After modifying API routes (`api/routes.py`, `api/schemas.py`)
|
||||
- After updating core logic (`core/*.py`)
|
||||
- After changing configuration (`config.py`)
|
||||
- After modifying utilities (`utils/*.py`)
|
||||
- After updating data models or database schemas
|
||||
|
||||
#### How to check if server is running:
|
||||
```bash
|
||||
# Look for running Python processes with wled_controller
|
||||
ps aux | grep wled_controller
|
||||
# Or check for processes listening on port 8080
|
||||
netstat -an | grep 8080
|
||||
```
|
||||
|
||||
#### How to restart:
|
||||
1. **Find the task ID** of the running server (look for background bash tasks in conversation)
|
||||
2. **Stop the server** using TaskStop with the task ID
|
||||
3. **Check for port conflicts** (port 8080 may still be in use):
|
||||
```bash
|
||||
netstat -ano | findstr :8080
|
||||
```
|
||||
If a process is still using port 8080, kill it:
|
||||
```bash
|
||||
powershell -Command "Stop-Process -Id <PID> -Force"
|
||||
```
|
||||
4. **Start a new server instance** in the background:
|
||||
```bash
|
||||
cd server && python -m wled_controller.main
|
||||
```
|
||||
Use `run_in_background: true` parameter in Bash tool
|
||||
5. **Wait 3 seconds** for server to initialize:
|
||||
```bash
|
||||
sleep 3
|
||||
```
|
||||
6. **Verify startup** by reading the output file:
|
||||
- Look for "Uvicorn running on http://0.0.0.0:8080"
|
||||
- Check for any errors in stderr
|
||||
- Verify "Application startup complete" message
|
||||
|
||||
**Common Issues:**
|
||||
- **Port 8080 in use**: Old process didn't terminate cleanly - kill it manually
|
||||
- **Module import errors**: Check that all Python files are syntactically correct
|
||||
- **Permission errors**: Ensure file permissions allow Python to execute
|
||||
|
||||
#### Files that DON'T require restart:
|
||||
- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly
|
||||
- Locale files (`static/locales/*.json`) - loaded by frontend
|
||||
- Documentation files (`*.md`)
|
||||
- Configuration files in `config/` if server supports hot-reload (check implementation)
|
||||
|
||||
### Git Commit and Push Policy
|
||||
|
||||
**CRITICAL**: NEVER commit OR push code changes without explicit user approval.
|
||||
|
||||
#### Rules
|
||||
|
||||
- You MUST NOT create commits without explicit user instruction
|
||||
- You MUST NOT push commits unless explicitly instructed by the user
|
||||
- Wait for the user to review changes and ask you to commit
|
||||
- If the user says "commit", create a commit but DO NOT push
|
||||
- If the user says "commit and push", you may push after committing
|
||||
- Always wait for explicit permission before any commit or push operation
|
||||
|
||||
#### Workflow
|
||||
|
||||
1. Make changes to code
|
||||
2. **STOP and WAIT** - inform the user of changes and wait for instruction
|
||||
3. Only create commit when user explicitly requests it (e.g., "commit", "create a commit")
|
||||
4. **STOP and WAIT** - do not push
|
||||
5. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
|
||||
|
||||
### Testing Changes
|
||||
|
||||
After restarting the server with new code:
|
||||
1. Test the modified endpoints/functionality
|
||||
2. Check browser console for any JavaScript errors
|
||||
3. Verify API responses match updated schemas
|
||||
4. Test with different locales if i18n was modified
|
||||
|
||||
## Project Structure Notes
|
||||
|
||||
- `src/wled_controller/main.py` - FastAPI application entry point
|
||||
- `src/wled_controller/api/` - REST API endpoints and schemas
|
||||
- `src/wled_controller/core/` - Core business logic (screen capture, WLED client, processing)
|
||||
- `src/wled_controller/utils/` - Utility functions (logging, monitor detection)
|
||||
- `src/wled_controller/static/` - Frontend files (HTML, CSS, JS, locales)
|
||||
- `config/` - Configuration files (YAML)
|
||||
- `data/` - Runtime data (devices.json, persistence)
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a new API endpoint:
|
||||
1. Add route to `api/routes.py`
|
||||
2. Define request/response schemas in `api/schemas.py`
|
||||
3. **Restart the server**
|
||||
4. Test the endpoint via `/docs` (Swagger UI)
|
||||
|
||||
### Adding a new field to existing API:
|
||||
1. Update Pydantic schema in `api/schemas.py`
|
||||
2. Update corresponding dataclass (if applicable)
|
||||
3. Update backend logic to populate the field
|
||||
4. **Restart the server**
|
||||
5. Update frontend to display the new field
|
||||
|
||||
### Modifying display/monitor detection:
|
||||
1. Update functions in `utils/monitor_names.py` or `core/screen_capture.py`
|
||||
2. **Restart the server**
|
||||
3. Test with `GET /api/v1/config/displays`
|
||||
|
||||
### Modifying server login:
|
||||
1. Update the logic.
|
||||
2. **Restart the server**
|
||||
|
||||
### Adding translations:
|
||||
1. Add keys to `static/locales/en.json` and `static/locales/ru.json`
|
||||
2. Add `data-i18n` attributes to HTML elements in `static/index.html`
|
||||
3. Use `t('key')` function in `static/app.js` for dynamic content
|
||||
4. No server restart needed (frontend only)
|
||||
|
||||
## Frontend UI Patterns
|
||||
|
||||
### Entity Cards
|
||||
|
||||
All entity cards (devices, targets, CSS sources, streams, scenes, automations, etc.) **must support clone functionality**. Clone buttons use the `ICON_CLONE` (📋) icon in `.card-actions`.
|
||||
|
||||
**Clone pattern**: Clone must open the entity's add/create modal with fields prefilled from the cloned item. It must **never** silently create a duplicate — the user should review and confirm.
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Export a `cloneMyEntity(id)` function that fetches (or finds in cache) the entity data
|
||||
2. Call the add/create modal function, passing the entity data as `cloneData`
|
||||
3. In the modal opener, detect clone mode (no ID + cloneData present) and prefill all fields
|
||||
4. Append `' (Copy)'` to the name
|
||||
5. Set the modal title to the "add" variant (not "edit")
|
||||
6. The save action creates a new entity (POST), not an update (PUT)
|
||||
|
||||
```javascript
|
||||
export async function cloneMyEntity(id) {
|
||||
const entity = myCache.data.find(e => e.id === id);
|
||||
if (!entity) return;
|
||||
showMyEditor(null, entity); // null id = create mode, entity = cloneData
|
||||
}
|
||||
```
|
||||
|
||||
Register the clone function in `app.js` window exports so inline `onclick` handlers can call it.
|
||||
|
||||
### Modal Dialogs
|
||||
|
||||
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX:
|
||||
|
||||
#### Backdrop Click Behavior
|
||||
All modals MUST close when the user clicks outside the dialog (on the backdrop). Implement this by adding a click handler that checks if the clicked element is the modal backdrop itself:
|
||||
|
||||
```javascript
|
||||
// Show modal
|
||||
const modal = document.getElementById('my-modal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Add backdrop click handler to close modal
|
||||
modal.onclick = function(event) {
|
||||
if (event.target === modal) {
|
||||
closeMyModal();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Where to add**: In every function that shows a modal (e.g., `showAddTemplateModal()`, `editTemplate()`, `showTestTemplateModal()`).
|
||||
|
||||
#### Close Button Requirement
|
||||
Each modal dialog that has a "Cancel" button MUST also have a cross (×) close button at the top-right corner of the dialog. This provides users with multiple intuitive ways to dismiss the dialog:
|
||||
|
||||
1. Click the backdrop (outside the dialog)
|
||||
2. Click the × button (top-right corner)
|
||||
3. Click the Cancel button (bottom of dialog)
|
||||
4. Press Escape key (if implemented)
|
||||
|
||||
**HTML Structure**:
|
||||
```html
|
||||
<div class="modal-content">
|
||||
<button class="close-btn" onclick="closeMyModal()">×</button>
|
||||
<h2>Dialog Title</h2>
|
||||
<!-- dialog content -->
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeMyModal()">Cancel</button>
|
||||
<button onclick="submitAction()">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS Requirements**:
|
||||
- Close button should be positioned absolutely at top-right
|
||||
- Should be easily clickable (min 24px × 24px hit area)
|
||||
- Should have clear hover state
|
||||
Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`).
|
||||
|
||||
## Authentication
|
||||
|
||||
Server uses API key authentication. Keys are configured in:
|
||||
- `config/default_config.yaml` under `auth.api_keys`
|
||||
- Or via environment variables: `WLED_AUTH__API_KEYS`
|
||||
Server uses API key authentication via Bearer token in `Authorization` header.
|
||||
|
||||
For development, ensure at least one API key is configured or the server won't start.
|
||||
- Config: `config/default_config.yaml` under `auth.api_keys`
|
||||
- Env var: `WLED_AUTH__API_KEYS`
|
||||
- Dev key: `development-key-change-in-production`
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a new API endpoint
|
||||
|
||||
1. Create route file in `api/routes/`
|
||||
2. Define request/response schemas in `api/schemas/`
|
||||
3. Register the router in `main.py`
|
||||
4. Restart the server
|
||||
5. Test via `/docs` (Swagger UI)
|
||||
|
||||
### Adding a new field to existing API
|
||||
|
||||
1. Update Pydantic schema in `api/schemas/`
|
||||
2. Update corresponding dataclass in `storage/`
|
||||
3. Update backend logic to populate the field
|
||||
4. Restart the server
|
||||
5. Update frontend to display the new field
|
||||
6. Rebuild bundle: `cd server && npm run build`
|
||||
|
||||
### Adding translations
|
||||
|
||||
1. Add keys to `static/locales/en.json`, `static/locales/ru.json`, and `static/locales/zh.json`
|
||||
2. Add `data-i18n` attributes to HTML elements in `templates/`
|
||||
3. Use `t('key')` in TypeScript modules (`static/js/`)
|
||||
4. No server restart needed (frontend only), but rebuild bundle if JS changed
|
||||
|
||||
### Modifying display/monitor detection
|
||||
|
||||
1. Update functions in `utils/monitor_names.py` or `core/screen_capture.py`
|
||||
2. Restart the server
|
||||
3. Test with `GET /api/v1/config/displays`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd server && pytest # Run all tests
|
||||
cd server && pytest --cov # With coverage report
|
||||
cd server && pytest tests/test_api.py # Single test file
|
||||
```
|
||||
|
||||
Tests are in `server/tests/`. Config in `pyproject.toml` under `[tool.pytest]`.
|
||||
|
||||
## Frontend Conventions
|
||||
|
||||
For all frontend conventions (CSS variables, UI patterns, modals, localization, icons, bundling), see [contexts/frontend.md](../contexts/frontend.md).
|
||||
|
||||
## Server Operations
|
||||
|
||||
For restart procedures, server modes, and demo mode checklist, see [contexts/server-operations.md](../contexts/server-operations.md).
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
## Stage 1: Build frontend bundle
|
||||
FROM node:20.18-slim AS frontend
|
||||
WORKDIR /build
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY esbuild.mjs tsconfig.json ./
|
||||
COPY src/wled_controller/static/ ./src/wled_controller/static/
|
||||
RUN npm run build
|
||||
|
||||
## Stage 2: Python application
|
||||
FROM python:3.11.11-slim AS runtime
|
||||
|
||||
LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
|
||||
LABEL description="WLED Screen Controller - Ambient lighting based on screen content"
|
||||
LABEL org.opencontainers.image.title="LED Grab"
|
||||
LABEL org.opencontainers.image.description="Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
LABEL org.opencontainers.image.version="0.2.0"
|
||||
LABEL org.opencontainers.image.url="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for screen capture
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Install system dependencies for screen capture and health check
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
libxcb1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-shm0 \
|
||||
@@ -14,21 +30,35 @@ RUN apt-get update && apt-get install -y \
|
||||
libxcb-shape0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy project files and install Python dependencies
|
||||
# Install Python dependencies first (layer caching optimization).
|
||||
# Copy pyproject.toml with a minimal package stub so pip can resolve deps.
|
||||
# The real source is copied afterward, keeping the dep layer cached.
|
||||
COPY pyproject.toml .
|
||||
RUN mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
|
||||
&& pip install --no-cache-dir ".[notifications]" \
|
||||
&& rm -rf src/wled_controller
|
||||
|
||||
# Copy source code and config (invalidates cache only when source changes)
|
||||
COPY src/ ./src/
|
||||
COPY config/ ./config/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Create directories for data and logs
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
# Copy built frontend bundle from stage 1
|
||||
COPY --from=frontend /build/src/wled_controller/static/dist/ ./src/wled_controller/static/dist/
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd --gid 1000 ledgrab \
|
||||
&& useradd --uid 1000 --gid ledgrab --shell /bin/bash --create-home ledgrab \
|
||||
&& mkdir -p /app/data /app/logs \
|
||||
&& chown -R ledgrab:ledgrab /app
|
||||
|
||||
USER ledgrab
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8080/health', timeout=5.0)" || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
# Set Python path
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
@@ -188,4 +188,4 @@ MIT - see [../LICENSE](../LICENSE)
|
||||
## Support
|
||||
|
||||
- 📖 [Full Documentation](../docs/)
|
||||
- 🐛 [Issues](https://github.com/yourusername/wled-screen-controller/issues)
|
||||
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
|
||||
|
||||
@@ -2,8 +2,10 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
log_level: "INFO"
|
||||
# CORS: restrict to localhost by default.
|
||||
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8080"
|
||||
cors_origins:
|
||||
- "*"
|
||||
- "http://localhost:8080"
|
||||
|
||||
auth:
|
||||
# API keys are REQUIRED - authentication is always enforced
|
||||
|
||||
36
server/config/demo_config.yaml
Normal file
36
server/config/demo_config.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
# Demo mode configuration
|
||||
# Loaded automatically when WLED_DEMO=true is set.
|
||||
# Uses isolated data directory (data/demo/) and a pre-configured API key
|
||||
# so the demo works out of the box with zero setup.
|
||||
|
||||
demo: true
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8081
|
||||
log_level: "INFO"
|
||||
# CORS: restrict to localhost by default.
|
||||
# For LAN access, add your machine's IP, e.g. "http://192.168.1.100:8081"
|
||||
cors_origins:
|
||||
- "http://localhost:8081"
|
||||
|
||||
auth:
|
||||
api_keys:
|
||||
demo: "demo"
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
templates_file: "data/capture_templates.json"
|
||||
postprocessing_templates_file: "data/postprocessing_templates.json"
|
||||
picture_sources_file: "data/picture_sources.json"
|
||||
output_targets_file: "data/output_targets.json"
|
||||
pattern_templates_file: "data/pattern_templates.json"
|
||||
|
||||
mqtt:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
format: "text"
|
||||
file: "logs/wled_controller.log"
|
||||
max_size_mb: 100
|
||||
backup_count: 5
|
||||
@@ -1,41 +1,54 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
wled-controller:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ledgrab:latest
|
||||
container_name: wled-screen-controller
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "${WLED_PORT:-8080}:8080"
|
||||
|
||||
volumes:
|
||||
# Persist device data
|
||||
# Persist device data and configuration across restarts
|
||||
- ./data:/app/data
|
||||
# Persist logs
|
||||
- ./logs:/app/logs
|
||||
# Mount configuration (optional override)
|
||||
- ./config:/app/config
|
||||
# Required for screen capture on Linux
|
||||
# Mount configuration for easy editing without rebuild
|
||||
- ./config:/app/config:ro
|
||||
# Required for screen capture on Linux (X11)
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
||||
|
||||
environment:
|
||||
# Server configuration
|
||||
## Server
|
||||
# Bind address and port (usually no need to change)
|
||||
- WLED_SERVER__HOST=0.0.0.0
|
||||
- WLED_SERVER__PORT=8080
|
||||
- WLED_SERVER__LOG_LEVEL=INFO
|
||||
# CORS origins — add your LAN IP for remote access, e.g.:
|
||||
# WLED_SERVER__CORS_ORIGINS=["http://localhost:8080","http://192.168.1.100:8080"]
|
||||
|
||||
# Display for X11 (Linux only)
|
||||
## Auth
|
||||
# Override the default API key (STRONGLY recommended for production):
|
||||
# WLED_AUTH__API_KEYS__main=your-secure-key-here
|
||||
# Generate a key: openssl rand -hex 32
|
||||
|
||||
## Display (Linux X11 only)
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
|
||||
# Processing defaults
|
||||
- WLED_PROCESSING__DEFAULT_FPS=30
|
||||
- WLED_PROCESSING__BORDER_WIDTH=10
|
||||
## Processing defaults
|
||||
#- WLED_PROCESSING__DEFAULT_FPS=30
|
||||
#- WLED_PROCESSING__BORDER_WIDTH=10
|
||||
|
||||
# Use host network for screen capture access
|
||||
# network_mode: host # Uncomment for Linux screen capture
|
||||
## MQTT (optional — for Home Assistant auto-discovery)
|
||||
#- WLED_MQTT__ENABLED=true
|
||||
#- WLED_MQTT__BROKER_HOST=192.168.1.2
|
||||
#- WLED_MQTT__BROKER_PORT=1883
|
||||
#- WLED_MQTT__USERNAME=
|
||||
#- WLED_MQTT__PASSWORD=
|
||||
|
||||
# Uncomment for Linux screen capture (requires host network for X11 access)
|
||||
# network_mode: host
|
||||
|
||||
networks:
|
||||
- wled-network
|
||||
|
||||
43
server/esbuild.mjs
Normal file
43
server/esbuild.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
const srcDir = 'src/wled_controller/static';
|
||||
const outDir = `${srcDir}/dist`;
|
||||
|
||||
const watch = process.argv.includes('--watch');
|
||||
|
||||
/** @type {esbuild.BuildOptions} */
|
||||
const jsOpts = {
|
||||
entryPoints: [`${srcDir}/js/app.ts`],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
outfile: `${outDir}/app.bundle.js`,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
target: ['es2020'],
|
||||
logLevel: 'info',
|
||||
};
|
||||
|
||||
/** @type {esbuild.BuildOptions} */
|
||||
const cssOpts = {
|
||||
entryPoints: [`${srcDir}/css/all.css`],
|
||||
bundle: true,
|
||||
outdir: outDir,
|
||||
outbase: `${srcDir}/css`,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
loader: { '.woff2': 'file' },
|
||||
assetNames: '[name]',
|
||||
entryNames: 'app.bundle',
|
||||
};
|
||||
|
||||
if (watch) {
|
||||
const jsCtx = await esbuild.context(jsOpts);
|
||||
const cssCtx = await esbuild.context(cssOpts);
|
||||
await jsCtx.watch();
|
||||
await cssCtx.watch();
|
||||
console.log('Watching for changes...');
|
||||
} else {
|
||||
await esbuild.build(jsOpts);
|
||||
await esbuild.build(cssOpts);
|
||||
}
|
||||
754
server/package-lock.json
generated
Normal file
754
server/package-lock.json
generated
Normal file
@@ -0,0 +1,754 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"elkjs": "^0.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/elkjs": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
||||
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"requires": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"elkjs": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
||||
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
26
server/package.json
Normal file
26
server/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"doc": "docs",
|
||||
"test": "tests"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"elkjs": "^0.11.1"
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "wled-screen-controller"
|
||||
version = "0.1.0"
|
||||
description = "WLED ambient lighting controller based on screen content"
|
||||
version = "0.2.0"
|
||||
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
authors = [
|
||||
{name = "Alexei Dolgolyov", email = "dolgolyov.alexei@gmail.com"}
|
||||
]
|
||||
@@ -56,10 +56,16 @@ dev = [
|
||||
"respx>=0.21.1",
|
||||
"black>=24.0.0",
|
||||
"ruff>=0.6.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
camera = [
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
# OS notification capture
|
||||
notifications = [
|
||||
"winsdk>=1.0.0b10; sys_platform == 'win32'",
|
||||
"dbus-next>=0.2.3; sys_platform == 'linux'",
|
||||
]
|
||||
# High-performance screen capture engines (Windows only)
|
||||
perf = [
|
||||
"dxcam>=0.0.5; sys_platform == 'win32'",
|
||||
@@ -70,6 +76,7 @@ perf = [
|
||||
[project.urls]
|
||||
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/src/branch/master/INSTALLATION.md"
|
||||
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues"
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .routes.system import router as system_router
|
||||
from .routes.backup import router as backup_router
|
||||
from .routes.system_settings import router as system_settings_router
|
||||
from .routes.devices import router as devices_router
|
||||
from .routes.templates import router as templates_router
|
||||
from .routes.postprocessing import router as postprocessing_router
|
||||
from .routes.picture_sources import router as picture_sources_router
|
||||
from .routes.pattern_templates import router as pattern_templates_router
|
||||
from .routes.output_targets import router as output_targets_router
|
||||
from .routes.output_targets_control import router as output_targets_control_router
|
||||
from .routes.output_targets_keycolors import router as output_targets_keycolors_router
|
||||
from .routes.color_strip_sources import router as color_strip_sources_router
|
||||
from .routes.audio import router as audio_router
|
||||
from .routes.audio_sources import router as audio_sources_router
|
||||
@@ -18,9 +22,12 @@ from .routes.automations import router as automations_router
|
||||
from .routes.scene_presets import router as scene_presets_router
|
||||
from .routes.webhooks import router as webhooks_router
|
||||
from .routes.sync_clocks import router as sync_clocks_router
|
||||
from .routes.color_strip_processing import router as cspt_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
router.include_router(backup_router)
|
||||
router.include_router(system_settings_router)
|
||||
router.include_router(devices_router)
|
||||
router.include_router(templates_router)
|
||||
router.include_router(postprocessing_router)
|
||||
@@ -32,9 +39,12 @@ router.include_router(audio_sources_router)
|
||||
router.include_router(audio_templates_router)
|
||||
router.include_router(value_sources_router)
|
||||
router.include_router(output_targets_router)
|
||||
router.include_router(output_targets_control_router)
|
||||
router.include_router(output_targets_keycolors_router)
|
||||
router.include_router(automations_router)
|
||||
router.include_router(scene_presets_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(sync_clocks_router)
|
||||
router.include_router(cspt_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -75,3 +75,16 @@ def verify_api_key(
|
||||
# Dependency for protected routes
|
||||
# Returns the label/identifier of the authenticated client
|
||||
AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
|
||||
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
|
||||
"""
|
||||
config = get_config()
|
||||
if token and config.auth.api_keys:
|
||||
for _label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Dependency injection for API routes."""
|
||||
"""Dependency injection for API routes.
|
||||
|
||||
Uses a registry dict instead of individual module-level globals.
|
||||
All getter function signatures remain unchanged for FastAPI Depends() compatibility.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, TypeVar
|
||||
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
@@ -14,147 +20,101 @@ from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
|
||||
# Global instances (initialized in main.py)
|
||||
_auto_backup_engine: AutoBackupEngine | None = None
|
||||
_device_store: DeviceStore | None = None
|
||||
_template_store: TemplateStore | None = None
|
||||
_pp_template_store: PostprocessingTemplateStore | None = None
|
||||
_pattern_template_store: PatternTemplateStore | None = None
|
||||
_picture_source_store: PictureSourceStore | None = None
|
||||
_output_target_store: OutputTargetStore | None = None
|
||||
_color_strip_store: ColorStripStore | None = None
|
||||
_audio_source_store: AudioSourceStore | None = None
|
||||
_audio_template_store: AudioTemplateStore | None = None
|
||||
_value_source_store: ValueSourceStore | None = None
|
||||
_processor_manager: ProcessorManager | None = None
|
||||
_automation_store: AutomationStore | None = None
|
||||
_scene_preset_store: ScenePresetStore | None = None
|
||||
_automation_engine: AutomationEngine | None = None
|
||||
_sync_clock_store: SyncClockStore | None = None
|
||||
_sync_clock_manager: SyncClockManager | None = None
|
||||
T = TypeVar("T")
|
||||
|
||||
# Central dependency registry — keyed by type or string label
|
||||
_deps: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _get(key: str, label: str) -> Any:
|
||||
"""Get a dependency by key, raising RuntimeError if not initialized."""
|
||||
dep = _deps.get(key)
|
||||
if dep is None:
|
||||
raise RuntimeError(f"{label} not initialized")
|
||||
return dep
|
||||
|
||||
|
||||
# ── Typed getters (unchanged signatures for FastAPI Depends()) ──────────
|
||||
|
||||
|
||||
def get_device_store() -> DeviceStore:
|
||||
"""Get device store dependency."""
|
||||
if _device_store is None:
|
||||
raise RuntimeError("Device store not initialized")
|
||||
return _device_store
|
||||
return _get("device_store", "Device store")
|
||||
|
||||
|
||||
def get_template_store() -> TemplateStore:
|
||||
"""Get template store dependency."""
|
||||
if _template_store is None:
|
||||
raise RuntimeError("Template store not initialized")
|
||||
return _template_store
|
||||
return _get("template_store", "Template store")
|
||||
|
||||
|
||||
def get_pp_template_store() -> PostprocessingTemplateStore:
|
||||
"""Get postprocessing template store dependency."""
|
||||
if _pp_template_store is None:
|
||||
raise RuntimeError("Postprocessing template store not initialized")
|
||||
return _pp_template_store
|
||||
return _get("pp_template_store", "Postprocessing template store")
|
||||
|
||||
|
||||
def get_pattern_template_store() -> PatternTemplateStore:
|
||||
"""Get pattern template store dependency."""
|
||||
if _pattern_template_store is None:
|
||||
raise RuntimeError("Pattern template store not initialized")
|
||||
return _pattern_template_store
|
||||
return _get("pattern_template_store", "Pattern template store")
|
||||
|
||||
|
||||
def get_picture_source_store() -> PictureSourceStore:
|
||||
"""Get picture source store dependency."""
|
||||
if _picture_source_store is None:
|
||||
raise RuntimeError("Picture source store not initialized")
|
||||
return _picture_source_store
|
||||
return _get("picture_source_store", "Picture source store")
|
||||
|
||||
|
||||
def get_output_target_store() -> OutputTargetStore:
|
||||
"""Get output target store dependency."""
|
||||
if _output_target_store is None:
|
||||
raise RuntimeError("Picture target store not initialized")
|
||||
return _output_target_store
|
||||
return _get("output_target_store", "Output target store")
|
||||
|
||||
|
||||
def get_color_strip_store() -> ColorStripStore:
|
||||
"""Get color strip store dependency."""
|
||||
if _color_strip_store is None:
|
||||
raise RuntimeError("Color strip store not initialized")
|
||||
return _color_strip_store
|
||||
return _get("color_strip_store", "Color strip store")
|
||||
|
||||
|
||||
def get_audio_source_store() -> AudioSourceStore:
|
||||
"""Get audio source store dependency."""
|
||||
if _audio_source_store is None:
|
||||
raise RuntimeError("Audio source store not initialized")
|
||||
return _audio_source_store
|
||||
return _get("audio_source_store", "Audio source store")
|
||||
|
||||
|
||||
def get_audio_template_store() -> AudioTemplateStore:
|
||||
"""Get audio template store dependency."""
|
||||
if _audio_template_store is None:
|
||||
raise RuntimeError("Audio template store not initialized")
|
||||
return _audio_template_store
|
||||
return _get("audio_template_store", "Audio template store")
|
||||
|
||||
|
||||
def get_value_source_store() -> ValueSourceStore:
|
||||
"""Get value source store dependency."""
|
||||
if _value_source_store is None:
|
||||
raise RuntimeError("Value source store not initialized")
|
||||
return _value_source_store
|
||||
return _get("value_source_store", "Value source store")
|
||||
|
||||
|
||||
def get_processor_manager() -> ProcessorManager:
|
||||
"""Get processor manager dependency."""
|
||||
if _processor_manager is None:
|
||||
raise RuntimeError("Processor manager not initialized")
|
||||
return _processor_manager
|
||||
return _get("processor_manager", "Processor manager")
|
||||
|
||||
|
||||
def get_automation_store() -> AutomationStore:
|
||||
"""Get automation store dependency."""
|
||||
if _automation_store is None:
|
||||
raise RuntimeError("Automation store not initialized")
|
||||
return _automation_store
|
||||
return _get("automation_store", "Automation store")
|
||||
|
||||
|
||||
def get_scene_preset_store() -> ScenePresetStore:
|
||||
"""Get scene preset store dependency."""
|
||||
if _scene_preset_store is None:
|
||||
raise RuntimeError("Scene preset store not initialized")
|
||||
return _scene_preset_store
|
||||
return _get("scene_preset_store", "Scene preset store")
|
||||
|
||||
|
||||
def get_automation_engine() -> AutomationEngine:
|
||||
"""Get automation engine dependency."""
|
||||
if _automation_engine is None:
|
||||
raise RuntimeError("Automation engine not initialized")
|
||||
return _automation_engine
|
||||
return _get("automation_engine", "Automation engine")
|
||||
|
||||
|
||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||
"""Get auto-backup engine dependency."""
|
||||
if _auto_backup_engine is None:
|
||||
raise RuntimeError("Auto-backup engine not initialized")
|
||||
return _auto_backup_engine
|
||||
return _get("auto_backup_engine", "Auto-backup engine")
|
||||
|
||||
|
||||
def get_sync_clock_store() -> SyncClockStore:
|
||||
"""Get sync clock store dependency."""
|
||||
if _sync_clock_store is None:
|
||||
raise RuntimeError("Sync clock store not initialized")
|
||||
return _sync_clock_store
|
||||
return _get("sync_clock_store", "Sync clock store")
|
||||
|
||||
|
||||
def get_sync_clock_manager() -> SyncClockManager:
|
||||
"""Get sync clock manager dependency."""
|
||||
if _sync_clock_manager is None:
|
||||
raise RuntimeError("Sync clock manager not initialized")
|
||||
return _sync_clock_manager
|
||||
return _get("sync_clock_manager", "Sync clock manager")
|
||||
|
||||
|
||||
def get_cspt_store() -> ColorStripProcessingTemplateStore:
|
||||
return _get("cspt_store", "Color strip processing template store")
|
||||
|
||||
|
||||
# ── Event helper ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
@@ -165,8 +125,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
action: "created", "updated", or "deleted"
|
||||
entity_id: The entity's unique ID
|
||||
"""
|
||||
if _processor_manager is not None:
|
||||
_processor_manager.fire_event({
|
||||
pm = _deps.get("processor_manager")
|
||||
if pm is not None:
|
||||
pm.fire_event({
|
||||
"type": "entity_changed",
|
||||
"entity_type": entity_type,
|
||||
"action": action,
|
||||
@@ -174,6 +135,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
})
|
||||
|
||||
|
||||
# ── Initialization ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def init_dependencies(
|
||||
device_store: DeviceStore,
|
||||
template_store: TemplateStore,
|
||||
@@ -192,27 +156,26 @@ def init_dependencies(
|
||||
auto_backup_engine: AutoBackupEngine | None = None,
|
||||
sync_clock_store: SyncClockStore | None = None,
|
||||
sync_clock_manager: SyncClockManager | None = None,
|
||||
cspt_store: ColorStripProcessingTemplateStore | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _output_target_store
|
||||
global _color_strip_store, _audio_source_store, _audio_template_store
|
||||
global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine
|
||||
global _sync_clock_store, _sync_clock_manager
|
||||
_device_store = device_store
|
||||
_template_store = template_store
|
||||
_processor_manager = processor_manager
|
||||
_pp_template_store = pp_template_store
|
||||
_pattern_template_store = pattern_template_store
|
||||
_picture_source_store = picture_source_store
|
||||
_output_target_store = output_target_store
|
||||
_color_strip_store = color_strip_store
|
||||
_audio_source_store = audio_source_store
|
||||
_audio_template_store = audio_template_store
|
||||
_value_source_store = value_source_store
|
||||
_automation_store = automation_store
|
||||
_scene_preset_store = scene_preset_store
|
||||
_automation_engine = automation_engine
|
||||
_auto_backup_engine = auto_backup_engine
|
||||
_sync_clock_store = sync_clock_store
|
||||
_sync_clock_manager = sync_clock_manager
|
||||
_deps.update({
|
||||
"device_store": device_store,
|
||||
"template_store": template_store,
|
||||
"processor_manager": processor_manager,
|
||||
"pp_template_store": pp_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
"picture_source_store": picture_source_store,
|
||||
"output_target_store": output_target_store,
|
||||
"color_strip_store": color_strip_store,
|
||||
"audio_source_store": audio_source_store,
|
||||
"audio_template_store": audio_template_store,
|
||||
"value_source_store": value_source_store,
|
||||
"automation_store": automation_store,
|
||||
"scene_preset_store": scene_preset_store,
|
||||
"automation_engine": automation_engine,
|
||||
"auto_backup_engine": auto_backup_engine,
|
||||
"sync_clock_store": sync_clock_store,
|
||||
"sync_clock_manager": sync_clock_manager,
|
||||
"cspt_store": cspt_store,
|
||||
})
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
"""Shared helpers for WebSocket-based capture test endpoints."""
|
||||
"""Shared helpers for WebSocket-based capture preview endpoints."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, List, Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
@@ -26,13 +24,12 @@ PREVIEW_JPEG_QUALITY = 70
|
||||
|
||||
|
||||
def authenticate_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys."""
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
return False
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Delegates to the canonical implementation in auth module.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
return verify_ws_token(token)
|
||||
|
||||
|
||||
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||
@@ -44,6 +41,19 @@ def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes:
|
||||
"""Encode a numpy RGB image to JPEG bytes, optionally downscaling."""
|
||||
import cv2
|
||||
if max_width and image.shape[1] > max_width:
|
||||
scale = max_width / image.shape[1]
|
||||
new_h = int(image.shape[0] * scale)
|
||||
image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA)
|
||||
# RGB -> BGR for OpenCV JPEG encoding
|
||||
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||
_, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image:
|
||||
"""Create a thumbnail copy of the image, preserving aspect ratio."""
|
||||
thumb = pil_image.copy()
|
||||
@@ -112,7 +122,7 @@ async def stream_capture_test(
|
||||
continue
|
||||
total_capture_time += t1 - t0
|
||||
frame_count += 1
|
||||
# Convert numpy → PIL once in the capture thread
|
||||
# Convert numpy -> PIL once in the capture thread
|
||||
if isinstance(capture.image, np.ndarray):
|
||||
latest_frame = Image.fromarray(capture.image)
|
||||
else:
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Audio device routes: enumerate available audio devices."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -17,8 +19,12 @@ async def list_audio_devices(_auth: AuthRequired):
|
||||
filter by the selected audio template's engine type.
|
||||
"""
|
||||
try:
|
||||
devices = AudioCaptureManager.enumerate_devices()
|
||||
by_engine = AudioCaptureManager.enumerate_devices_by_engine()
|
||||
devices, by_engine = await asyncio.to_thread(
|
||||
lambda: (
|
||||
AudioCaptureManager.enumerate_devices(),
|
||||
AudioCaptureManager.enumerate_devices_by_engine(),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"devices": devices,
|
||||
"count": len(devices),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -21,11 +20,11 @@ from wled_controller.api.schemas.audio_sources import (
|
||||
AudioSourceResponse,
|
||||
AudioSourceUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.storage.audio_source import AudioSource
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -44,7 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
channel=getattr(source, "channel", None),
|
||||
description=source.description,
|
||||
tags=getattr(source, 'tags', []),
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -87,6 +86,9 @@ async def create_audio_source(
|
||||
)
|
||||
fire_entity_event("audio_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -127,6 +129,9 @@ async def update_audio_source(
|
||||
)
|
||||
fire_entity_event("audio_source", "updated", source_id)
|
||||
return _to_response(source)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -150,6 +155,9 @@ async def delete_audio_source(
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("audio_source", "deleted", source_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -169,16 +177,8 @@ async def test_audio_source_ws(
|
||||
(ref-counted — shares with running targets), and streams AudioAnalysis
|
||||
snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""Audio capture template and engine routes."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -17,11 +14,11 @@ from wled_controller.api.schemas.audio_templates import (
|
||||
AudioTemplateResponse,
|
||||
AudioTemplateUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -41,7 +38,7 @@ async def list_audio_templates(
|
||||
responses = [
|
||||
AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
engine_config=t.engine_config, tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
@@ -69,10 +66,13 @@ async def create_audio_template(
|
||||
fire_entity_event("audio_template", "created", template.id)
|
||||
return AudioTemplateResponse(
|
||||
id=template.id, name=template.name, engine_type=template.engine_type,
|
||||
engine_config=template.engine_config, tags=getattr(template, 'tags', []),
|
||||
engine_config=template.engine_config, tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at, description=template.description,
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -93,7 +93,7 @@ async def get_audio_template(
|
||||
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
|
||||
return AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
engine_config=t.engine_config, tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
@@ -116,10 +116,13 @@ async def update_audio_template(
|
||||
fire_entity_event("audio_template", "updated", template_id)
|
||||
return AudioTemplateResponse(
|
||||
id=t.id, name=t.name, engine_type=t.engine_type,
|
||||
engine_config=t.engine_config, tags=getattr(t, 'tags', []),
|
||||
engine_config=t.engine_config, tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at, description=t.description,
|
||||
)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -140,6 +143,9 @@ async def delete_audio_template(
|
||||
fire_entity_event("audio_template", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -189,16 +195,8 @@ async def test_audio_template_ws(
|
||||
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
|
||||
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from wled_controller.storage.automation import (
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -89,7 +90,12 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
||||
webhook_url = None
|
||||
for c in automation.conditions:
|
||||
if isinstance(c, WebhookCondition) and c.token:
|
||||
if request:
|
||||
# Prefer configured external URL, fall back to request base URL
|
||||
from wled_controller.api.routes.system import load_external_url
|
||||
ext = load_external_url()
|
||||
if ext:
|
||||
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
|
||||
elif request:
|
||||
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
|
||||
else:
|
||||
webhook_url = f"/api/v1/webhooks/{c.token}"
|
||||
@@ -108,7 +114,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
||||
is_active=state["is_active"],
|
||||
last_activated_at=state.get("last_activated_at"),
|
||||
last_deactivated_at=state.get("last_deactivated_at"),
|
||||
tags=getattr(automation, 'tags', []),
|
||||
tags=automation.tags,
|
||||
created_at=automation.created_at,
|
||||
updated_at=automation.updated_at,
|
||||
)
|
||||
@@ -158,6 +164,9 @@ async def create_automation(
|
||||
|
||||
try:
|
||||
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -244,6 +253,9 @@ async def update_automation(
|
||||
if data.conditions is not None:
|
||||
try:
|
||||
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
395
server/src/wled_controller/api/routes/backup.py
Normal file
395
server/src/wled_controller/api/routes/backup.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""System routes: backup, restore, export, import, auto-backup.
|
||||
|
||||
Extracted from system.py to keep files under 800 lines.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import get_auto_backup_engine
|
||||
from wled_controller.api.schemas.system import (
|
||||
AutoBackupSettings,
|
||||
AutoBackupStatusResponse,
|
||||
BackupFileInfo,
|
||||
BackupListResponse,
|
||||
RestoreResponse,
|
||||
)
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration backup / restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping: logical store name -> StorageConfig attribute name
|
||||
STORE_MAP = {
|
||||
"devices": "devices_file",
|
||||
"capture_templates": "templates_file",
|
||||
"postprocessing_templates": "postprocessing_templates_file",
|
||||
"picture_sources": "picture_sources_file",
|
||||
"output_targets": "output_targets_file",
|
||||
"pattern_templates": "pattern_templates_file",
|
||||
"color_strip_sources": "color_strip_sources_file",
|
||||
"audio_sources": "audio_sources_file",
|
||||
"audio_templates": "audio_templates_file",
|
||||
"value_sources": "value_sources_file",
|
||||
"sync_clocks": "sync_clocks_file",
|
||||
"color_strip_processing_templates": "color_strip_processing_templates_file",
|
||||
"automations": "automations_file",
|
||||
"scene_presets": "scene_presets_file",
|
||||
}
|
||||
|
||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _schedule_restart() -> None:
|
||||
"""Spawn a restart script after a short delay so the HTTP response completes."""
|
||||
|
||||
def _restart():
|
||||
import time
|
||||
time.sleep(1)
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-File",
|
||||
str(_SERVER_DIR / "restart.ps1")],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen(
|
||||
["bash", str(_SERVER_DIR / "restart.sh")],
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
threading.Thread(target=_restart, daemon=True).start()
|
||||
|
||||
|
||||
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
|
||||
def export_store(store_key: str, _: AuthRequired):
|
||||
"""Download a single entity store as a JSON file."""
|
||||
if store_key not in STORE_MAP:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||
)
|
||||
config = get_config()
|
||||
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
export = {
|
||||
"meta": {
|
||||
"format": "ledgrab-partial-export",
|
||||
"format_version": 1,
|
||||
"store_key": store_key,
|
||||
"app_version": __version__,
|
||||
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
},
|
||||
"store": data,
|
||||
}
|
||||
content = json.dumps(export, indent=2, ensure_ascii=False)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-{store_key}-{timestamp}.json"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content.encode("utf-8")),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
|
||||
async def import_store(
|
||||
store_key: str,
|
||||
_: AuthRequired,
|
||||
file: UploadFile = File(...),
|
||||
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
|
||||
):
|
||||
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
|
||||
if store_key not in STORE_MAP:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await file.read()
|
||||
if len(raw) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||
|
||||
# Support both full-backup format and partial-export format
|
||||
if "stores" in payload and isinstance(payload.get("meta"), dict):
|
||||
# Full backup: extract the specific store
|
||||
if payload["meta"].get("format") not in ("ledgrab-backup",):
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||
stores = payload.get("stores", {})
|
||||
if store_key not in stores:
|
||||
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
|
||||
incoming = stores[store_key]
|
||||
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
|
||||
# Partial export format
|
||||
if payload["meta"].get("store_key") != store_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
|
||||
)
|
||||
incoming = payload.get("store", {})
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||
|
||||
if not isinstance(incoming, dict):
|
||||
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
|
||||
|
||||
config = get_config()
|
||||
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||
|
||||
def _write():
|
||||
if merge and file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if isinstance(existing, dict):
|
||||
existing.update(incoming)
|
||||
atomic_write_json(file_path, existing)
|
||||
return len(existing)
|
||||
atomic_write_json(file_path, incoming)
|
||||
return len(incoming)
|
||||
|
||||
count = await asyncio.to_thread(_write)
|
||||
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
|
||||
_schedule_restart()
|
||||
return {
|
||||
"status": "imported",
|
||||
"store_key": store_key,
|
||||
"entries": count,
|
||||
"merge": merge,
|
||||
"restart_scheduled": True,
|
||||
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/system/backup", tags=["System"])
|
||||
def backup_config(_: AuthRequired):
|
||||
"""Download all configuration as a single JSON backup file."""
|
||||
config = get_config()
|
||||
stores = {}
|
||||
for store_key, config_attr in STORE_MAP.items():
|
||||
file_path = Path(getattr(config.storage, config_attr))
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
stores[store_key] = json.load(f)
|
||||
else:
|
||||
stores[store_key] = {}
|
||||
|
||||
backup = {
|
||||
"meta": {
|
||||
"format": "ledgrab-backup",
|
||||
"format_version": 1,
|
||||
"app_version": __version__,
|
||||
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
"store_count": len(stores),
|
||||
},
|
||||
"stores": stores,
|
||||
}
|
||||
|
||||
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-backup-{timestamp}.json"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content.encode("utf-8")),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/restart", tags=["System"])
|
||||
def restart_server(_: AuthRequired):
|
||||
"""Schedule a server restart and return immediately."""
|
||||
_schedule_restart()
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
||||
async def restore_config(
|
||||
_: AuthRequired,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""Upload a backup file to restore all configuration. Triggers server restart."""
|
||||
# Read and parse
|
||||
try:
|
||||
raw = await file.read()
|
||||
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
|
||||
backup = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
|
||||
|
||||
# Validate envelope
|
||||
meta = backup.get("meta")
|
||||
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
|
||||
|
||||
fmt_version = meta.get("format_version", 0)
|
||||
if fmt_version > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Backup format version {fmt_version} is not supported by this server version",
|
||||
)
|
||||
|
||||
stores = backup.get("stores")
|
||||
if not isinstance(stores, dict):
|
||||
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
|
||||
|
||||
known_keys = set(STORE_MAP.keys())
|
||||
present_keys = known_keys & set(stores.keys())
|
||||
if not present_keys:
|
||||
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
|
||||
|
||||
for key in present_keys:
|
||||
if not isinstance(stores[key], dict):
|
||||
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
|
||||
|
||||
# Write store files atomically (in thread to avoid blocking event loop)
|
||||
config = get_config()
|
||||
|
||||
def _write_stores():
|
||||
count = 0
|
||||
for store_key, config_attr in STORE_MAP.items():
|
||||
if store_key in stores:
|
||||
file_path = Path(getattr(config.storage, config_attr))
|
||||
atomic_write_json(file_path, stores[store_key])
|
||||
count += 1
|
||||
logger.info(f"Restored store: {store_key} -> {file_path}")
|
||||
return count
|
||||
|
||||
written = await asyncio.to_thread(_write_stores)
|
||||
|
||||
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
|
||||
_schedule_restart()
|
||||
|
||||
missing = known_keys - present_keys
|
||||
return RestoreResponse(
|
||||
status="restored",
|
||||
stores_written=written,
|
||||
stores_total=len(STORE_MAP),
|
||||
missing_stores=sorted(missing) if missing else [],
|
||||
restart_scheduled=True,
|
||||
message=f"Restored {written} stores. Server restarting...",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-backup settings & saved backups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/auto-backup/settings",
|
||||
response_model=AutoBackupStatusResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_auto_backup_settings(
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Get auto-backup settings and status."""
|
||||
return engine.get_settings()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/system/auto-backup/settings",
|
||||
response_model=AutoBackupStatusResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_auto_backup_settings(
|
||||
_: AuthRequired,
|
||||
body: AutoBackupSettings,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
||||
return await engine.update_settings(
|
||||
enabled=body.enabled,
|
||||
interval_hours=body.interval_hours,
|
||||
max_backups=body.max_backups,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
|
||||
async def trigger_backup(
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Manually trigger a backup now."""
|
||||
backup = await engine.trigger_backup()
|
||||
return {"status": "ok", "backup": backup}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/backups",
|
||||
response_model=BackupListResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def list_backups(
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""List all saved backup files."""
|
||||
backups = engine.list_backups()
|
||||
return BackupListResponse(
|
||||
backups=[BackupFileInfo(**b) for b in backups],
|
||||
count=len(backups),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
|
||||
def download_saved_backup(
|
||||
filename: str,
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Download a specific saved backup file."""
|
||||
try:
|
||||
path = engine.get_backup_path(filename)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
content = path.read_bytes()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
|
||||
async def delete_saved_backup(
|
||||
filename: str,
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Delete a specific saved backup file."""
|
||||
try:
|
||||
engine.delete_backup(filename)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return {"status": "deleted", "filename": filename}
|
||||
273
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
273
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Color strip processing template routes."""
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_cspt_store,
|
||||
get_device_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.filters import FilterInstanceSchema
|
||||
from wled_controller.api.schemas.color_strip_processing import (
|
||||
ColorStripProcessingTemplateCreate,
|
||||
ColorStripProcessingTemplateListResponse,
|
||||
ColorStripProcessingTemplateResponse,
|
||||
ColorStripProcessingTemplateUpdate,
|
||||
)
|
||||
from wled_controller.core.filters import FilterInstance
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
|
||||
"""Convert a ColorStripProcessingTemplate to its API response."""
|
||||
return ColorStripProcessingTemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=t.tags,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"])
|
||||
async def list_cspt(
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""List all color strip processing templates."""
|
||||
try:
|
||||
templates = store.get_all_templates()
|
||||
responses = [_cspt_to_response(t) for t in templates]
|
||||
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list color strip processing templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
|
||||
async def create_cspt(
|
||||
data: ColorStripProcessingTemplateCreate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Create a new color strip processing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||
template = store.create_template(
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("cspt", "created", template.id)
|
||||
return _cspt_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||
async def get_cspt(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Get color strip processing template by ID."""
|
||||
try:
|
||||
template = store.get_template(template_id)
|
||||
return _cspt_to_response(template)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Color strip processing template {template_id} not found")
|
||||
|
||||
|
||||
@router.put("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||
async def update_cspt(
|
||||
template_id: str,
|
||||
data: ColorStripProcessingTemplateUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Update a color strip processing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
|
||||
template = store.update_template(
|
||||
template_id=template_id,
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("cspt", "updated", template_id)
|
||||
return _cspt_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
|
||||
async def delete_cspt(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a color strip processing template."""
|
||||
try:
|
||||
refs = store.get_references(template_id, device_store=device_store, css_store=css_store)
|
||||
if refs:
|
||||
names = ", ".join(refs)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot delete: template is referenced by: {names}. "
|
||||
"Please reassign before deleting.",
|
||||
)
|
||||
store.delete_template(template_id)
|
||||
fire_entity_event("cspt", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||
|
||||
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
|
||||
async def test_cspt_ws(
|
||||
websocket: WebSocket,
|
||||
template_id: str,
|
||||
token: str = Query(""),
|
||||
input_source_id: str = Query(""),
|
||||
led_count: int = Query(100),
|
||||
fps: int = Query(20),
|
||||
):
|
||||
"""WebSocket for real-time CSPT preview.
|
||||
|
||||
Takes an input CSS source, applies the CSPT filter chain, and streams
|
||||
the processed RGB frames. Auth via ``?token=<api_key>``.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
from wled_controller.core.filters import FilterRegistry
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Validate template exists
|
||||
cspt_store = get_cspt_store()
|
||||
try:
|
||||
template = cspt_store.get_template(template_id)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
if not input_source_id:
|
||||
await websocket.close(code=4003, reason="input_source_id is required")
|
||||
return
|
||||
|
||||
# Validate input source exists
|
||||
css_store = get_color_strip_store()
|
||||
try:
|
||||
input_source = css_store.get_source(input_source_id)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
# Resolve filter chain
|
||||
try:
|
||||
resolved = cspt_store.resolve_filter_instances(template.filters)
|
||||
filters = [FilterRegistry.create_instance(fi.filter_id, fi.options) for fi in resolved]
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test: failed to resolve filters for {template_id}: {e}")
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
# Acquire input stream
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
csm = manager.color_strip_stream_manager
|
||||
consumer_id = f"__cspt_test_{_uuid.uuid4().hex[:8]}__"
|
||||
try:
|
||||
stream = csm.acquire(input_source_id, consumer_id)
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test: failed to acquire input stream for {input_source_id}: {e}")
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
# Configure LED count for auto-sizing streams
|
||||
if hasattr(stream, "configure"):
|
||||
stream.configure(max(1, led_count))
|
||||
|
||||
fps = max(1, min(60, fps))
|
||||
frame_interval = 1.0 / fps
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"CSPT test WS connected: template={template_id}, input={input_source_id}")
|
||||
|
||||
try:
|
||||
# Send metadata
|
||||
meta = {
|
||||
"type": "meta",
|
||||
"source_type": input_source.source_type,
|
||||
"source_name": input_source.name,
|
||||
"template_name": template.name,
|
||||
"led_count": stream.led_count,
|
||||
"filter_count": len(filters),
|
||||
}
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# Stream processed frames
|
||||
while True:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
# Apply CSPT filters
|
||||
for flt in filters:
|
||||
try:
|
||||
result = flt.process_strip(colors)
|
||||
if result is not None:
|
||||
colors = result
|
||||
except Exception:
|
||||
pass
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
await asyncio.sleep(frame_interval)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test WS error: {e}")
|
||||
finally:
|
||||
csm.release(input_source_id, consumer_id)
|
||||
logger.info(f"CSPT test WS disconnected: template={template_id}")
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Color strip source routes: CRUD, calibration test, and API input push."""
|
||||
"""Color strip source routes: CRUD, calibration test, preview, and API input push."""
|
||||
|
||||
import secrets
|
||||
import asyncio
|
||||
import io as _io
|
||||
import json as _json
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
@@ -28,32 +32,39 @@ from wled_controller.api.schemas.devices import (
|
||||
)
|
||||
from wled_controller.core.capture.calibration import (
|
||||
calibration_from_dict,
|
||||
calibration_to_dict,
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.config import get_config
|
||||
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
|
||||
calibration = None
|
||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) and source.calibration:
|
||||
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
|
||||
"""Convert a ColorStripSource to a ColorStripSourceResponse.
|
||||
|
||||
# Convert raw stop dicts to ColorStop schema objects for gradient sources
|
||||
Uses the source's to_dict() for type-specific fields, then applies
|
||||
schema conversions for calibration and gradient stops.
|
||||
"""
|
||||
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
|
||||
raw_stops = getattr(source, "stops", None)
|
||||
|
||||
d = source.to_dict()
|
||||
|
||||
# Convert calibration dict → schema object
|
||||
calibration = None
|
||||
raw_cal = d.pop("calibration", None)
|
||||
if raw_cal and isinstance(raw_cal, dict):
|
||||
calibration = CalibrationSchema(**raw_cal)
|
||||
|
||||
# Convert stop dicts → schema objects
|
||||
raw_stops = d.pop("stops", None)
|
||||
stops = None
|
||||
if raw_stops is not None:
|
||||
try:
|
||||
@@ -61,51 +72,20 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
except Exception:
|
||||
stops = None
|
||||
|
||||
# Remove serialized timestamp strings — use actual datetime objects
|
||||
d.pop("created_at", None)
|
||||
d.pop("updated_at", None)
|
||||
|
||||
# Filter to only keys accepted by the schema (to_dict may include extra
|
||||
# fields like 'fps' that aren't in the response model)
|
||||
valid_fields = ColorStripSourceResponse.model_fields
|
||||
filtered = {k: v for k, v in d.items() if k in valid_fields}
|
||||
|
||||
return ColorStripSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
source_type=source.source_type,
|
||||
picture_source_id=getattr(source, "picture_source_id", None),
|
||||
brightness=getattr(source, "brightness", None),
|
||||
saturation=getattr(source, "saturation", None),
|
||||
gamma=getattr(source, "gamma", None),
|
||||
smoothing=getattr(source, "smoothing", None),
|
||||
interpolation_mode=getattr(source, "interpolation_mode", None),
|
||||
led_count=getattr(source, "led_count", 0),
|
||||
**filtered,
|
||||
calibration=calibration,
|
||||
color=getattr(source, "color", None),
|
||||
stops=stops,
|
||||
colors=getattr(source, "colors", None),
|
||||
effect_type=getattr(source, "effect_type", None),
|
||||
palette=getattr(source, "palette", None),
|
||||
intensity=getattr(source, "intensity", None),
|
||||
scale=getattr(source, "scale", None),
|
||||
mirror=getattr(source, "mirror", None),
|
||||
description=source.description,
|
||||
clock_id=source.clock_id,
|
||||
frame_interpolation=getattr(source, "frame_interpolation", None),
|
||||
animation=getattr(source, "animation", None),
|
||||
layers=getattr(source, "layers", None),
|
||||
zones=getattr(source, "zones", None),
|
||||
visualization_mode=getattr(source, "visualization_mode", None),
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
sensitivity=getattr(source, "sensitivity", None),
|
||||
color_peak=getattr(source, "color_peak", None),
|
||||
fallback_color=getattr(source, "fallback_color", None),
|
||||
timeout=getattr(source, "timeout", None),
|
||||
notification_effect=getattr(source, "notification_effect", None),
|
||||
duration_ms=getattr(source, "duration_ms", None),
|
||||
default_color=getattr(source, "default_color", None),
|
||||
app_colors=getattr(source, "app_colors", None),
|
||||
app_filter_mode=getattr(source, "app_filter_mode", None),
|
||||
app_filter_list=getattr(source, "app_filter_list", None),
|
||||
os_listener=getattr(source, "os_listener", None),
|
||||
speed=getattr(source, "speed", None),
|
||||
use_real_time=getattr(source, "use_real_time", None),
|
||||
latitude=getattr(source, "latitude", None),
|
||||
num_candles=getattr(source, "num_candles", None),
|
||||
overlay_active=overlay_active,
|
||||
tags=getattr(source, 'tags', []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
@@ -140,6 +120,27 @@ async def list_color_strip_sources(
|
||||
return ColorStripSourceListResponse(sources=responses, count=len(responses))
|
||||
|
||||
|
||||
def _extract_css_kwargs(data) -> dict:
|
||||
"""Extract store-compatible kwargs from a Pydantic CSS create/update schema.
|
||||
|
||||
Converts nested Pydantic models (calibration, stops, layers, zones,
|
||||
animation) to plain dicts/lists that the store expects.
|
||||
"""
|
||||
kwargs = data.model_dump(exclude_unset=False, exclude={"calibration", "stops", "layers", "zones", "animation"})
|
||||
# Remove fields that don't map to store kwargs
|
||||
kwargs.pop("source_type", None)
|
||||
|
||||
if data.calibration is not None:
|
||||
kwargs["calibration"] = calibration_from_dict(data.calibration.model_dump())
|
||||
else:
|
||||
kwargs["calibration"] = None
|
||||
kwargs["stops"] = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
kwargs["layers"] = [layer.model_dump() for layer in data.layers] if data.layers is not None else None
|
||||
kwargs["zones"] = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
kwargs["animation"] = data.animation.model_dump() if data.animation else None
|
||||
return kwargs
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
|
||||
async def create_color_strip_source(
|
||||
data: ColorStripSourceCreate,
|
||||
@@ -148,63 +149,15 @@ async def create_color_strip_source(
|
||||
):
|
||||
"""Create a new color strip source."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||
|
||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
|
||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||
|
||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
picture_source_id=data.picture_source_id,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
smoothing=data.smoothing,
|
||||
interpolation_mode=data.interpolation_mode,
|
||||
led_count=data.led_count,
|
||||
calibration=calibration,
|
||||
color=data.color,
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
frame_interpolation=data.frame_interpolation,
|
||||
animation=data.animation.model_dump() if data.animation else None,
|
||||
colors=data.colors,
|
||||
effect_type=data.effect_type,
|
||||
palette=data.palette,
|
||||
intensity=data.intensity,
|
||||
scale=data.scale,
|
||||
mirror=data.mirror,
|
||||
layers=layers,
|
||||
zones=zones,
|
||||
visualization_mode=data.visualization_mode,
|
||||
audio_source_id=data.audio_source_id,
|
||||
sensitivity=data.sensitivity,
|
||||
color_peak=data.color_peak,
|
||||
fallback_color=data.fallback_color,
|
||||
timeout=data.timeout,
|
||||
clock_id=data.clock_id,
|
||||
notification_effect=data.notification_effect,
|
||||
duration_ms=data.duration_ms,
|
||||
default_color=data.default_color,
|
||||
app_colors=data.app_colors,
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
speed=data.speed,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
num_candles=data.num_candles,
|
||||
tags=data.tags,
|
||||
)
|
||||
kwargs = _extract_css_kwargs(data)
|
||||
source = store.create_source(source_type=data.source_type, **kwargs)
|
||||
fire_entity_event("color_strip_source", "created", source.id)
|
||||
return _css_to_response(source)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -237,60 +190,8 @@ async def update_color_strip_source(
|
||||
):
|
||||
"""Update a color strip source and hot-reload any running streams."""
|
||||
try:
|
||||
calibration = None
|
||||
if data.calibration is not None:
|
||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
||||
|
||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
|
||||
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
|
||||
|
||||
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
|
||||
|
||||
source = store.update_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
picture_source_id=data.picture_source_id,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
smoothing=data.smoothing,
|
||||
interpolation_mode=data.interpolation_mode,
|
||||
led_count=data.led_count,
|
||||
calibration=calibration,
|
||||
color=data.color,
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
frame_interpolation=data.frame_interpolation,
|
||||
animation=data.animation.model_dump() if data.animation else None,
|
||||
colors=data.colors,
|
||||
effect_type=data.effect_type,
|
||||
palette=data.palette,
|
||||
intensity=data.intensity,
|
||||
scale=data.scale,
|
||||
mirror=data.mirror,
|
||||
layers=layers,
|
||||
zones=zones,
|
||||
visualization_mode=data.visualization_mode,
|
||||
audio_source_id=data.audio_source_id,
|
||||
sensitivity=data.sensitivity,
|
||||
color_peak=data.color_peak,
|
||||
fallback_color=data.fallback_color,
|
||||
timeout=data.timeout,
|
||||
clock_id=data.clock_id,
|
||||
notification_effect=data.notification_effect,
|
||||
duration_ms=data.duration_ms,
|
||||
default_color=data.default_color,
|
||||
app_colors=data.app_colors,
|
||||
app_filter_mode=data.app_filter_mode,
|
||||
app_filter_list=data.app_filter_list,
|
||||
os_listener=data.os_listener,
|
||||
speed=data.speed,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
num_candles=data.num_candles,
|
||||
tags=data.tags,
|
||||
)
|
||||
kwargs = _extract_css_kwargs(data)
|
||||
source = store.update_source(source_id=source_id, **kwargs)
|
||||
|
||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||
try:
|
||||
@@ -341,6 +242,14 @@ async def delete_color_strip_source(
|
||||
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
|
||||
"Remove it from the mapped source(s) first.",
|
||||
)
|
||||
processed_names = store.get_processed_referencing(source_id)
|
||||
if processed_names:
|
||||
names = ", ".join(processed_names)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Color strip source is used as input in processed source(s): {names}. "
|
||||
"Delete or reassign the processed source(s) first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||
except HTTPException:
|
||||
@@ -505,7 +414,8 @@ async def push_colors(
|
||||
):
|
||||
"""Push raw LED colors to an api_input color strip source.
|
||||
|
||||
The colors are forwarded to all running stream instances for this source.
|
||||
Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
|
||||
The payload is forwarded to all running stream instances for this source.
|
||||
"""
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
@@ -515,15 +425,27 @@ async def push_colors(
|
||||
if not isinstance(source, ApiInputColorStripSource):
|
||||
raise HTTPException(status_code=400, detail="Source is not an api_input type")
|
||||
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
|
||||
if body.segments is not None:
|
||||
# Segment-based path
|
||||
seg_dicts = [s.model_dump() for s in body.segments]
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_segments"):
|
||||
stream.push_segments(seg_dicts)
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
"segments_applied": len(body.segments),
|
||||
}
|
||||
else:
|
||||
# Legacy flat colors path
|
||||
colors_array = np.array(body.colors, dtype=np.uint8)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
|
||||
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
stream.push_colors(colors_array)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"streams_updated": len(streams),
|
||||
@@ -570,6 +492,199 @@ async def notify_source(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"])
|
||||
async def os_notification_history(_auth: AuthRequired):
|
||||
"""Return recent OS notification capture history (newest first)."""
|
||||
from wled_controller.core.processing.os_notification_listener import get_os_notification_listener
|
||||
listener = get_os_notification_listener()
|
||||
if listener is None:
|
||||
return {"available": False, "history": []}
|
||||
return {
|
||||
"available": listener.available,
|
||||
"history": listener.recent_history,
|
||||
}
|
||||
|
||||
|
||||
# ── Transient Preview WebSocket ────────────────────────────────────────
|
||||
|
||||
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"}
|
||||
|
||||
|
||||
@router.websocket("/api/v1/color-strip-sources/preview/ws")
|
||||
async def preview_color_strip_ws(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(""),
|
||||
led_count: int = Query(100),
|
||||
fps: int = Query(20),
|
||||
):
|
||||
"""Transient preview WebSocket — stream frames for an ad-hoc source config.
|
||||
|
||||
Auth via ``?token=<api_key>&led_count=100&fps=20``.
|
||||
|
||||
After accepting, waits for a text message containing the full source config
|
||||
JSON (must include ``source_type``). Responds with a JSON metadata message,
|
||||
then streams binary RGB frames at the requested FPS.
|
||||
|
||||
Subsequent text messages are treated as config updates: if the source_type
|
||||
changed the old stream is replaced; otherwise ``update_source()`` is used.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
led_count = max(1, min(1000, led_count))
|
||||
fps = max(1, min(60, fps))
|
||||
frame_interval = 1.0 / fps
|
||||
|
||||
stream = None
|
||||
clock_id = None
|
||||
current_source_type = None
|
||||
|
||||
# Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_sync_clock_manager():
|
||||
"""Return the SyncClockManager if available."""
|
||||
try:
|
||||
mgr = get_processor_manager()
|
||||
return getattr(mgr, "_sync_clock_manager", None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_source(config: dict):
|
||||
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
|
||||
from wled_controller.storage.color_strip_source import ColorStripSource
|
||||
config.setdefault("id", "__preview__")
|
||||
config.setdefault("name", "__preview__")
|
||||
return ColorStripSource.from_dict(config)
|
||||
|
||||
def _create_stream(source):
|
||||
"""Instantiate and start the appropriate stream class for *source*."""
|
||||
from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
|
||||
s = stream_cls(source)
|
||||
if hasattr(s, "configure"):
|
||||
s.configure(led_count)
|
||||
# Inject sync clock if requested
|
||||
cid = getattr(source, "clock_id", None)
|
||||
if cid and hasattr(s, "set_clock"):
|
||||
scm = _get_sync_clock_manager()
|
||||
if scm:
|
||||
try:
|
||||
clock_rt = scm.acquire(cid)
|
||||
s.set_clock(clock_rt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Preview: could not acquire clock {cid}: {e}")
|
||||
cid = None
|
||||
else:
|
||||
cid = None
|
||||
else:
|
||||
cid = None
|
||||
s.start()
|
||||
return s, cid
|
||||
|
||||
def _stop_stream(s, cid):
|
||||
"""Stop a stream and release its clock."""
|
||||
try:
|
||||
s.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if cid:
|
||||
scm = _get_sync_clock_manager()
|
||||
if scm:
|
||||
try:
|
||||
scm.release(cid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _send_meta(source_type: str):
|
||||
meta = {"type": "meta", "led_count": led_count, "source_type": source_type}
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# Wait for initial config ────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
initial_text = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
config = _json.loads(initial_text)
|
||||
source_type = config.get("source_type")
|
||||
if source_type not in _PREVIEW_ALLOWED_TYPES:
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
|
||||
await websocket.close(code=4003, reason="Invalid source_type")
|
||||
return
|
||||
source = _build_source(config)
|
||||
stream, clock_id = _create_stream(source)
|
||||
current_source_type = source_type
|
||||
except Exception as e:
|
||||
logger.error(f"Preview WS: bad initial config: {e}")
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
await _send_meta(current_source_type)
|
||||
logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}")
|
||||
|
||||
# Frame loop ─────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Non-blocking check for incoming config updates
|
||||
try:
|
||||
msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval)
|
||||
except asyncio.TimeoutError:
|
||||
msg = None
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
if msg is not None:
|
||||
try:
|
||||
new_config = _json.loads(msg)
|
||||
new_type = new_config.get("source_type")
|
||||
if new_type not in _PREVIEW_ALLOWED_TYPES:
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
|
||||
continue
|
||||
new_source = _build_source(new_config)
|
||||
if new_type != current_source_type:
|
||||
# Source type changed — recreate stream
|
||||
_stop_stream(stream, clock_id)
|
||||
stream, clock_id = _create_stream(new_source)
|
||||
current_source_type = new_type
|
||||
else:
|
||||
stream.update_source(new_source)
|
||||
if hasattr(stream, "configure"):
|
||||
stream.configure(led_count)
|
||||
await _send_meta(current_source_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"Preview WS: bad config update: {e}")
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||
|
||||
# Send frame
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
# Stream hasn't produced a frame yet — send black
|
||||
await websocket.send_bytes(b'\x00' * led_count * 3)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Preview WS error: {e}")
|
||||
finally:
|
||||
if stream is not None:
|
||||
_stop_stream(stream, clock_id)
|
||||
logger.info("Preview WS disconnected")
|
||||
|
||||
|
||||
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
|
||||
async def css_api_input_ws(
|
||||
websocket: WebSocket,
|
||||
@@ -581,16 +696,8 @@ async def css_api_input_ws(
|
||||
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
|
||||
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -618,18 +725,41 @@ async def css_api_input_ws(
|
||||
break
|
||||
|
||||
if "text" in message:
|
||||
# JSON frame: {"colors": [[R,G,B], ...]}
|
||||
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
|
||||
import json
|
||||
try:
|
||||
data = json.loads(message["text"])
|
||||
raw_colors = data.get("colors", [])
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
await websocket.send_json({"error": str(e)})
|
||||
continue
|
||||
|
||||
if "segments" in data:
|
||||
# Segment-based path — validate and push
|
||||
try:
|
||||
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
|
||||
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
|
||||
except Exception as e:
|
||||
await websocket.send_json({"error": f"Invalid segment: {e}"})
|
||||
continue
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_segments"):
|
||||
stream.push_segments(seg_dicts)
|
||||
continue
|
||||
|
||||
elif "colors" in data:
|
||||
try:
|
||||
raw_colors = data["colors"]
|
||||
colors_array = np.array(raw_colors, dtype=np.uint8)
|
||||
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
|
||||
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
|
||||
continue
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
except (ValueError, TypeError) as e:
|
||||
await websocket.send_json({"error": str(e)})
|
||||
continue
|
||||
else:
|
||||
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
|
||||
continue
|
||||
|
||||
elif "bytes" in message:
|
||||
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
|
||||
@@ -642,7 +772,7 @@ async def css_api_input_ws(
|
||||
else:
|
||||
continue
|
||||
|
||||
# Push to all running streams
|
||||
# Push to all running streams (colors_array path only reaches here)
|
||||
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
|
||||
for stream in streams:
|
||||
if hasattr(stream, "push_colors"):
|
||||
@@ -654,3 +784,218 @@ async def css_api_input_ws(
|
||||
logger.error(f"API input WebSocket error for source {source_id}: {e}")
|
||||
finally:
|
||||
logger.info(f"API input WebSocket disconnected for source {source_id}")
|
||||
|
||||
|
||||
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||
|
||||
@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws")
|
||||
async def test_color_strip_ws(
|
||||
websocket: WebSocket,
|
||||
source_id: str,
|
||||
token: str = Query(""),
|
||||
led_count: int = Query(100),
|
||||
fps: int = Query(20),
|
||||
):
|
||||
"""WebSocket for real-time CSS source preview. Auth via ``?token=<api_key>``.
|
||||
|
||||
First message is JSON metadata (source_type, led_count, calibration segments).
|
||||
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Validate source exists
|
||||
store: ColorStripStore = get_color_strip_store()
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
# Acquire stream – unique consumer ID per WS to avoid release races
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
csm = manager.color_strip_stream_manager
|
||||
consumer_id = f"__test_{_uuid.uuid4().hex[:8]}__"
|
||||
try:
|
||||
stream = csm.acquire(source_id, consumer_id)
|
||||
except Exception as e:
|
||||
logger.error(f"CSS test: failed to acquire stream for {source_id}: {e}")
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
# Configure LED count for auto-sizing streams
|
||||
if hasattr(stream, "configure"):
|
||||
stream.configure(max(1, led_count))
|
||||
|
||||
# Clamp FPS to sane range
|
||||
fps = max(1, min(60, fps))
|
||||
_frame_interval = 1.0 / fps
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})")
|
||||
|
||||
try:
|
||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||
|
||||
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
|
||||
is_api_input = isinstance(stream, ApiInputColorStripStream)
|
||||
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
|
||||
|
||||
# Send metadata as first message
|
||||
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
|
||||
is_composite = isinstance(source, CompositeColorStripSource)
|
||||
meta: dict = {
|
||||
"type": "meta",
|
||||
"source_type": source.source_type,
|
||||
"source_name": source.name,
|
||||
"led_count": stream.led_count,
|
||||
}
|
||||
if is_picture and stream.calibration:
|
||||
cal = stream.calibration
|
||||
total = cal.get_total_leds()
|
||||
offset = cal.offset % total if total > 0 else 0
|
||||
edges = []
|
||||
for seg in cal.segments:
|
||||
# Compute output indices matching PixelMapper logic
|
||||
indices = list(range(seg.led_start, seg.led_start + seg.led_count))
|
||||
if seg.reverse:
|
||||
indices = indices[::-1]
|
||||
if offset > 0:
|
||||
indices = [(idx + offset) % total for idx in indices]
|
||||
edges.append({"edge": seg.edge, "indices": indices})
|
||||
meta["edges"] = edges
|
||||
meta["border_width"] = cal.border_width
|
||||
if is_composite and hasattr(source, "layers"):
|
||||
# Send layer info for composite preview
|
||||
enabled_layers = [layer for layer in source.layers if layer.get("enabled", True)]
|
||||
layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...]
|
||||
for layer in enabled_layers:
|
||||
info = {"id": layer["source_id"], "name": layer.get("source_id", "?"),
|
||||
"is_notification": False, "has_brightness": bool(layer.get("brightness_source_id"))}
|
||||
try:
|
||||
layer_src = store.get_source(layer["source_id"])
|
||||
info["name"] = layer_src.name
|
||||
info["is_notification"] = isinstance(layer_src, NotificationColorStripSource)
|
||||
if isinstance(layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
info["is_picture"] = True
|
||||
if hasattr(layer_src, "calibration") and layer_src.calibration:
|
||||
info["calibration_led_count"] = layer_src.calibration.get_total_leds()
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
layer_infos.append(info)
|
||||
meta["layers"] = [li["name"] for li in layer_infos]
|
||||
meta["layer_infos"] = layer_infos
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# For api_input: send the current buffer immediately so the client
|
||||
# gets a frame right away (fallback color if inactive) rather than
|
||||
# leaving the canvas blank/stale until external data arrives.
|
||||
if is_api_input:
|
||||
initial_colors = stream.get_latest_colors()
|
||||
if initial_colors is not None:
|
||||
await websocket.send_bytes(initial_colors.tobytes())
|
||||
|
||||
# For picture sources, grab the live stream for frame preview
|
||||
_frame_live = None
|
||||
if is_picture and hasattr(stream, 'live_stream'):
|
||||
_frame_live = stream.live_stream
|
||||
_last_aux_time = 0.0
|
||||
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
|
||||
_frame_dims_sent = False # send frame dimensions once with first JPEG
|
||||
|
||||
# Stream binary RGB frames at ~20 Hz
|
||||
while True:
|
||||
# For composite sources, send per-layer data like target preview does
|
||||
if is_composite and isinstance(stream, CompositeColorStripStream):
|
||||
layer_colors = stream.get_layer_colors()
|
||||
composite_colors = stream.get_latest_colors()
|
||||
if composite_colors is not None and layer_colors and len(layer_colors) > 1:
|
||||
led_count = composite_colors.shape[0]
|
||||
rgb_size = led_count * 3
|
||||
# Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb]
|
||||
header = bytes([0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF])
|
||||
parts = [header]
|
||||
for lc in layer_colors:
|
||||
if lc is not None and lc.shape[0] == led_count:
|
||||
parts.append(lc.tobytes())
|
||||
else:
|
||||
parts.append(b'\x00' * rgb_size)
|
||||
parts.append(composite_colors.tobytes())
|
||||
await websocket.send_bytes(b''.join(parts))
|
||||
elif composite_colors is not None:
|
||||
await websocket.send_bytes(composite_colors.tobytes())
|
||||
else:
|
||||
# For api_input: only send when new data was pushed
|
||||
if is_api_input:
|
||||
gen = stream.push_generation
|
||||
if gen != _last_push_gen:
|
||||
_last_push_gen = gen
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
else:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
|
||||
# Periodically send auxiliary data (frame preview, brightness)
|
||||
now = _time.monotonic()
|
||||
if now - _last_aux_time >= _AUX_INTERVAL:
|
||||
_last_aux_time = now
|
||||
|
||||
# Send brightness values for composite layers
|
||||
if is_composite and isinstance(stream, CompositeColorStripStream):
|
||||
try:
|
||||
bri_values = stream.get_layer_brightness()
|
||||
if any(v is not None for v in bri_values):
|
||||
bri_msg = {"type": "brightness", "values": [
|
||||
round(v * 100) if v is not None else None for v in bri_values
|
||||
]}
|
||||
await websocket.send_text(_json.dumps(bri_msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send JPEG frame preview for picture sources
|
||||
if _frame_live:
|
||||
try:
|
||||
frame = _frame_live.get_latest_frame()
|
||||
if frame is not None and frame.image is not None:
|
||||
from PIL import Image as _PIL_Image
|
||||
img = frame.image
|
||||
# Ensure 3-channel RGB (some engines may produce BGRA)
|
||||
if img.ndim == 3 and img.shape[2] == 4:
|
||||
img = img[:, :, :3]
|
||||
h, w = img.shape[:2]
|
||||
# Send frame dimensions once so client can compute border overlay
|
||||
if not _frame_dims_sent:
|
||||
_frame_dims_sent = True
|
||||
await websocket.send_text(_json.dumps({
|
||||
"type": "frame_dims",
|
||||
"width": w,
|
||||
"height": h,
|
||||
}))
|
||||
# Downscale for bandwidth
|
||||
scale = min(960 / w, 540 / h, 1.0)
|
||||
if scale < 1.0:
|
||||
new_w = max(1, int(w * scale))
|
||||
new_h = max(1, int(h * scale))
|
||||
pil = _PIL_Image.fromarray(img).resize((new_w, new_h), _PIL_Image.LANCZOS)
|
||||
else:
|
||||
pil = _PIL_Image.fromarray(img)
|
||||
buf = _io.BytesIO()
|
||||
pil.save(buf, format='JPEG', quality=70)
|
||||
# Wire format: [0xFD] [jpeg_bytes]
|
||||
await websocket.send_bytes(b'\xfd' + buf.getvalue())
|
||||
except Exception as e:
|
||||
logger.warning(f"JPEG frame preview error: {e}")
|
||||
|
||||
await asyncio.sleep(_frame_interval)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"CSS test WebSocket error for {source_id}: {e}")
|
||||
finally:
|
||||
csm.release(source_id, consumer_id)
|
||||
logger.info(f"CSS test WebSocket disconnected for {source_id}")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
||||
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -18,6 +16,7 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.devices import (
|
||||
BrightnessRequest,
|
||||
DeviceCreate,
|
||||
DeviceListResponse,
|
||||
DeviceResponse,
|
||||
@@ -27,6 +26,7 @@ from wled_controller.api.schemas.devices import (
|
||||
DiscoverDevicesResponse,
|
||||
OpenRGBZoneResponse,
|
||||
OpenRGBZonesResponse,
|
||||
PowerRequest,
|
||||
)
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage import DeviceStore
|
||||
@@ -53,7 +53,20 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
rgbw=device.rgbw,
|
||||
zone_mode=device.zone_mode,
|
||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||
tags=getattr(device, 'tags', []),
|
||||
tags=device.tags,
|
||||
dmx_protocol=device.dmx_protocol,
|
||||
dmx_start_universe=device.dmx_start_universe,
|
||||
dmx_start_channel=device.dmx_start_channel,
|
||||
espnow_peer_mac=device.espnow_peer_mac,
|
||||
espnow_channel=device.espnow_channel,
|
||||
hue_username=device.hue_username,
|
||||
hue_client_key=device.hue_client_key,
|
||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
||||
spi_speed_hz=device.spi_speed_hz,
|
||||
spi_led_type=device.spi_led_type,
|
||||
chroma_device_type=device.chroma_device_type,
|
||||
gamesense_device_type=device.gamesense_device_type,
|
||||
default_css_processing_template_id=device.default_css_processing_template_id,
|
||||
created_at=device.created_at,
|
||||
updated_at=device.updated_at,
|
||||
)
|
||||
@@ -129,12 +142,23 @@ async def create_device(
|
||||
rgbw=device_data.rgbw or False,
|
||||
zone_mode=device_data.zone_mode or "combined",
|
||||
tags=device_data.tags,
|
||||
dmx_protocol=device_data.dmx_protocol or "artnet",
|
||||
dmx_start_universe=device_data.dmx_start_universe or 0,
|
||||
dmx_start_channel=device_data.dmx_start_channel or 1,
|
||||
espnow_peer_mac=device_data.espnow_peer_mac or "",
|
||||
espnow_channel=device_data.espnow_channel or 1,
|
||||
hue_username=device_data.hue_username or "",
|
||||
hue_client_key=device_data.hue_client_key or "",
|
||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id or "",
|
||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
|
||||
)
|
||||
|
||||
# WS devices: auto-set URL to ws://{device_id}
|
||||
if device_type == "ws":
|
||||
store.update_device(device_id=device.id, url=f"ws://{device.id}")
|
||||
device = store.get_device(device.id)
|
||||
device = store.update_device(device.id, url=f"ws://{device.id}")
|
||||
|
||||
# Register in processor manager for health monitoring
|
||||
manager.add_device(
|
||||
@@ -285,9 +309,10 @@ async def get_device(
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get device details by ID."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return _device_to_response(device)
|
||||
|
||||
|
||||
@@ -313,6 +338,18 @@ async def update_device(
|
||||
rgbw=update_data.rgbw,
|
||||
zone_mode=update_data.zone_mode,
|
||||
tags=update_data.tags,
|
||||
dmx_protocol=update_data.dmx_protocol,
|
||||
dmx_start_universe=update_data.dmx_start_universe,
|
||||
dmx_start_channel=update_data.dmx_start_channel,
|
||||
espnow_peer_mac=update_data.espnow_peer_mac,
|
||||
espnow_channel=update_data.espnow_channel,
|
||||
hue_username=update_data.hue_username,
|
||||
hue_client_key=update_data.hue_client_key,
|
||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
||||
spi_speed_hz=update_data.spi_speed_hz,
|
||||
spi_led_type=update_data.spi_led_type,
|
||||
chroma_device_type=update_data.chroma_device_type,
|
||||
gamesense_device_type=update_data.gamesense_device_type,
|
||||
)
|
||||
|
||||
# Sync connection info in processor manager
|
||||
@@ -394,9 +431,10 @@ async def get_device_state(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get device health/connection state."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
state = manager.get_device_health_dict(device_id)
|
||||
@@ -406,6 +444,27 @@ async def get_device_state(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"])
|
||||
async def ping_device(
|
||||
device_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Force an immediate health check on a device."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
state = await manager.force_device_health_check(device_id)
|
||||
state["device_type"] = device.device_type
|
||||
return DeviceStateResponse(**state)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
# ===== WLED BRIGHTNESS ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||
@@ -421,9 +480,10 @@ async def get_device_brightness(
|
||||
frontend request — hitting the ESP32 over WiFi in the async event loop
|
||||
causes ~150 ms jitter in the processing loop.
|
||||
"""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -450,21 +510,20 @@ async def get_device_brightness(
|
||||
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||
async def set_device_brightness(
|
||||
device_id: str,
|
||||
body: dict,
|
||||
body: BrightnessRequest,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Set brightness on the device."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
bri = body.get("brightness")
|
||||
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:
|
||||
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
|
||||
bri = body.brightness
|
||||
|
||||
try:
|
||||
try:
|
||||
@@ -472,10 +531,7 @@ async def set_device_brightness(
|
||||
await provider.set_brightness(device.url, bri)
|
||||
except NotImplementedError:
|
||||
# Provider has no hardware brightness; use software brightness
|
||||
device.software_brightness = bri
|
||||
from datetime import datetime, timezone
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
store.save()
|
||||
store.update_device(device_id=device_id, software_brightness=bri)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds:
|
||||
ds.software_brightness = bri
|
||||
@@ -501,9 +557,10 @@ async def get_device_power(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get current power state from the device."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "power_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -524,21 +581,20 @@ async def get_device_power(
|
||||
@router.put("/api/v1/devices/{device_id}/power", tags=["Settings"])
|
||||
async def set_device_power(
|
||||
device_id: str,
|
||||
body: dict,
|
||||
body: PowerRequest,
|
||||
_auth: AuthRequired,
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Turn device on or off."""
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "power_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
|
||||
|
||||
on = body.get("on")
|
||||
if on is None or not isinstance(on, bool):
|
||||
raise HTTPException(status_code=400, detail="'on' must be a boolean")
|
||||
on = body.power
|
||||
|
||||
try:
|
||||
# For serial devices, use the cached idle client to avoid port conflicts
|
||||
@@ -572,23 +628,15 @@ async def device_ws_stream(
|
||||
Wire format: [brightness_byte][R G B R G B ...]
|
||||
Auth via ?token=<api_key>.
|
||||
"""
|
||||
from wled_controller.config import get_config
|
||||
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
store = get_device_store()
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Device not found")
|
||||
return
|
||||
if device.device_type != "ws":
|
||||
|
||||
@@ -1,57 +1,25 @@
|
||||
"""Output target routes: CRUD, processing control, settings, state, metrics."""
|
||||
"""Output target routes: CRUD endpoints and batch state/metrics queries."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from PIL import Image
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_device_store,
|
||||
get_pattern_template_store,
|
||||
get_picture_source_store,
|
||||
get_output_target_store,
|
||||
get_pp_template_store,
|
||||
get_processor_manager,
|
||||
get_template_store,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import (
|
||||
ExtractedColorResponse,
|
||||
KCTestRectangleResponse,
|
||||
KCTestResponse,
|
||||
KeyColorsResponse,
|
||||
KeyColorsSettingsSchema,
|
||||
OutputTargetCreate,
|
||||
OutputTargetListResponse,
|
||||
OutputTargetResponse,
|
||||
OutputTargetUpdate,
|
||||
TargetMetricsResponse,
|
||||
TargetProcessingState,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
calculate_average_color,
|
||||
calculate_dominant_color,
|
||||
calculate_median_color,
|
||||
get_available_displays,
|
||||
)
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||
from wled_controller.storage.key_colors_output_target import (
|
||||
KeyColorsSettings,
|
||||
@@ -59,6 +27,7 @@ from wled_controller.storage.key_colors_output_target import (
|
||||
)
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -98,7 +67,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
target_type=target.target_type,
|
||||
device_id=target.device_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
brightness_value_source_id=target.brightness_value_source_id,
|
||||
brightness_value_source_id=target.brightness_value_source_id or "",
|
||||
fps=target.fps,
|
||||
keepalive_interval=target.keepalive_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
@@ -106,7 +75,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
adaptive_fps=target.adaptive_fps,
|
||||
protocol=target.protocol,
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -119,7 +88,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
picture_source_id=target.picture_source_id,
|
||||
key_colors_settings=_kc_settings_to_schema(target.settings),
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -130,7 +99,7 @@ def _target_to_response(target) -> OutputTargetResponse:
|
||||
name=target.name,
|
||||
target_type=target.target_type,
|
||||
description=target.description,
|
||||
tags=getattr(target, 'tags', []),
|
||||
tags=target.tags,
|
||||
|
||||
created_at=target.created_at,
|
||||
updated_at=target.updated_at,
|
||||
@@ -151,8 +120,9 @@ async def create_target(
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
if data.device_id:
|
||||
device = device_store.get_device(data.device_id)
|
||||
if not device:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
@@ -187,6 +157,9 @@ async def create_target(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -250,8 +223,9 @@ async def update_target(
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
if data.device_id is not None and data.device_id:
|
||||
device = device_store.get_device(data.device_id)
|
||||
if not device:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
# Build KC settings with partial-update support: only apply fields that were
|
||||
@@ -315,12 +289,18 @@ async def update_target(
|
||||
data.adaptive_fps is not None or
|
||||
data.key_colors_settings is not None),
|
||||
css_changed=data.color_strip_source_id is not None,
|
||||
device_changed=data.device_id is not None,
|
||||
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Device change requires async stop -> swap -> start cycle
|
||||
if data.device_id is not None:
|
||||
try:
|
||||
await manager.update_target_device(target_id, target.device_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
fire_entity_event("output_target", "updated", target_id)
|
||||
return _target_to_response(target)
|
||||
|
||||
@@ -365,523 +345,3 @@ async def delete_target(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete target: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
|
||||
async def start_processing(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Start processing for a output target."""
|
||||
try:
|
||||
# Verify target exists in store
|
||||
target_store.get_target(target_id)
|
||||
|
||||
await manager.start_processing(target_id)
|
||||
|
||||
logger.info(f"Started processing for target {target_id}")
|
||||
return {"status": "started", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
|
||||
async def stop_processing(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop processing for a output target."""
|
||||
try:
|
||||
await manager.stop_processing(target_id)
|
||||
|
||||
logger.info(f"Stopped processing for target {target_id}")
|
||||
return {"status": "stopped", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== STATE & METRICS ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
|
||||
async def get_target_state(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get current processing state for a target."""
|
||||
try:
|
||||
state = manager.get_target_state(target_id)
|
||||
return TargetProcessingState(**state)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get target state: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
|
||||
async def get_target_metrics(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get processing metrics for a target."""
|
||||
try:
|
||||
metrics = manager.get_target_metrics(target_id)
|
||||
return TargetMetricsResponse(**metrics)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get target metrics: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== KEY COLORS ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
|
||||
async def get_target_colors(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get latest extracted colors for a key-colors target (polling)."""
|
||||
try:
|
||||
raw_colors = manager.get_kc_latest_colors(target_id)
|
||||
colors = {}
|
||||
for name, (r, g, b) in raw_colors.items():
|
||||
colors[name] = ExtractedColorResponse(
|
||||
r=r, g=g, b=b,
|
||||
hex=f"#{r:02x}{g:02x}{b:02x}",
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
return KeyColorsResponse(
|
||||
target_id=target_id,
|
||||
colors=colors,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
|
||||
async def test_kc_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
template_store: TemplateStore = Depends(get_template_store),
|
||||
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
pp_template_store=Depends(get_pp_template_store),
|
||||
):
|
||||
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
|
||||
import httpx
|
||||
|
||||
stream = None
|
||||
try:
|
||||
# 1. Load and validate KC target
|
||||
try:
|
||||
target = target_store.get_target(target_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
if not isinstance(target, KeyColorsOutputTarget):
|
||||
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
|
||||
|
||||
settings = target.settings
|
||||
|
||||
# 2. Resolve pattern template
|
||||
if not settings.pattern_template_id:
|
||||
raise HTTPException(status_code=400, detail="No pattern template configured")
|
||||
|
||||
try:
|
||||
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
|
||||
|
||||
rectangles = pattern_tmpl.rectangles
|
||||
if not rectangles:
|
||||
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
|
||||
|
||||
# 3. Resolve picture source and capture a frame
|
||||
if not target.picture_source_id:
|
||||
raise HTTPException(status_code=400, detail="No picture source configured")
|
||||
|
||||
try:
|
||||
chain = source_store.resolve_stream_chain(target.picture_source_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
source = raw_stream.image_source
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
else:
|
||||
from pathlib import Path
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
|
||||
elif isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
try:
|
||||
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Capture template not found: {raw_stream.capture_template_id}",
|
||||
)
|
||||
|
||||
display_index = raw_stream.display_index
|
||||
|
||||
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
|
||||
)
|
||||
|
||||
locked_device_id = processor_manager.get_display_lock_info(display_index)
|
||||
if locked_device_id:
|
||||
try:
|
||||
device = device_store.get_device(locked_device_id)
|
||||
device_name = device.name
|
||||
except Exception:
|
||||
device_name = locked_device_id
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
|
||||
f"Please stop the device processing before testing.",
|
||||
)
|
||||
|
||||
stream = EngineRegistry.create_stream(
|
||||
capture_template.engine_type, display_index, capture_template.engine_config
|
||||
)
|
||||
stream.initialize()
|
||||
|
||||
screen_capture = stream.capture_frame()
|
||||
if screen_capture is None:
|
||||
raise RuntimeError("No frame captured")
|
||||
|
||||
if isinstance(screen_capture.image, np.ndarray):
|
||||
pil_image = Image.fromarray(screen_capture.image)
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported picture source type")
|
||||
|
||||
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
|
||||
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||
if pp_template_ids and pp_template_store:
|
||||
img_array = np.array(pil_image)
|
||||
image_pool = ImagePool()
|
||||
for pp_id in pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_template_store.get_template(pp_id)
|
||||
except ValueError:
|
||||
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
|
||||
continue
|
||||
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
|
||||
for fi in flat_filters:
|
||||
try:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(img_array, image_pool)
|
||||
if result is not None:
|
||||
img_array = result
|
||||
except ValueError:
|
||||
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
|
||||
pil_image = Image.fromarray(img_array)
|
||||
|
||||
# 4. Extract colors from each rectangle
|
||||
img_array = np.array(pil_image)
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
calc_fns = {
|
||||
"average": calculate_average_color,
|
||||
"median": calculate_median_color,
|
||||
"dominant": calculate_dominant_color,
|
||||
}
|
||||
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
|
||||
|
||||
result_rects = []
|
||||
for rect in rectangles:
|
||||
px_x = max(0, int(rect.x * w))
|
||||
px_y = max(0, int(rect.y * h))
|
||||
px_w = max(1, int(rect.width * w))
|
||||
px_h = max(1, int(rect.height * h))
|
||||
px_x = min(px_x, w - 1)
|
||||
px_y = min(px_y, h - 1)
|
||||
px_w = min(px_w, w - px_x)
|
||||
px_h = min(px_h, h - px_y)
|
||||
|
||||
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
|
||||
r, g, b = calc_fn(sub_img)
|
||||
|
||||
result_rects.append(KCTestRectangleResponse(
|
||||
name=rect.name,
|
||||
x=rect.x,
|
||||
y=rect.y,
|
||||
width=rect.width,
|
||||
height=rect.height,
|
||||
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
|
||||
))
|
||||
|
||||
# 5. Encode frame as base64 JPEG
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
return KCTestResponse(
|
||||
image=image_data_uri,
|
||||
rectangles=result_rects,
|
||||
interpolation_mode=settings.interpolation_mode,
|
||||
pattern_template_name=pattern_tmpl.name,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test KC target: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if stream:
|
||||
try:
|
||||
stream.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up test stream: {e}")
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/ws")
|
||||
async def target_colors_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_processor_manager()
|
||||
|
||||
try:
|
||||
manager.add_kc_ws_client(target_id, websocket)
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Target not found")
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Keep alive — wait for client messages (or disconnect)
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.remove_kc_ws_client(target_id, websocket)
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
|
||||
async def led_preview_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_processor_manager()
|
||||
|
||||
try:
|
||||
manager.add_led_preview_client(target_id, websocket)
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Target not found")
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.remove_led_preview_client(target_id, websocket)
|
||||
|
||||
|
||||
# ===== STATE CHANGE EVENT STREAM =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/events/ws")
|
||||
async def events_ws(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_processor_manager()
|
||||
queue = manager.subscribe_events()
|
||||
|
||||
try:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
await websocket.send_json(event)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
manager.unsubscribe_events(queue)
|
||||
|
||||
|
||||
# ===== OVERLAY VISUALIZATION =====
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
|
||||
async def start_target_overlay(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
color_strip_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
):
|
||||
"""Start screen overlay visualization for a target.
|
||||
|
||||
Displays a transparent overlay on the target display showing:
|
||||
- Border sampling zones (colored rectangles)
|
||||
- LED position markers (numbered dots)
|
||||
- Pixel-to-LED mapping ranges (colored segments)
|
||||
- Calibration info text
|
||||
"""
|
||||
try:
|
||||
# Get target name from store
|
||||
target = target_store.get_target(target_id)
|
||||
if not target:
|
||||
raise ValueError(f"Target {target_id} not found")
|
||||
|
||||
# Pre-load calibration and display info from the CSS store so the overlay
|
||||
# can start even when processing is not currently running.
|
||||
calibration = None
|
||||
display_info = None
|
||||
if isinstance(target, WledOutputTarget) and target.color_strip_source_id:
|
||||
first_css_id = target.color_strip_source_id
|
||||
if first_css_id:
|
||||
try:
|
||||
css = color_strip_store.get_source(first_css_id)
|
||||
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
|
||||
calibration = css.calibration
|
||||
# Resolve the display this CSS is capturing
|
||||
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
|
||||
ps_id = getattr(css, "picture_source_id", "") or ""
|
||||
display_index = _resolve_display_index(ps_id, picture_source_store)
|
||||
displays = get_available_displays()
|
||||
if displays:
|
||||
display_index = min(display_index, len(displays) - 1)
|
||||
display_info = displays[display_index]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
|
||||
|
||||
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
|
||||
return {"status": "started", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
|
||||
async def stop_target_overlay(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop screen overlay visualization for a target."""
|
||||
try:
|
||||
await manager.stop_overlay(target_id)
|
||||
return {"status": "stopped", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
|
||||
async def get_overlay_status(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Check if overlay is active for a target."""
|
||||
try:
|
||||
active = manager.is_overlay_active(target_id)
|
||||
return {"target_id": target_id, "active": active}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
338
server/src/wled_controller/api/routes/output_targets_control.py
Normal file
338
server/src/wled_controller/api/routes/output_targets_control.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""Output target routes: processing control, state, metrics, events, overlay.
|
||||
|
||||
Extracted from output_targets.py to keep files under 800 lines.
|
||||
"""
|
||||
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
get_color_strip_store,
|
||||
get_output_target_store,
|
||||
get_picture_source_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import (
|
||||
BulkTargetRequest,
|
||||
BulkTargetResponse,
|
||||
TargetMetricsResponse,
|
||||
TargetProcessingState,
|
||||
)
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets/bulk/start", response_model=BulkTargetResponse, tags=["Processing"])
|
||||
async def bulk_start_processing(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Start processing for multiple output targets. Returns lists of started IDs and per-ID errors."""
|
||||
started: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for target_id in body.ids:
|
||||
try:
|
||||
target_store.get_target(target_id)
|
||||
await manager.start_processing(target_id)
|
||||
started.append(target_id)
|
||||
logger.info(f"Bulk start: started processing for target {target_id}")
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except RuntimeError as e:
|
||||
msg = str(e)
|
||||
for t in target_store.get_all_targets():
|
||||
if t.id in msg:
|
||||
msg = msg.replace(t.id, f"'{t.name}'")
|
||||
errors[target_id] = msg
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk start: failed to start target {target_id}: {e}")
|
||||
errors[target_id] = str(e)
|
||||
|
||||
return BulkTargetResponse(started=started, errors=errors)
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/bulk/stop", response_model=BulkTargetResponse, tags=["Processing"])
|
||||
async def bulk_stop_processing(
|
||||
body: BulkTargetRequest,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
|
||||
stopped: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for target_id in body.ids:
|
||||
try:
|
||||
await manager.stop_processing(target_id)
|
||||
stopped.append(target_id)
|
||||
logger.info(f"Bulk stop: stopped processing for target {target_id}")
|
||||
except ValueError as e:
|
||||
errors[target_id] = str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk stop: failed to stop target {target_id}: {e}")
|
||||
errors[target_id] = str(e)
|
||||
|
||||
return BulkTargetResponse(stopped=stopped, errors=errors)
|
||||
|
||||
|
||||
# ===== PROCESSING CONTROL ENDPOINTS =====
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/start", tags=["Processing"])
|
||||
async def start_processing(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Start processing for a output target."""
|
||||
try:
|
||||
# Verify target exists in store
|
||||
target_store.get_target(target_id)
|
||||
|
||||
await manager.start_processing(target_id)
|
||||
|
||||
logger.info(f"Started processing for target {target_id}")
|
||||
return {"status": "started", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
# Resolve target IDs to human-readable names in error messages
|
||||
msg = str(e)
|
||||
for t in target_store.get_all_targets():
|
||||
if t.id in msg:
|
||||
msg = msg.replace(t.id, f"'{t.name}'")
|
||||
raise HTTPException(status_code=409, detail=msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
|
||||
async def stop_processing(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop processing for a output target."""
|
||||
try:
|
||||
await manager.stop_processing(target_id)
|
||||
|
||||
logger.info(f"Stopped processing for target {target_id}")
|
||||
return {"status": "stopped", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop processing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== STATE & METRICS ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/state", response_model=TargetProcessingState, tags=["Processing"])
|
||||
async def get_target_state(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get current processing state for a target."""
|
||||
try:
|
||||
state = manager.get_target_state(target_id)
|
||||
return TargetProcessingState(**state)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get target state: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
|
||||
async def get_target_metrics(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get processing metrics for a target."""
|
||||
try:
|
||||
metrics = manager.get_target_metrics(target_id)
|
||||
return TargetMetricsResponse(**metrics)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get target metrics: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ===== STATE CHANGE EVENT STREAM =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/events/ws")
|
||||
async def events_ws(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_processor_manager()
|
||||
queue = manager.subscribe_events()
|
||||
|
||||
try:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
await websocket.send_json(event)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
manager.unsubscribe_events(queue)
|
||||
|
||||
|
||||
# ===== OVERLAY VISUALIZATION =====
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/overlay/start", tags=["Visualization"])
|
||||
async def start_target_overlay(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
color_strip_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
picture_source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
):
|
||||
"""Start screen overlay visualization for a target.
|
||||
|
||||
Displays a transparent overlay on the target display showing:
|
||||
- Border sampling zones (colored rectangles)
|
||||
- LED position markers (numbered dots)
|
||||
- Pixel-to-LED mapping ranges (colored segments)
|
||||
- Calibration info text
|
||||
"""
|
||||
try:
|
||||
# Get target name from store
|
||||
target = target_store.get_target(target_id)
|
||||
if not target:
|
||||
raise ValueError(f"Target {target_id} not found")
|
||||
|
||||
# Pre-load calibration and display info from the CSS store so the overlay
|
||||
# can start even when processing is not currently running.
|
||||
calibration = None
|
||||
display_info = None
|
||||
if isinstance(target, WledOutputTarget) and target.color_strip_source_id:
|
||||
first_css_id = target.color_strip_source_id
|
||||
if first_css_id:
|
||||
try:
|
||||
css = color_strip_store.get_source(first_css_id)
|
||||
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
|
||||
calibration = css.calibration
|
||||
# Resolve the display this CSS is capturing
|
||||
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
|
||||
ps_id = getattr(css, "picture_source_id", "") or ""
|
||||
display_index = _resolve_display_index(ps_id, picture_source_store)
|
||||
displays = get_available_displays()
|
||||
if displays:
|
||||
display_index = min(display_index, len(displays) - 1)
|
||||
display_info = displays[display_index]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
|
||||
|
||||
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
|
||||
return {"status": "started", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
|
||||
async def stop_target_overlay(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Stop screen overlay visualization for a target."""
|
||||
try:
|
||||
await manager.stop_overlay(target_id)
|
||||
return {"status": "stopped", "target_id": target_id}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])
|
||||
async def get_overlay_status(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Check if overlay is active for a target."""
|
||||
try:
|
||||
active = manager.is_overlay_active(target_id)
|
||||
return {"target_id": target_id, "active": active}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
# ===== LED PREVIEW WEBSOCKET =====
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/led-preview/ws")
|
||||
async def led_preview_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_processor_manager()
|
||||
|
||||
try:
|
||||
manager.add_led_preview_client(target_id, websocket)
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Target not found")
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.remove_led_preview_client(target_id, websocket)
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Output target routes: key colors endpoints, testing, and WebSocket streams.
|
||||
|
||||
Extracted from output_targets.py to keep files under 800 lines.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
get_device_store,
|
||||
get_output_target_store,
|
||||
get_pattern_template_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_processor_manager,
|
||||
get_template_store,
|
||||
)
|
||||
from wled_controller.api.schemas.output_targets import (
|
||||
ExtractedColorResponse,
|
||||
KCTestRectangleResponse,
|
||||
KCTestResponse,
|
||||
KeyColorsResponse,
|
||||
)
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
calculate_average_color,
|
||||
calculate_dominant_color,
|
||||
calculate_median_color,
|
||||
)
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===== KEY COLORS ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/output-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
|
||||
async def get_target_colors(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get latest extracted colors for a key-colors target (polling)."""
|
||||
try:
|
||||
raw_colors = manager.get_kc_latest_colors(target_id)
|
||||
colors = {}
|
||||
for name, (r, g, b) in raw_colors.items():
|
||||
colors[name] = ExtractedColorResponse(
|
||||
r=r, g=g, b=b,
|
||||
hex=f"#{r:02x}{g:02x}{b:02x}",
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
return KeyColorsResponse(
|
||||
target_id=target_id,
|
||||
colors=colors,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/output-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"])
|
||||
async def test_kc_target(
|
||||
target_id: str,
|
||||
_auth: AuthRequired,
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
source_store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
template_store: TemplateStore = Depends(get_template_store),
|
||||
pattern_store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||
processor_manager: ProcessorManager = Depends(get_processor_manager),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
pp_template_store=Depends(get_pp_template_store),
|
||||
):
|
||||
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
|
||||
import httpx
|
||||
|
||||
stream = None
|
||||
try:
|
||||
# 1. Load and validate KC target
|
||||
try:
|
||||
target = target_store.get_target(target_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
if not isinstance(target, KeyColorsOutputTarget):
|
||||
raise HTTPException(status_code=400, detail="Target is not a key_colors target")
|
||||
|
||||
settings = target.settings
|
||||
|
||||
# 2. Resolve pattern template
|
||||
if not settings.pattern_template_id:
|
||||
raise HTTPException(status_code=400, detail="No pattern template configured")
|
||||
|
||||
try:
|
||||
pattern_tmpl = pattern_store.get_template(settings.pattern_template_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}")
|
||||
|
||||
rectangles = pattern_tmpl.rectangles
|
||||
if not rectangles:
|
||||
raise HTTPException(status_code=400, detail="Pattern template has no rectangles")
|
||||
|
||||
# 3. Resolve picture source and capture a frame
|
||||
if not target.picture_source_id:
|
||||
raise HTTPException(status_code=400, detail="No picture source configured")
|
||||
|
||||
try:
|
||||
chain = source_store.resolve_stream_chain(target.picture_source_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
source = raw_stream.image_source
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
else:
|
||||
from pathlib import Path
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
|
||||
elif isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
try:
|
||||
capture_template = template_store.get_template(raw_stream.capture_template_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Capture template not found: {raw_stream.capture_template_id}",
|
||||
)
|
||||
|
||||
display_index = raw_stream.display_index
|
||||
|
||||
if capture_template.engine_type not in EngineRegistry.get_available_engines():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Engine '{capture_template.engine_type}' is not available on this system",
|
||||
)
|
||||
|
||||
locked_device_id = processor_manager.get_display_lock_info(display_index)
|
||||
if locked_device_id:
|
||||
try:
|
||||
device = device_store.get_device(locked_device_id)
|
||||
device_name = device.name
|
||||
except Exception:
|
||||
device_name = locked_device_id
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Display {display_index} is currently being captured by device '{device_name}'. "
|
||||
f"Please stop the device processing before testing.",
|
||||
)
|
||||
|
||||
stream = EngineRegistry.create_stream(
|
||||
capture_template.engine_type, display_index, capture_template.engine_config
|
||||
)
|
||||
stream.initialize()
|
||||
|
||||
screen_capture = stream.capture_frame()
|
||||
if screen_capture is None:
|
||||
raise RuntimeError("No frame captured")
|
||||
|
||||
if isinstance(screen_capture.image, np.ndarray):
|
||||
pil_image = Image.fromarray(screen_capture.image)
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported picture source type")
|
||||
|
||||
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
|
||||
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||
if pp_template_ids and pp_template_store:
|
||||
img_array = np.array(pil_image)
|
||||
image_pool = ImagePool()
|
||||
for pp_id in pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_template_store.get_template(pp_id)
|
||||
except ValueError:
|
||||
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
|
||||
continue
|
||||
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
|
||||
for fi in flat_filters:
|
||||
try:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(img_array, image_pool)
|
||||
if result is not None:
|
||||
img_array = result
|
||||
except ValueError:
|
||||
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
|
||||
pil_image = Image.fromarray(img_array)
|
||||
|
||||
# 4. Extract colors from each rectangle
|
||||
img_array = np.array(pil_image)
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
calc_fns = {
|
||||
"average": calculate_average_color,
|
||||
"median": calculate_median_color,
|
||||
"dominant": calculate_dominant_color,
|
||||
}
|
||||
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
|
||||
|
||||
result_rects = []
|
||||
for rect in rectangles:
|
||||
px_x = max(0, int(rect.x * w))
|
||||
px_y = max(0, int(rect.y * h))
|
||||
px_w = max(1, int(rect.width * w))
|
||||
px_h = max(1, int(rect.height * h))
|
||||
px_x = min(px_x, w - 1)
|
||||
px_y = min(px_y, h - 1)
|
||||
px_w = min(px_w, w - px_x)
|
||||
px_h = min(px_h, h - px_y)
|
||||
|
||||
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
|
||||
r, g, b = calc_fn(sub_img)
|
||||
|
||||
result_rects.append(KCTestRectangleResponse(
|
||||
name=rect.name,
|
||||
x=rect.x,
|
||||
y=rect.y,
|
||||
width=rect.width,
|
||||
height=rect.height,
|
||||
color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"),
|
||||
))
|
||||
|
||||
# 5. Encode frame as base64 JPEG
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
return KCTestResponse(
|
||||
image=image_data_uri,
|
||||
rectangles=result_rects,
|
||||
interpolation_mode=settings.interpolation_mode,
|
||||
pattern_template_name=pattern_tmpl.name,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test KC target: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
if stream:
|
||||
try:
|
||||
stream.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up test stream: {e}")
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
|
||||
async def test_kc_target_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
token: str = Query(""),
|
||||
fps: int = Query(3),
|
||||
preview_width: int = Query(480),
|
||||
):
|
||||
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
|
||||
|
||||
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
|
||||
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
|
||||
"""
|
||||
import json as _json
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Load stores
|
||||
target_store_inst: OutputTargetStore = get_output_target_store()
|
||||
source_store_inst: PictureSourceStore = get_picture_source_store()
|
||||
get_template_store()
|
||||
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
|
||||
processor_manager_inst: ProcessorManager = get_processor_manager()
|
||||
device_store_inst: DeviceStore = get_device_store()
|
||||
pp_template_store_inst = get_pp_template_store()
|
||||
|
||||
# Validate target
|
||||
try:
|
||||
target = target_store_inst.get_target(target_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
if not isinstance(target, KeyColorsOutputTarget):
|
||||
await websocket.close(code=4003, reason="Target is not a key_colors target")
|
||||
return
|
||||
|
||||
settings = target.settings
|
||||
|
||||
if not settings.pattern_template_id:
|
||||
await websocket.close(code=4003, reason="No pattern template configured")
|
||||
return
|
||||
|
||||
try:
|
||||
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
|
||||
except ValueError:
|
||||
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
|
||||
return
|
||||
|
||||
rectangles = pattern_tmpl.rectangles
|
||||
if not rectangles:
|
||||
await websocket.close(code=4003, reason="Pattern template has no rectangles")
|
||||
return
|
||||
|
||||
if not target.picture_source_id:
|
||||
await websocket.close(code=4003, reason="No picture source configured")
|
||||
return
|
||||
|
||||
try:
|
||||
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
# For screen capture sources, check display lock
|
||||
if isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
display_index = raw_stream.display_index
|
||||
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
|
||||
if locked_device_id:
|
||||
try:
|
||||
device = device_store_inst.get_device(locked_device_id)
|
||||
device_name = device.name
|
||||
except Exception:
|
||||
device_name = locked_device_id
|
||||
await websocket.close(
|
||||
code=4003,
|
||||
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
|
||||
)
|
||||
return
|
||||
|
||||
fps = max(1, min(30, fps))
|
||||
preview_width = max(120, min(1920, preview_width))
|
||||
frame_interval = 1.0 / fps
|
||||
|
||||
calc_fns = {
|
||||
"average": calculate_average_color,
|
||||
"median": calculate_median_color,
|
||||
"dominant": calculate_dominant_color,
|
||||
}
|
||||
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
|
||||
|
||||
# Use the shared LiveStreamManager so we share the capture stream with
|
||||
# running LED targets instead of creating a competing DXGI duplicator.
|
||||
live_stream_mgr = processor_manager_inst._live_stream_manager
|
||||
live_stream = None
|
||||
|
||||
try:
|
||||
live_stream = await asyncio.to_thread(
|
||||
live_stream_mgr.acquire, target.picture_source_id
|
||||
)
|
||||
logger.info(f"KC test WS acquired shared live stream for {target.picture_source_id}")
|
||||
|
||||
prev_frame_ref = None
|
||||
|
||||
while True:
|
||||
loop_start = time.monotonic()
|
||||
|
||||
try:
|
||||
capture = await asyncio.to_thread(live_stream.get_latest_frame)
|
||||
|
||||
if capture is None or capture.image is None:
|
||||
await asyncio.sleep(frame_interval)
|
||||
continue
|
||||
|
||||
# Skip if same frame object (no new capture yet)
|
||||
if capture is prev_frame_ref:
|
||||
await asyncio.sleep(frame_interval * 0.5)
|
||||
continue
|
||||
prev_frame_ref = capture
|
||||
|
||||
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
|
||||
if pil_image is None:
|
||||
await asyncio.sleep(frame_interval)
|
||||
continue
|
||||
|
||||
# Apply postprocessing (if the source chain has PP templates)
|
||||
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
|
||||
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||
if pp_template_ids and pp_template_store_inst:
|
||||
img_array = np.array(pil_image)
|
||||
image_pool = ImagePool()
|
||||
for pp_id in pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_template_store_inst.get_template(pp_id)
|
||||
except ValueError:
|
||||
continue
|
||||
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
|
||||
for fi in flat_filters:
|
||||
try:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(img_array, image_pool)
|
||||
if result is not None:
|
||||
img_array = result
|
||||
except ValueError:
|
||||
pass
|
||||
pil_image = Image.fromarray(img_array)
|
||||
|
||||
# Extract colors
|
||||
img_array = np.array(pil_image)
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
result_rects = []
|
||||
for rect in rectangles:
|
||||
px_x = max(0, int(rect.x * w))
|
||||
px_y = max(0, int(rect.y * h))
|
||||
px_w = max(1, int(rect.width * w))
|
||||
px_h = max(1, int(rect.height * h))
|
||||
px_x = min(px_x, w - 1)
|
||||
px_y = min(px_y, h - 1)
|
||||
px_w = min(px_w, w - px_x)
|
||||
px_h = min(px_h, h - px_y)
|
||||
|
||||
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
|
||||
r, g, b = calc_fn(sub_img)
|
||||
|
||||
result_rects.append({
|
||||
"name": rect.name,
|
||||
"x": rect.x,
|
||||
"y": rect.y,
|
||||
"width": rect.width,
|
||||
"height": rect.height,
|
||||
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
|
||||
})
|
||||
|
||||
# Encode frame as JPEG
|
||||
if preview_width and pil_image.width > preview_width:
|
||||
ratio = preview_width / pil_image.width
|
||||
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
|
||||
else:
|
||||
thumb = pil_image
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=85)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
await websocket.send_text(_json.dumps({
|
||||
"type": "frame",
|
||||
"image": f"data:image/jpeg;base64,{b64}",
|
||||
"rectangles": result_rects,
|
||||
"pattern_template_name": pattern_tmpl.name,
|
||||
"interpolation_mode": settings.interpolation_mode,
|
||||
}))
|
||||
|
||||
except (WebSocketDisconnect, Exception) as inner_e:
|
||||
if isinstance(inner_e, WebSocketDisconnect):
|
||||
raise
|
||||
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
|
||||
|
||||
elapsed = time.monotonic() - loop_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"KC test WS disconnected for {target_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
|
||||
finally:
|
||||
if live_stream is not None:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
live_stream_mgr.release, target.picture_source_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"KC test WS closed for {target_id}")
|
||||
|
||||
|
||||
@router.websocket("/api/v1/output-targets/{target_id}/ws")
|
||||
async def target_colors_ws(
|
||||
websocket: WebSocket,
|
||||
target_id: str,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_processor_manager()
|
||||
|
||||
try:
|
||||
manager.add_kc_ws_client(target_id, websocket)
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Target not found")
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Keep alive — wait for client messages (or disconnect)
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.remove_kc_ws_client(target_id, websocket)
|
||||
@@ -19,6 +19,7 @@ from wled_controller.storage.key_colors_output_target import KeyColorRectangle
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -37,7 +38,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse:
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
tags=t.tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +77,9 @@ async def create_pattern_template(
|
||||
)
|
||||
fire_entity_event("pattern_template", "created", template.id)
|
||||
return _pat_template_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -121,6 +125,9 @@ async def update_pattern_template(
|
||||
)
|
||||
fire_entity_event("pattern_template", "updated", template_id)
|
||||
return _pat_template_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -149,6 +156,9 @@ async def delete_pattern_template(
|
||||
fire_entity_event("pattern_template", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Picture source routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
@@ -38,8 +39,9 @@ from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -61,7 +63,15 @@ def _stream_to_response(s) -> PictureSourceResponse:
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
description=s.description,
|
||||
tags=getattr(s, 'tags', []),
|
||||
tags=s.tags,
|
||||
# Video fields
|
||||
url=getattr(s, "url", None),
|
||||
loop=getattr(s, "loop", None),
|
||||
playback_speed=getattr(s, "playback_speed", None),
|
||||
start_time=getattr(s, "start_time", None),
|
||||
end_time=getattr(s, "end_time", None),
|
||||
resolution_limit=getattr(s, "resolution_limit", None),
|
||||
clock_id=getattr(s, "clock_id", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -97,23 +107,26 @@ async def validate_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
def _process_image(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
|
||||
# Create thumbnail preview (max 320px wide)
|
||||
thumb = pil_image.copy()
|
||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=80)
|
||||
buf.seek(0)
|
||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
return width, height, preview
|
||||
|
||||
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||
|
||||
return ImageValidateResponse(
|
||||
valid=True, width=width, height=height, preview=preview
|
||||
@@ -140,18 +153,22 @@ async def get_full_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
def _encode_full(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
buf.seek(0)
|
||||
return Response(content=buf.getvalue(), media_type="image/jpeg")
|
||||
return buf.getvalue()
|
||||
|
||||
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -199,11 +216,22 @@ async def create_picture_source(
|
||||
image_source=data.image_source,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
# Video fields
|
||||
url=data.url,
|
||||
loop=data.loop,
|
||||
playback_speed=data.playback_speed,
|
||||
start_time=data.start_time,
|
||||
end_time=data.end_time,
|
||||
resolution_limit=data.resolution_limit,
|
||||
clock_id=data.clock_id,
|
||||
)
|
||||
fire_entity_event("picture_source", "created", stream.id)
|
||||
return _stream_to_response(stream)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -245,9 +273,20 @@ async def update_picture_source(
|
||||
image_source=data.image_source,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
# Video fields
|
||||
url=data.url,
|
||||
loop=data.loop,
|
||||
playback_speed=data.playback_speed,
|
||||
start_time=data.start_time,
|
||||
end_time=data.end_time,
|
||||
resolution_limit=data.resolution_limit,
|
||||
clock_id=data.clock_id,
|
||||
)
|
||||
fire_entity_event("picture_source", "updated", stream_id)
|
||||
return _stream_to_response(stream)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -277,6 +316,9 @@ async def delete_picture_source(
|
||||
fire_entity_event("picture_source", "deleted", stream_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -284,6 +326,52 @@ async def delete_picture_source(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
|
||||
async def get_video_thumbnail(
|
||||
stream_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
):
|
||||
"""Get a thumbnail for a video picture source (first frame)."""
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.core.processing.video_stream import extract_thumbnail
|
||||
from wled_controller.storage.picture_source import VideoCaptureSource
|
||||
|
||||
try:
|
||||
source = store.get_stream(stream_id)
|
||||
if not isinstance(source, VideoCaptureSource):
|
||||
raise HTTPException(status_code=400, detail="Not a video source")
|
||||
|
||||
frame = await asyncio.get_event_loop().run_in_executor(
|
||||
None, extract_thumbnail, source.url, source.resolution_limit
|
||||
)
|
||||
if frame is None:
|
||||
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
|
||||
|
||||
# Encode as JPEG
|
||||
pil_img = Image.fromarray(frame)
|
||||
# Resize to max 320px wide for thumbnail
|
||||
if pil_img.width > 320:
|
||||
ratio = 320 / pil_img.width
|
||||
pil_img = pil_img.resize((320, int(pil_img.height * ratio)), Image.LANCZOS)
|
||||
|
||||
buf = BytesIO()
|
||||
pil_img.save(buf, format="JPEG", quality=80)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return {"thumbnail": f"data:image/jpeg;base64,{b64}", "width": pil_img.width, "height": pil_img.height}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract video thumbnail: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
|
||||
async def test_picture_source(
|
||||
stream_id: str,
|
||||
@@ -305,6 +393,9 @@ async def test_picture_source(
|
||||
# Resolve stream chain
|
||||
try:
|
||||
chain = store.resolve_stream_chain(stream_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -326,7 +417,7 @@ async def test_picture_source(
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -393,48 +484,50 @@ async def test_picture_source(
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
|
||||
# Create thumbnail
|
||||
thumbnail_width = 640
|
||||
aspect_ratio = pil_image.height / pil_image.width
|
||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Apply postprocessing filters if this is a processed stream
|
||||
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
flat_filters = None
|
||||
if pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||
if flat_filters:
|
||||
pool = ImagePool()
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
def _create_thumbnails_and_encode(pil_img, filters):
|
||||
thumbnail_w = 640
|
||||
aspect_ratio = pil_img.height / pil_img.width
|
||||
thumbnail_h = int(thumbnail_w * aspect_ratio)
|
||||
thumb = pil_img.copy()
|
||||
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
|
||||
|
||||
if filters:
|
||||
pool = ImagePool()
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in flat_filters:
|
||||
for fi in filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
thumb = apply_filters(thumb)
|
||||
pil_img = apply_filters(pil_img)
|
||||
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
thumb.save(img_buffer, format='JPEG', quality=85)
|
||||
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# Encode full-resolution image
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
pil_img.save(full_buffer, format='JPEG', quality=90)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
|
||||
|
||||
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
|
||||
_create_thumbnails_and_encode, pil_image, flat_filters
|
||||
)
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
@@ -461,6 +554,9 @@ async def test_picture_source(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
@@ -488,7 +584,7 @@ async def test_picture_source_ws(
|
||||
preview_width: int = Query(0),
|
||||
):
|
||||
"""WebSocket for picture source test with intermediate frame previews."""
|
||||
from wled_controller.api.routes._test_helpers import (
|
||||
from wled_controller.api.routes._preview_helpers import (
|
||||
authenticate_ws_token,
|
||||
stream_capture_test,
|
||||
)
|
||||
@@ -520,6 +616,86 @@ async def test_picture_source_ws(
|
||||
await websocket.close(code=4003, reason="Static image streams don't support live test")
|
||||
return
|
||||
|
||||
# Video sources: use VideoCaptureLiveStream for test preview
|
||||
if isinstance(raw_stream, VideoCaptureSource):
|
||||
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
|
||||
|
||||
video_stream = VideoCaptureLiveStream(
|
||||
url=raw_stream.url,
|
||||
loop=raw_stream.loop,
|
||||
playback_speed=raw_stream.playback_speed,
|
||||
start_time=raw_stream.start_time,
|
||||
end_time=raw_stream.end_time,
|
||||
resolution_limit=raw_stream.resolution_limit,
|
||||
target_fps=raw_stream.target_fps,
|
||||
)
|
||||
|
||||
def _encode_video_frame(image, pw):
|
||||
"""Encode numpy RGB image as JPEG base64 data URI."""
|
||||
from PIL import Image as PILImage
|
||||
pil = PILImage.fromarray(image)
|
||||
if pw and pil.width > pw:
|
||||
ratio = pw / pil.width
|
||||
pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
pil.save(buf, format="JPEG", quality=80)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
return f"data:image/jpeg;base64,{b64}", pil.width, pil.height
|
||||
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
|
||||
import time as _time
|
||||
fps = min(raw_stream.target_fps or 30, 30)
|
||||
frame_time = 1.0 / fps
|
||||
end_at = _time.monotonic() + duration
|
||||
frame_count = 0
|
||||
last_frame = None
|
||||
while _time.monotonic() < end_at:
|
||||
frame = video_stream.get_latest_frame()
|
||||
if frame is not None and frame.image is not None and frame is not last_frame:
|
||||
last_frame = frame
|
||||
frame_count += 1
|
||||
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
|
||||
None, _encode_video_frame, frame.image, preview_width or None,
|
||||
)
|
||||
elapsed = duration - (end_at - _time.monotonic())
|
||||
await websocket.send_json({
|
||||
"type": "frame",
|
||||
"thumbnail": thumb,
|
||||
"width": w, "height": h,
|
||||
"elapsed": round(elapsed, 1),
|
||||
"frame_count": frame_count,
|
||||
})
|
||||
await asyncio.sleep(frame_time)
|
||||
# Send final result
|
||||
if last_frame is not None:
|
||||
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
|
||||
None, _encode_video_frame, last_frame.image, None,
|
||||
)
|
||||
await websocket.send_json({
|
||||
"type": "result",
|
||||
"full_image": full_img,
|
||||
"width": fw, "height": fh,
|
||||
"total_frames": frame_count,
|
||||
"duration": duration,
|
||||
"avg_fps": round(frame_count / max(duration, 0.001), 1),
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Video source test WS error for {stream_id}: {e}")
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "detail": str(e)})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
video_stream.stop()
|
||||
logger.info(f"Video source test WS disconnected for {stream_id}")
|
||||
return
|
||||
|
||||
if not isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
await websocket.close(code=4003, reason="Unsupported stream type for live test")
|
||||
return
|
||||
|
||||
@@ -36,6 +36,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -51,7 +52,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
tags=t.tags,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,13 +62,9 @@ async def list_pp_templates(
|
||||
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
|
||||
):
|
||||
"""List all postprocessing templates."""
|
||||
try:
|
||||
templates = store.get_all_templates()
|
||||
responses = [_pp_template_to_response(t) for t in templates]
|
||||
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list postprocessing templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
|
||||
@@ -87,6 +84,9 @@ async def create_pp_template(
|
||||
)
|
||||
fire_entity_event("pp_template", "created", template.id)
|
||||
return _pp_template_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -127,6 +127,9 @@ async def update_pp_template(
|
||||
)
|
||||
fire_entity_event("pp_template", "updated", template_id)
|
||||
return _pp_template_to_response(template)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -156,6 +159,9 @@ async def delete_pp_template(
|
||||
fire_entity_event("pp_template", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -184,6 +190,9 @@ async def test_pp_template(
|
||||
# Resolve source stream chain to get the raw stream
|
||||
try:
|
||||
chain = stream_store.resolve_stream_chain(test_request.source_stream_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -327,6 +336,9 @@ async def test_pp_template(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -353,7 +365,7 @@ async def test_pp_template_ws(
|
||||
preview_width: int = Query(0),
|
||||
):
|
||||
"""WebSocket for PP template test with intermediate frame previews."""
|
||||
from wled_controller.api.routes._test_helpers import (
|
||||
from wled_controller.api.routes._preview_helpers import (
|
||||
authenticate_ws_token,
|
||||
stream_capture_test,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.storage.scene_preset import ScenePreset
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -46,7 +47,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
|
||||
"fps": t.fps,
|
||||
} for t in preset.targets],
|
||||
order=preset.order,
|
||||
tags=getattr(preset, 'tags', []),
|
||||
tags=preset.tags,
|
||||
created_at=preset.created_at,
|
||||
updated_at=preset.updated_at,
|
||||
)
|
||||
@@ -85,6 +86,9 @@ async def create_scene_preset(
|
||||
|
||||
try:
|
||||
preset = store.create_preset(preset)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -34,7 +35,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon
|
||||
name=clock.name,
|
||||
speed=rt.speed if rt else clock.speed,
|
||||
description=clock.description,
|
||||
tags=getattr(clock, 'tags', []),
|
||||
tags=clock.tags,
|
||||
is_running=rt.is_running if rt else True,
|
||||
elapsed_time=rt.get_time() if rt else 0.0,
|
||||
created_at=clock.created_at,
|
||||
@@ -73,6 +74,9 @@ async def create_sync_clock(
|
||||
)
|
||||
fire_entity_event("sync_clock", "created", clock.id)
|
||||
return _to_response(clock, manager)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -114,6 +118,9 @@ async def update_sync_clock(
|
||||
manager.update_speed(clock_id, clock.speed)
|
||||
fire_entity_event("sync_clock", "updated", clock_id)
|
||||
return _to_response(clock, manager)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -137,6 +144,9 @@ async def delete_sync_clock(
|
||||
manager.release_all_for(clock_id)
|
||||
store.delete_clock(clock_id)
|
||||
fire_entity_event("sync_clock", "deleted", clock_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"""System routes: health, version, displays, performance, backup/restore, ADB."""
|
||||
"""System routes: health, version, displays, performance, tags, api-keys.
|
||||
|
||||
Backup/restore and settings routes are in backup.py and system_settings.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
get_auto_backup_engine,
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
get_automation_store,
|
||||
@@ -36,42 +32,30 @@ from wled_controller.api.dependencies import (
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.api.schemas.system import (
|
||||
AutoBackupSettings,
|
||||
AutoBackupStatusResponse,
|
||||
BackupFileInfo,
|
||||
BackupListResponse,
|
||||
DisplayInfo,
|
||||
DisplayListResponse,
|
||||
GpuInfo,
|
||||
HealthResponse,
|
||||
PerformanceResponse,
|
||||
ProcessListResponse,
|
||||
RestoreResponse,
|
||||
VersionResponse,
|
||||
)
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.config import get_config, is_demo_mode
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
# Re-export STORE_MAP and load_external_url so existing callers still work
|
||||
from wled_controller.api.routes.backup import STORE_MAP # noqa: F401
|
||||
from wled_controller.api.routes.system_settings import load_external_url # noqa: F401
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Prime psutil CPU counter (first call always returns 0.0)
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
# Try to initialize NVIDIA GPU monitoring
|
||||
_nvml_available = False
|
||||
try:
|
||||
import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated)
|
||||
|
||||
_pynvml_mod.nvmlInit()
|
||||
_nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
|
||||
_nvml_available = True
|
||||
_nvml = _pynvml_mod
|
||||
logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}")
|
||||
except Exception:
|
||||
_nvml = None
|
||||
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
|
||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle # noqa: E402
|
||||
|
||||
|
||||
def _get_cpu_name() -> str | None:
|
||||
@@ -100,8 +84,8 @@ def _get_cpu_name() -> str | None:
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("CPU name detection failed: %s", e)
|
||||
return platform.processor() or None
|
||||
|
||||
|
||||
@@ -116,12 +100,13 @@ async def health_check():
|
||||
|
||||
Returns basic health information including status, version, and timestamp.
|
||||
"""
|
||||
logger.info("Health check requested")
|
||||
logger.debug("Health check requested")
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
version=__version__,
|
||||
demo_mode=get_config().demo,
|
||||
)
|
||||
|
||||
|
||||
@@ -131,12 +116,13 @@ async def get_version():
|
||||
|
||||
Returns application version, Python version, and API version.
|
||||
"""
|
||||
logger.info("Version info requested")
|
||||
logger.debug("Version info requested")
|
||||
|
||||
return VersionResponse(
|
||||
version=__version__,
|
||||
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
api_version="v1",
|
||||
demo_mode=get_config().demo,
|
||||
)
|
||||
|
||||
|
||||
@@ -156,20 +142,12 @@ async def list_all_tags(_: AuthRequired):
|
||||
store = getter()
|
||||
except RuntimeError:
|
||||
continue
|
||||
# Each store has a different "get all" method name
|
||||
items = None
|
||||
for method_name in (
|
||||
"get_all_devices", "get_all_targets", "get_all_sources",
|
||||
"get_all_streams", "get_all_clocks", "get_all_automations",
|
||||
"get_all_presets", "get_all_templates",
|
||||
):
|
||||
fn = getattr(store, method_name, None)
|
||||
if fn is not None:
|
||||
items = fn()
|
||||
break
|
||||
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
|
||||
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
|
||||
items = fn() if fn else None
|
||||
if items:
|
||||
for item in items:
|
||||
all_tags.update(getattr(item, 'tags', []))
|
||||
all_tags.update(item.tags)
|
||||
return {"tags": sorted(all_tags)}
|
||||
|
||||
|
||||
@@ -187,13 +165,22 @@ async def get_displays(
|
||||
logger.info(f"Listing available displays (engine_type={engine_type})")
|
||||
|
||||
try:
|
||||
if engine_type:
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
|
||||
if engine_type:
|
||||
engine_cls = EngineRegistry.get_engine(engine_type)
|
||||
display_dataclasses = engine_cls.get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||
elif is_demo_mode():
|
||||
# In demo mode, use the best available engine (demo engine at priority 1000)
|
||||
# instead of the mss-based real display detection
|
||||
best = EngineRegistry.get_best_available_engine()
|
||||
if best:
|
||||
engine_cls = EngineRegistry.get_engine(best)
|
||||
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||
else:
|
||||
display_dataclasses = get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
else:
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
|
||||
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
||||
displays = [
|
||||
@@ -217,6 +204,10 @@ async def get_displays(
|
||||
count=len(displays),
|
||||
)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -277,8 +268,8 @@ def get_system_performance(_: AuthRequired):
|
||||
memory_total_mb=round(mem_info.total / 1024 / 1024, 1),
|
||||
temperature_c=float(temp),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("NVML query failed: %s", e)
|
||||
|
||||
return PerformanceResponse(
|
||||
cpu_name=_cpu_name,
|
||||
@@ -304,304 +295,12 @@ async def get_metrics_history(
|
||||
return manager.metrics_history.get_history()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration backup / restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping: logical store name → StorageConfig attribute name
|
||||
STORE_MAP = {
|
||||
"devices": "devices_file",
|
||||
"capture_templates": "templates_file",
|
||||
"postprocessing_templates": "postprocessing_templates_file",
|
||||
"picture_sources": "picture_sources_file",
|
||||
"output_targets": "output_targets_file",
|
||||
"pattern_templates": "pattern_templates_file",
|
||||
"color_strip_sources": "color_strip_sources_file",
|
||||
"audio_sources": "audio_sources_file",
|
||||
"audio_templates": "audio_templates_file",
|
||||
"value_sources": "value_sources_file",
|
||||
"sync_clocks": "sync_clocks_file",
|
||||
"automations": "automations_file",
|
||||
"scene_presets": "scene_presets_file",
|
||||
}
|
||||
|
||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _schedule_restart() -> None:
|
||||
"""Spawn a restart script after a short delay so the HTTP response completes."""
|
||||
|
||||
def _restart():
|
||||
import time
|
||||
time.sleep(1)
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-File",
|
||||
str(_SERVER_DIR / "restart.ps1")],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen(
|
||||
["bash", str(_SERVER_DIR / "restart.sh")],
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
threading.Thread(target=_restart, daemon=True).start()
|
||||
|
||||
|
||||
@router.get("/api/v1/system/backup", tags=["System"])
|
||||
def backup_config(_: AuthRequired):
|
||||
"""Download all configuration as a single JSON backup file."""
|
||||
@router.get("/api/v1/system/api-keys", tags=["System"])
|
||||
def list_api_keys(_: AuthRequired):
|
||||
"""List API key labels (read-only; keys are defined in the YAML config file)."""
|
||||
config = get_config()
|
||||
stores = {}
|
||||
for store_key, config_attr in STORE_MAP.items():
|
||||
file_path = Path(getattr(config.storage, config_attr))
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
stores[store_key] = json.load(f)
|
||||
else:
|
||||
stores[store_key] = {}
|
||||
|
||||
backup = {
|
||||
"meta": {
|
||||
"format": "ledgrab-backup",
|
||||
"format_version": 1,
|
||||
"app_version": __version__,
|
||||
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
"store_count": len(stores),
|
||||
},
|
||||
"stores": stores,
|
||||
}
|
||||
|
||||
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-backup-{timestamp}.json"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content.encode("utf-8")),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
||||
async def restore_config(
|
||||
_: AuthRequired,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""Upload a backup file to restore all configuration. Triggers server restart."""
|
||||
# Read and parse
|
||||
try:
|
||||
raw = await file.read()
|
||||
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
|
||||
backup = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
|
||||
|
||||
# Validate envelope
|
||||
meta = backup.get("meta")
|
||||
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
|
||||
|
||||
fmt_version = meta.get("format_version", 0)
|
||||
if fmt_version > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Backup format version {fmt_version} is not supported by this server version",
|
||||
)
|
||||
|
||||
stores = backup.get("stores")
|
||||
if not isinstance(stores, dict):
|
||||
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
|
||||
|
||||
known_keys = set(STORE_MAP.keys())
|
||||
present_keys = known_keys & set(stores.keys())
|
||||
if not present_keys:
|
||||
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
|
||||
|
||||
for key in present_keys:
|
||||
if not isinstance(stores[key], dict):
|
||||
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
|
||||
|
||||
# Write store files atomically (in thread to avoid blocking event loop)
|
||||
config = get_config()
|
||||
|
||||
def _write_stores():
|
||||
count = 0
|
||||
for store_key, config_attr in STORE_MAP.items():
|
||||
if store_key in stores:
|
||||
file_path = Path(getattr(config.storage, config_attr))
|
||||
atomic_write_json(file_path, stores[store_key])
|
||||
count += 1
|
||||
logger.info(f"Restored store: {store_key} -> {file_path}")
|
||||
return count
|
||||
|
||||
written = await asyncio.to_thread(_write_stores)
|
||||
|
||||
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
|
||||
_schedule_restart()
|
||||
|
||||
missing = known_keys - present_keys
|
||||
return RestoreResponse(
|
||||
status="restored",
|
||||
stores_written=written,
|
||||
stores_total=len(STORE_MAP),
|
||||
missing_stores=sorted(missing) if missing else [],
|
||||
restart_scheduled=True,
|
||||
message=f"Restored {written} stores. Server restarting...",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-backup settings & saved backups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/auto-backup/settings",
|
||||
response_model=AutoBackupStatusResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_auto_backup_settings(
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Get auto-backup settings and status."""
|
||||
return engine.get_settings()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/system/auto-backup/settings",
|
||||
response_model=AutoBackupStatusResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_auto_backup_settings(
|
||||
_: AuthRequired,
|
||||
body: AutoBackupSettings,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Update auto-backup settings (enable/disable, interval, max backups)."""
|
||||
return await engine.update_settings(
|
||||
enabled=body.enabled,
|
||||
interval_hours=body.interval_hours,
|
||||
max_backups=body.max_backups,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/backups",
|
||||
response_model=BackupListResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def list_backups(
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""List all saved backup files."""
|
||||
backups = engine.list_backups()
|
||||
return BackupListResponse(
|
||||
backups=[BackupFileInfo(**b) for b in backups],
|
||||
count=len(backups),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/system/backups/{filename}", tags=["System"])
|
||||
def download_saved_backup(
|
||||
filename: str,
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Download a specific saved backup file."""
|
||||
try:
|
||||
path = engine.get_backup_path(filename)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
content = path.read_bytes()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/v1/system/backups/{filename}", tags=["System"])
|
||||
async def delete_saved_backup(
|
||||
filename: str,
|
||||
_: AuthRequired,
|
||||
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
|
||||
):
|
||||
"""Delete a specific saved backup file."""
|
||||
try:
|
||||
engine.delete_backup(filename)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return {"status": "deleted", "filename": filename}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADB helpers (for Android / scrcpy engine)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AdbConnectRequest(BaseModel):
|
||||
address: str
|
||||
|
||||
|
||||
def _get_adb_path() -> str:
|
||||
"""Get the adb binary path from the scrcpy engine's resolver."""
|
||||
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
|
||||
return _get_adb()
|
||||
|
||||
|
||||
@router.post("/api/v1/adb/connect", tags=["ADB"])
|
||||
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
"""Connect to a WiFi ADB device by IP address.
|
||||
|
||||
Appends ``:5555`` if no port is specified.
|
||||
"""
|
||||
address = request.address.strip()
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
if ":" not in address:
|
||||
address = f"{address}:5555"
|
||||
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Connecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "connect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
if "connected" in output.lower():
|
||||
return {"status": "connected", "address": address, "message": output}
|
||||
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=504, detail="ADB connect timed out")
|
||||
|
||||
|
||||
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
|
||||
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||
"""Disconnect a WiFi ADB device."""
|
||||
address = request.address.strip()
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Disconnecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "disconnect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return {"status": "disconnected", "message": result.stdout.strip()}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
|
||||
keys = [
|
||||
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
|
||||
for label, key in config.auth.api_keys.items()
|
||||
]
|
||||
return {"keys": keys, "count": len(keys)}
|
||||
|
||||
377
server/src/wled_controller/api/routes/system_settings.py
Normal file
377
server/src/wled_controller/api/routes/system_settings.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""System routes: MQTT, external URL, ADB, logs WebSocket, log level.
|
||||
|
||||
Extracted from system.py to keep files under 800 lines.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.schemas.system import (
|
||||
ExternalUrlRequest,
|
||||
ExternalUrlResponse,
|
||||
LogLevelRequest,
|
||||
LogLevelResponse,
|
||||
MQTTSettingsRequest,
|
||||
MQTTSettingsResponse,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MQTT settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MQTT_SETTINGS_FILE: Path | None = None
|
||||
|
||||
|
||||
def _get_mqtt_settings_path() -> Path:
|
||||
global _MQTT_SETTINGS_FILE
|
||||
if _MQTT_SETTINGS_FILE is None:
|
||||
cfg = get_config()
|
||||
# Derive the data directory from any known storage file path
|
||||
data_dir = Path(cfg.storage.devices_file).parent
|
||||
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
|
||||
return _MQTT_SETTINGS_FILE
|
||||
|
||||
|
||||
def _load_mqtt_settings() -> dict:
|
||||
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
|
||||
cfg = get_config()
|
||||
defaults = {
|
||||
"enabled": cfg.mqtt.enabled,
|
||||
"broker_host": cfg.mqtt.broker_host,
|
||||
"broker_port": cfg.mqtt.broker_port,
|
||||
"username": cfg.mqtt.username,
|
||||
"password": cfg.mqtt.password,
|
||||
"client_id": cfg.mqtt.client_id,
|
||||
"base_topic": cfg.mqtt.base_topic,
|
||||
}
|
||||
path = _get_mqtt_settings_path()
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
overrides = json.load(f)
|
||||
defaults.update(overrides)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load MQTT settings override file: {e}")
|
||||
return defaults
|
||||
|
||||
|
||||
def _save_mqtt_settings(settings: dict) -> None:
|
||||
"""Persist MQTT settings to the JSON override file."""
|
||||
from wled_controller.utils import atomic_write_json
|
||||
atomic_write_json(_get_mqtt_settings_path(), settings)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/mqtt/settings",
|
||||
response_model=MQTTSettingsResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_mqtt_settings(_: AuthRequired):
|
||||
"""Get current MQTT broker settings. Password is masked."""
|
||||
s = _load_mqtt_settings()
|
||||
return MQTTSettingsResponse(
|
||||
enabled=s["enabled"],
|
||||
broker_host=s["broker_host"],
|
||||
broker_port=s["broker_port"],
|
||||
username=s["username"],
|
||||
password_set=bool(s.get("password")),
|
||||
client_id=s["client_id"],
|
||||
base_topic=s["base_topic"],
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/system/mqtt/settings",
|
||||
response_model=MQTTSettingsResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
|
||||
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
|
||||
current = _load_mqtt_settings()
|
||||
|
||||
# If caller sends an empty password, keep the existing one
|
||||
password = body.password if body.password else current.get("password", "")
|
||||
|
||||
new_settings = {
|
||||
"enabled": body.enabled,
|
||||
"broker_host": body.broker_host,
|
||||
"broker_port": body.broker_port,
|
||||
"username": body.username,
|
||||
"password": password,
|
||||
"client_id": body.client_id,
|
||||
"base_topic": body.base_topic,
|
||||
}
|
||||
_save_mqtt_settings(new_settings)
|
||||
logger.info("MQTT settings updated")
|
||||
|
||||
return MQTTSettingsResponse(
|
||||
enabled=new_settings["enabled"],
|
||||
broker_host=new_settings["broker_host"],
|
||||
broker_port=new_settings["broker_port"],
|
||||
username=new_settings["username"],
|
||||
password_set=bool(new_settings["password"]),
|
||||
client_id=new_settings["client_id"],
|
||||
base_topic=new_settings["base_topic"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# External URL setting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXTERNAL_URL_FILE: Path | None = None
|
||||
|
||||
|
||||
def _get_external_url_path() -> Path:
|
||||
global _EXTERNAL_URL_FILE
|
||||
if _EXTERNAL_URL_FILE is None:
|
||||
cfg = get_config()
|
||||
data_dir = Path(cfg.storage.devices_file).parent
|
||||
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
|
||||
return _EXTERNAL_URL_FILE
|
||||
|
||||
|
||||
def load_external_url() -> str:
|
||||
"""Load the external URL setting. Returns empty string if not set."""
|
||||
path = _get_external_url_path()
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("external_url", "")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _save_external_url(url: str) -> None:
|
||||
from wled_controller.utils import atomic_write_json
|
||||
atomic_write_json(_get_external_url_path(), {"external_url": url})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/external-url",
|
||||
response_model=ExternalUrlResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_external_url(_: AuthRequired):
|
||||
"""Get the configured external base URL."""
|
||||
return ExternalUrlResponse(external_url=load_external_url())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/system/external-url",
|
||||
response_model=ExternalUrlResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
|
||||
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
|
||||
url = body.external_url.strip().rstrip("/")
|
||||
_save_external_url(url)
|
||||
logger.info("External URL updated: %s", url or "(cleared)")
|
||||
return ExternalUrlResponse(external_url=url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live log viewer WebSocket
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.websocket("/api/v1/system/logs/ws")
|
||||
async def logs_ws(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket that streams server log lines in real time.
|
||||
|
||||
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
|
||||
lines as individual text messages, then pushes new lines as they appear.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
from wled_controller.utils import log_broadcaster
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# Ensure the broadcaster knows the event loop (may be first connection)
|
||||
log_broadcaster.ensure_loop()
|
||||
|
||||
# Subscribe *before* reading the backlog so no lines slip through
|
||||
queue = log_broadcaster.subscribe()
|
||||
|
||||
try:
|
||||
# Send backlog first
|
||||
for line in log_broadcaster.get_backlog():
|
||||
await websocket.send_text(line)
|
||||
|
||||
# Stream new lines
|
||||
while True:
|
||||
try:
|
||||
line = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
await websocket.send_text(line)
|
||||
except asyncio.TimeoutError:
|
||||
# Send a keepalive ping so the connection stays alive
|
||||
try:
|
||||
await websocket.send_text("")
|
||||
except Exception:
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
log_broadcaster.unsubscribe(queue)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADB helpers (for Android / scrcpy engine)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Regex: IPv4 address with optional port, e.g. "192.168.1.5" or "192.168.1.5:5555"
|
||||
_ADB_ADDRESS_RE = re.compile(
|
||||
r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$"
|
||||
)
|
||||
|
||||
|
||||
class AdbConnectRequest(BaseModel):
|
||||
address: str
|
||||
|
||||
|
||||
def _validate_adb_address(address: str) -> None:
|
||||
"""Raise 400 if *address* is not a valid IP:port for ADB."""
|
||||
if not _ADB_ADDRESS_RE.match(address):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Invalid ADB address '{address}'. "
|
||||
"Expected format: <IP> or <IP>:<port>, e.g. 192.168.1.5 or 192.168.1.5:5555"
|
||||
),
|
||||
)
|
||||
# Validate each octet is 0-255 and port is 1-65535
|
||||
parts = address.split(":")
|
||||
ip_parts = parts[0].split(".")
|
||||
for octet in ip_parts:
|
||||
if not (0 <= int(octet) <= 255):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid IP octet '{octet}' in address '{address}'. Each octet must be 0-255.",
|
||||
)
|
||||
if len(parts) == 2:
|
||||
port = int(parts[1])
|
||||
if not (1 <= port <= 65535):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid port '{parts[1]}' in address '{address}'. Port must be 1-65535.",
|
||||
)
|
||||
|
||||
|
||||
def _get_adb_path() -> str:
|
||||
"""Get the adb binary path from the scrcpy engine's resolver."""
|
||||
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
|
||||
return _get_adb()
|
||||
|
||||
|
||||
@router.post("/api/v1/adb/connect", tags=["ADB"])
|
||||
async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
"""Connect to a WiFi ADB device by IP address.
|
||||
|
||||
Appends ``:5555`` if no port is specified.
|
||||
"""
|
||||
address = request.address.strip()
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
_validate_adb_address(address)
|
||||
if ":" not in address:
|
||||
address = f"{address}:5555"
|
||||
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Connecting ADB device: {address}")
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "connect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
output = (stdout.decode() + stderr.decode()).strip()
|
||||
if "connected" in output.lower():
|
||||
return {"status": "connected", "address": address, "message": output}
|
||||
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB connect timed out")
|
||||
|
||||
|
||||
@router.post("/api/v1/adb/disconnect", tags=["ADB"])
|
||||
async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||
"""Disconnect a WiFi ADB device."""
|
||||
address = request.address.strip()
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Disconnecting ADB device: {address}")
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "disconnect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
return {"status": "disconnected", "message": stdout.decode().strip()}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
|
||||
|
||||
|
||||
# --- Log level -----
|
||||
|
||||
_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
|
||||
|
||||
@router.get("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
|
||||
async def get_log_level(_: AuthRequired):
|
||||
"""Get the current root logger log level."""
|
||||
level_int = logging.getLogger().getEffectiveLevel()
|
||||
return LogLevelResponse(level=logging.getLevelName(level_int))
|
||||
|
||||
|
||||
@router.put("/api/v1/system/log-level", response_model=LogLevelResponse, tags=["System"])
|
||||
async def set_log_level(_: AuthRequired, body: LogLevelRequest):
|
||||
"""Change the root logger log level at runtime (no server restart required)."""
|
||||
level_name = body.level.upper()
|
||||
if level_name not in _VALID_LOG_LEVELS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid log level '{body.level}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}",
|
||||
)
|
||||
level_int = getattr(logging, level_name)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level_int)
|
||||
# Also update all handlers so they actually emit at the new level
|
||||
for handler in root.handlers:
|
||||
handler.setLevel(level_int)
|
||||
logger.info("Log level changed to %s", level_name)
|
||||
return LogLevelResponse(level=level_name)
|
||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_cspt_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_template_store,
|
||||
@@ -40,6 +41,7 @@ from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -63,7 +65,7 @@ async def list_templates(
|
||||
name=t.name,
|
||||
engine_type=t.engine_type,
|
||||
engine_config=t.engine_config,
|
||||
tags=getattr(t, 'tags', []),
|
||||
tags=t.tags,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
@@ -103,12 +105,16 @@ async def create_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=getattr(template, 'tags', []),
|
||||
tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -133,7 +139,7 @@ async def get_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=getattr(template, 'tags', []),
|
||||
tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
@@ -164,12 +170,16 @@ async def update_template(
|
||||
name=template.name,
|
||||
engine_type=template.engine_type,
|
||||
engine_config=template.engine_config,
|
||||
tags=getattr(template, 'tags', []),
|
||||
tags=template.tags,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
description=template.description,
|
||||
)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -209,6 +219,9 @@ async def delete_template(
|
||||
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions as-is
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -358,6 +371,10 @@ def test_template(
|
||||
),
|
||||
)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
@@ -386,7 +403,7 @@ async def test_template_ws(
|
||||
Config is sent as the first client message (JSON with engine_type,
|
||||
engine_config, display_index, capture_duration).
|
||||
"""
|
||||
from wled_controller.api.routes._test_helpers import (
|
||||
from wled_controller.api.routes._preview_helpers import (
|
||||
authenticate_ws_token,
|
||||
stream_capture_test,
|
||||
)
|
||||
@@ -479,3 +496,48 @@ async def list_filter_types(
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
|
||||
@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||
async def list_strip_filter_types(
|
||||
_auth: AuthRequired,
|
||||
cspt_store=Depends(get_cspt_store),
|
||||
):
|
||||
"""List filter types that support 1D LED strip processing."""
|
||||
all_filters = FilterRegistry.get_all()
|
||||
|
||||
# Pre-build template choices for the css_filter_template filter
|
||||
cspt_choices = None
|
||||
if cspt_store:
|
||||
try:
|
||||
templates = cspt_store.get_all_templates()
|
||||
cspt_choices = [{"value": t.id, "label": t.name} for t in templates]
|
||||
except Exception:
|
||||
cspt_choices = []
|
||||
|
||||
responses = []
|
||||
for filter_id, filter_cls in all_filters.items():
|
||||
if not getattr(filter_cls, "supports_strip", True):
|
||||
continue
|
||||
schema = filter_cls.get_options_schema()
|
||||
opt_schemas = []
|
||||
for opt in schema:
|
||||
choices = opt.choices
|
||||
if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None:
|
||||
choices = cspt_choices
|
||||
opt_schemas.append(FilterOptionDefSchema(
|
||||
key=opt.key,
|
||||
label=opt.label,
|
||||
type=opt.option_type,
|
||||
default=opt.default,
|
||||
min_value=opt.min_value,
|
||||
max_value=opt.max_value,
|
||||
step=opt.step,
|
||||
choices=choices,
|
||||
))
|
||||
responses.append(FilterTypeResponse(
|
||||
filter_id=filter_cls.filter_id,
|
||||
filter_name=filter_cls.filter_name,
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
@@ -13,7 +12,6 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.api.schemas.value_sources import (
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
@@ -25,6 +23,7 @@ from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.output_target_store import OutputTargetStore
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -51,6 +50,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
schedule=d.get("schedule"),
|
||||
picture_source_id=d.get("picture_source_id"),
|
||||
scene_behavior=d.get("scene_behavior"),
|
||||
use_real_time=d.get("use_real_time"),
|
||||
latitude=d.get("latitude"),
|
||||
description=d.get("description"),
|
||||
tags=d.get("tags", []),
|
||||
created_at=source.created_at,
|
||||
@@ -99,10 +100,15 @@ async def create_value_source(
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("value_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -148,12 +154,17 @@ async def update_value_source(
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
use_real_time=data.use_real_time,
|
||||
latitude=data.latitude,
|
||||
tags=data.tags,
|
||||
)
|
||||
# Hot-reload running value streams
|
||||
pm.update_value_source(source_id)
|
||||
fire_entity_event("value_source", "updated", source_id)
|
||||
return _to_response(source)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -178,6 +189,9 @@ async def delete_value_source(
|
||||
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("value_source", "deleted", source_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -196,16 +210,8 @@ async def test_value_source_ws(
|
||||
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
|
||||
and streams {value: float} JSON to the client.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ automations that have a webhook condition. No API-key auth is required —
|
||||
the secret token itself authenticates the caller.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
|
||||
@@ -18,6 +21,28 @@ from wled_controller.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory rate limiter: 30 requests per 60-second window per IP
|
||||
# ---------------------------------------------------------------------------
|
||||
_RATE_LIMIT = 30
|
||||
_RATE_WINDOW = 60.0 # seconds
|
||||
_rate_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def _check_rate_limit(client_ip: str) -> None:
|
||||
"""Raise 429 if *client_ip* exceeded the webhook rate limit."""
|
||||
now = time.time()
|
||||
window_start = now - _RATE_WINDOW
|
||||
# Prune timestamps outside the window
|
||||
timestamps = _rate_hits[client_ip]
|
||||
_rate_hits[client_ip] = [t for t in timestamps if t > window_start]
|
||||
if len(_rate_hits[client_ip]) >= _RATE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Rate limit exceeded. Max 30 webhook requests per minute.",
|
||||
)
|
||||
_rate_hits[client_ip].append(now)
|
||||
|
||||
|
||||
class WebhookPayload(BaseModel):
|
||||
action: str = Field(description="'activate' or 'deactivate'")
|
||||
@@ -30,10 +55,13 @@ class WebhookPayload(BaseModel):
|
||||
async def handle_webhook(
|
||||
token: str,
|
||||
body: WebhookPayload,
|
||||
request: Request,
|
||||
store: AutomationStore = Depends(get_automation_store),
|
||||
engine: AutomationEngine = Depends(get_automation_engine),
|
||||
):
|
||||
"""Receive a webhook call and set the corresponding condition state."""
|
||||
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||
|
||||
if body.action not in ("activate", "deactivate"):
|
||||
raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'")
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Color strip processing template schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .filters import FilterInstanceSchema
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateCreate(BaseModel):
|
||||
"""Request to create a color strip processing template."""
|
||||
|
||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateUpdate(BaseModel):
|
||||
"""Request to update a color strip processing template."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateResponse(BaseModel):
|
||||
"""Color strip processing template information response."""
|
||||
|
||||
id: str = Field(description="Template ID")
|
||||
name: str = Field(description="Template name")
|
||||
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
|
||||
class ColorStripProcessingTemplateListResponse(BaseModel):
|
||||
"""List of color strip processing templates response."""
|
||||
|
||||
templates: List[ColorStripProcessingTemplateResponse] = Field(description="List of templates")
|
||||
count: int = Field(description="Number of templates")
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from wled_controller.api.schemas.devices import Calibration
|
||||
|
||||
@@ -31,9 +31,11 @@ class CompositeLayer(BaseModel):
|
||||
"""A single layer in a composite color strip source."""
|
||||
|
||||
source_id: str = Field(description="ID of the layer's color strip source")
|
||||
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen")
|
||||
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen|override")
|
||||
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
|
||||
enabled: bool = Field(default=True, description="Whether this layer is active")
|
||||
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
|
||||
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
|
||||
|
||||
|
||||
class MappedZone(BaseModel):
|
||||
@@ -49,12 +51,9 @@ class ColorStripSourceCreate(BaseModel):
|
||||
"""Request to create a color strip source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight"] = Field(default="picture", description="Source type")
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0)
|
||||
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
|
||||
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
|
||||
@@ -82,7 +81,6 @@ class ColorStripSourceCreate(BaseModel):
|
||||
# shared
|
||||
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
|
||||
@@ -101,6 +99,9 @@ class ColorStripSourceCreate(BaseModel):
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
@@ -112,9 +113,6 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
# picture-type fields
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0)
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
@@ -142,7 +140,6 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
# shared
|
||||
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||
@@ -161,6 +158,9 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -174,9 +174,6 @@ class ColorStripSourceResponse(BaseModel):
|
||||
source_type: str = Field(description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier")
|
||||
saturation: Optional[float] = Field(None, description="Saturation")
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction")
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
|
||||
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||
@@ -204,7 +201,6 @@ class ColorStripSourceResponse(BaseModel):
|
||||
# shared
|
||||
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
||||
animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)")
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||
@@ -223,6 +219,9 @@ class ColorStripSourceResponse(BaseModel):
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
@@ -238,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
|
||||
count: int = Field(description="Number of sources")
|
||||
|
||||
|
||||
class ColorPushRequest(BaseModel):
|
||||
"""Request to push raw LED colors to an api_input source."""
|
||||
class SegmentPayload(BaseModel):
|
||||
"""A single segment for segment-based LED color updates."""
|
||||
|
||||
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)")
|
||||
start: int = Field(ge=0, description="Starting LED index")
|
||||
length: int = Field(ge=1, description="Number of LEDs in segment")
|
||||
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
|
||||
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
|
||||
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_mode_fields(self) -> "SegmentPayload":
|
||||
if self.mode == "solid":
|
||||
if self.color is None or len(self.color) != 3:
|
||||
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
|
||||
if not all(0 <= c <= 255 for c in self.color):
|
||||
raise ValueError("solid color values must be 0-255")
|
||||
elif self.mode == "per_pixel":
|
||||
if not self.colors:
|
||||
raise ValueError("per_pixel mode requires non-empty 'colors' list")
|
||||
for c in self.colors:
|
||||
if len(c) != 3:
|
||||
raise ValueError("each color in per_pixel must be [R,G,B]")
|
||||
elif self.mode == "gradient":
|
||||
if not self.colors or len(self.colors) < 2:
|
||||
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
|
||||
for c in self.colors:
|
||||
if len(c) != 3:
|
||||
raise ValueError("each color stop in gradient must be [R,G,B]")
|
||||
return self
|
||||
|
||||
|
||||
class ColorPushRequest(BaseModel):
|
||||
"""Request to push raw LED colors to an api_input source.
|
||||
|
||||
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
|
||||
At least one must be provided.
|
||||
"""
|
||||
|
||||
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
|
||||
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_colors_or_segments(self) -> "ColorPushRequest":
|
||||
if self.colors is None and self.segments is None:
|
||||
raise ValueError("Either 'colors' or 'segments' must be provided")
|
||||
return self
|
||||
|
||||
|
||||
class NotifyRequest(BaseModel):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shared schemas used across multiple route modules."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ class DeviceCreate(BaseModel):
|
||||
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
# DMX (Art-Net / sACN) fields
|
||||
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
|
||||
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
|
||||
# ESP-NOW fields
|
||||
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)")
|
||||
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
|
||||
# Philips Hue fields
|
||||
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
|
||||
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
|
||||
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group/zone ID")
|
||||
# SPI Direct fields
|
||||
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed in Hz")
|
||||
spi_led_type: Optional[str] = Field(None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW")
|
||||
# Razer Chroma fields
|
||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad")
|
||||
# SteelSeries GameSense fields
|
||||
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator")
|
||||
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
@@ -34,6 +53,19 @@ class DeviceUpdate(BaseModel):
|
||||
rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)")
|
||||
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||
tags: Optional[List[str]] = None
|
||||
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
|
||||
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
|
||||
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
|
||||
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
|
||||
hue_username: Optional[str] = Field(None, description="Hue bridge username")
|
||||
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
|
||||
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group ID")
|
||||
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
|
||||
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
||||
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
|
||||
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
@@ -112,6 +144,18 @@ class CalibrationTestModeResponse(BaseModel):
|
||||
device_id: str = Field(description="Device ID")
|
||||
|
||||
|
||||
class BrightnessRequest(BaseModel):
|
||||
"""Request to set device brightness."""
|
||||
|
||||
brightness: int = Field(ge=0, le=255, description="Brightness level (0-255)")
|
||||
|
||||
|
||||
class PowerRequest(BaseModel):
|
||||
"""Request to set device power state."""
|
||||
|
||||
power: bool = Field(description="Whether the device should be on (true) or off (false)")
|
||||
|
||||
|
||||
class DeviceResponse(BaseModel):
|
||||
"""Device information response."""
|
||||
|
||||
@@ -128,6 +172,19 @@ class DeviceResponse(BaseModel):
|
||||
zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate")
|
||||
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
|
||||
dmx_start_universe: int = Field(default=0, description="DMX start universe")
|
||||
dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)")
|
||||
espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address")
|
||||
espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel")
|
||||
hue_username: str = Field(default="", description="Hue bridge username")
|
||||
hue_client_key: str = Field(default="", description="Hue entertainment client key")
|
||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
||||
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
|
||||
default_css_processing_template_id: str = Field(default="", description="Default color strip processing template ID")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
@@ -177,6 +177,20 @@ class TargetMetricsResponse(BaseModel):
|
||||
last_update: Optional[datetime] = Field(None, description="Last update timestamp")
|
||||
|
||||
|
||||
class BulkTargetRequest(BaseModel):
|
||||
"""Request body for bulk start/stop operations."""
|
||||
|
||||
ids: List[str] = Field(description="List of target IDs to operate on")
|
||||
|
||||
|
||||
class BulkTargetResponse(BaseModel):
|
||||
"""Response for bulk start/stop operations."""
|
||||
|
||||
started: List[str] = Field(default_factory=list, description="IDs that were successfully started")
|
||||
stopped: List[str] = Field(default_factory=list, description="IDs that were successfully stopped")
|
||||
errors: Dict[str, str] = Field(default_factory=dict, description="Map of target ID to error message for failures")
|
||||
|
||||
|
||||
class KCTestRectangleResponse(BaseModel):
|
||||
"""A rectangle with its extracted color from a KC test."""
|
||||
|
||||
|
||||
@@ -10,15 +10,23 @@ class PictureSourceCreate(BaseModel):
|
||||
"""Request to create a picture source."""
|
||||
|
||||
name: str = Field(description="Stream name", min_length=1, max_length=100)
|
||||
stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type")
|
||||
stream_type: Literal["raw", "processed", "static_image", "video"] = Field(description="Stream type")
|
||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90)
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
# Video fields
|
||||
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
|
||||
loop: bool = Field(True, description="Loop video playback")
|
||||
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||
|
||||
|
||||
class PictureSourceUpdate(BaseModel):
|
||||
@@ -27,12 +35,20 @@ class PictureSourceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100)
|
||||
display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0)
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=1, le=90)
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
|
||||
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
|
||||
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
|
||||
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
|
||||
description: Optional[str] = Field(None, description="Stream description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
# Video fields
|
||||
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
|
||||
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0)
|
||||
resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680)
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing")
|
||||
|
||||
|
||||
class PictureSourceResponse(BaseModel):
|
||||
@@ -40,7 +56,7 @@ class PictureSourceResponse(BaseModel):
|
||||
|
||||
id: str = Field(description="Stream ID")
|
||||
name: str = Field(description="Stream name")
|
||||
stream_type: str = Field(description="Stream type (raw, processed, or static_image)")
|
||||
stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)")
|
||||
display_index: Optional[int] = Field(None, description="Display index")
|
||||
capture_template_id: Optional[str] = Field(None, description="Capture template ID")
|
||||
target_fps: Optional[int] = Field(None, description="Target FPS")
|
||||
@@ -51,6 +67,14 @@ class PictureSourceResponse(BaseModel):
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Stream description")
|
||||
# Video fields
|
||||
url: Optional[str] = Field(None, description="Video URL")
|
||||
loop: Optional[bool] = Field(None, description="Loop video playback")
|
||||
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
|
||||
start_time: Optional[float] = Field(None, description="Trim start time in seconds")
|
||||
end_time: Optional[float] = Field(None, description="Trim end time in seconds")
|
||||
resolution_limit: Optional[int] = Field(None, description="Max width for decode")
|
||||
clock_id: Optional[str] = Field(None, description="Sync clock ID")
|
||||
|
||||
|
||||
class PictureSourceListResponse(BaseModel):
|
||||
|
||||
@@ -12,6 +12,7 @@ class HealthResponse(BaseModel):
|
||||
status: Literal["healthy", "unhealthy"] = Field(description="Service health status")
|
||||
timestamp: datetime = Field(description="Current server time")
|
||||
version: str = Field(description="Application version")
|
||||
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
@@ -20,6 +21,7 @@ class VersionResponse(BaseModel):
|
||||
version: str = Field(description="Application version")
|
||||
python_version: str = Field(description="Python version")
|
||||
api_version: str = Field(description="API version")
|
||||
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||
|
||||
|
||||
class DisplayInfo(BaseModel):
|
||||
@@ -115,3 +117,57 @@ class BackupListResponse(BaseModel):
|
||||
|
||||
backups: List[BackupFileInfo]
|
||||
count: int
|
||||
|
||||
|
||||
# ─── MQTT schemas ──────────────────────────────────────────────
|
||||
|
||||
class MQTTSettingsResponse(BaseModel):
|
||||
"""MQTT broker settings response (password is masked)."""
|
||||
|
||||
enabled: bool = Field(description="Whether MQTT is enabled")
|
||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||
username: str = Field(description="MQTT username (empty = anonymous)")
|
||||
password_set: bool = Field(description="Whether a password is configured")
|
||||
client_id: str = Field(description="MQTT client ID")
|
||||
base_topic: str = Field(description="Base topic prefix")
|
||||
|
||||
|
||||
class MQTTSettingsRequest(BaseModel):
|
||||
"""MQTT broker settings update request."""
|
||||
|
||||
enabled: bool = Field(description="Whether MQTT is enabled")
|
||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||
username: str = Field(default="", description="MQTT username (empty = anonymous)")
|
||||
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
|
||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||
|
||||
|
||||
# ─── External URL schema ───────────────────────────────────────
|
||||
|
||||
class ExternalUrlResponse(BaseModel):
|
||||
"""External URL setting response."""
|
||||
|
||||
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
|
||||
|
||||
|
||||
class ExternalUrlRequest(BaseModel):
|
||||
"""External URL setting update request."""
|
||||
|
||||
external_url: str = Field(default="", description="External base URL. Empty string to clear.")
|
||||
|
||||
|
||||
# ─── Log level schemas ─────────────────────────────────────────
|
||||
|
||||
class LogLevelResponse(BaseModel):
|
||||
"""Current log level response."""
|
||||
|
||||
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
||||
|
||||
|
||||
class LogLevelRequest(BaseModel):
|
||||
"""Request to change the log level."""
|
||||
|
||||
level: str = Field(description="New log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
||||
|
||||
@@ -10,12 +10,12 @@ class ValueSourceCreate(BaseModel):
|
||||
"""Request to create a value source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene"] = Field(description="Source type")
|
||||
source_type: Literal["static", "animated", "audio", "adaptive_time", "adaptive_scene", "daylight"] = Field(description="Source type")
|
||||
# static fields
|
||||
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# animated fields
|
||||
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
|
||||
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# audio fields
|
||||
@@ -28,6 +28,9 @@ class ValueSourceCreate(BaseModel):
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||
# daylight fields
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
@@ -40,7 +43,7 @@ class ValueSourceUpdate(BaseModel):
|
||||
value: Optional[float] = Field(None, description="Constant value (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# animated fields
|
||||
waveform: Optional[str] = Field(None, description="Waveform: sine|triangle|square|sawtooth")
|
||||
speed: Optional[float] = Field(None, description="Cycles per minute (1.0-120.0)", ge=1.0, le=120.0)
|
||||
speed: Optional[float] = Field(None, description="Speed: animated=cpm (0.1-120), daylight=multiplier (0.1-10)", ge=0.1, le=120.0)
|
||||
min_value: Optional[float] = Field(None, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
max_value: Optional[float] = Field(None, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# audio fields
|
||||
@@ -53,6 +56,9 @@ class ValueSourceUpdate(BaseModel):
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match")
|
||||
# daylight fields
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time instead of simulation")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -76,6 +82,8 @@ class ValueSourceResponse(BaseModel):
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
|
||||
@@ -39,6 +39,7 @@ class StorageConfig(BaseSettings):
|
||||
value_sources_file: str = "data/value_sources.json"
|
||||
automations_file: str = "data/automations.json"
|
||||
scene_presets_file: str = "data/scene_presets.json"
|
||||
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
|
||||
sync_clocks_file: str = "data/sync_clocks.json"
|
||||
|
||||
|
||||
@@ -72,12 +73,22 @@ class Config(BaseSettings):
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
demo: bool = False
|
||||
|
||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
"""Override storage paths when demo mode is active."""
|
||||
if self.demo:
|
||||
for field_name in self.storage.model_fields:
|
||||
value = getattr(self.storage, field_name)
|
||||
if isinstance(value, str) and value.startswith("data/"):
|
||||
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, config_path: str | Path) -> "Config":
|
||||
"""Load configuration from YAML file.
|
||||
@@ -92,7 +103,7 @@ class Config(BaseSettings):
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
||||
|
||||
with open(config_path, "r") as f:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
|
||||
return cls(**config_data)
|
||||
@@ -103,8 +114,9 @@ class Config(BaseSettings):
|
||||
|
||||
Tries to load from:
|
||||
1. Environment variable WLED_CONFIG_PATH
|
||||
2. ./config/default_config.yaml
|
||||
3. Default values
|
||||
2. WLED_DEMO=true → ./config/demo_config.yaml (if it exists)
|
||||
3. ./config/default_config.yaml
|
||||
4. Default values
|
||||
|
||||
Returns:
|
||||
Config instance
|
||||
@@ -114,6 +126,12 @@ class Config(BaseSettings):
|
||||
if config_path:
|
||||
return cls.from_yaml(config_path)
|
||||
|
||||
# Demo mode: try dedicated demo config first
|
||||
if os.getenv("WLED_DEMO", "").lower() in ("true", "1", "yes"):
|
||||
demo_path = Path("config/demo_config.yaml")
|
||||
if demo_path.exists():
|
||||
return cls.from_yaml(demo_path)
|
||||
|
||||
# Try default location
|
||||
default_path = Path("config/default_config.yaml")
|
||||
if default_path.exists():
|
||||
@@ -148,3 +166,8 @@ def reload_config() -> Config:
|
||||
global config
|
||||
config = Config.load()
|
||||
return config
|
||||
|
||||
|
||||
def is_demo_mode() -> bool:
|
||||
"""Check whether the application is running in demo mode."""
|
||||
return get_config().demo
|
||||
|
||||
@@ -15,10 +15,12 @@ from wled_controller.core.audio.analysis import (
|
||||
)
|
||||
from wled_controller.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
|
||||
from wled_controller.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
|
||||
from wled_controller.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
|
||||
|
||||
# Auto-register available engines
|
||||
AudioEngineRegistry.register(WasapiEngine)
|
||||
AudioEngineRegistry.register(SounddeviceEngine)
|
||||
AudioEngineRegistry.register(DemoAudioEngine)
|
||||
|
||||
__all__ = [
|
||||
"AudioCaptureEngine",
|
||||
@@ -34,4 +36,6 @@ __all__ = [
|
||||
"WasapiCaptureStream",
|
||||
"SounddeviceEngine",
|
||||
"SounddeviceCaptureStream",
|
||||
"DemoAudioEngine",
|
||||
"DemoAudioCaptureStream",
|
||||
]
|
||||
|
||||
@@ -141,7 +141,6 @@ class AudioAnalyzer:
|
||||
Returns:
|
||||
AudioAnalysis with spectrum, RMS, beat, etc.
|
||||
"""
|
||||
chunk_size = self._chunk_size
|
||||
alpha = self._smoothing_alpha
|
||||
one_minus_alpha = 1.0 - alpha
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from wled_controller.core.audio.analysis import (
|
||||
AudioAnalysis,
|
||||
AudioAnalyzer,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
)
|
||||
from wled_controller.core.audio.base import AudioCaptureStreamBase
|
||||
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||
|
||||
153
server/src/wled_controller/core/audio/demo_engine.py
Normal file
153
server/src/wled_controller/core/audio/demo_engine.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Demo audio engine — virtual audio devices with synthetic audio data."""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.config import is_demo_mode
|
||||
from wled_controller.core.audio.base import (
|
||||
AudioCaptureEngine,
|
||||
AudioCaptureStreamBase,
|
||||
AudioDeviceInfo,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Virtual audio device definitions: (name, is_loopback, channels, samplerate)
|
||||
_VIRTUAL_DEVICES = [
|
||||
("Demo Microphone", False, 2, 44100.0),
|
||||
("Demo System Audio", True, 2, 44100.0),
|
||||
]
|
||||
|
||||
|
||||
class DemoAudioCaptureStream(AudioCaptureStreamBase):
|
||||
"""Demo audio capture stream that produces synthetic music-like audio.
|
||||
|
||||
Generates a mix of sine waves with slowly varying frequencies to
|
||||
simulate beat-like patterns suitable for audio-reactive visualizations.
|
||||
"""
|
||||
|
||||
def __init__(self, device_index: int, is_loopback: bool, config: Dict[str, Any]):
|
||||
super().__init__(device_index, is_loopback, config)
|
||||
self._channels = 2
|
||||
self._sample_rate = 44100
|
||||
self._chunk_size = 1024
|
||||
self._phase = 0.0 # Accumulated phase in samples for continuity
|
||||
|
||||
@property
|
||||
def channels(self) -> int:
|
||||
return self._channels
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> int:
|
||||
return self._sample_rate
|
||||
|
||||
@property
|
||||
def chunk_size(self) -> int:
|
||||
return self._chunk_size
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
self._phase = 0.0
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
f"Demo audio stream initialized "
|
||||
f"(device={self.device_index}, loopback={self.is_loopback})"
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._initialized = False
|
||||
logger.info(f"Demo audio stream cleaned up (device={self.device_index})")
|
||||
|
||||
def read_chunk(self) -> Optional[np.ndarray]:
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
t_now = time.time()
|
||||
n = self._chunk_size
|
||||
sr = self._sample_rate
|
||||
|
||||
# Sample indices for this chunk (continuous across calls)
|
||||
t = (self._phase + np.arange(n, dtype=np.float64)) / sr
|
||||
self._phase += n
|
||||
|
||||
# --- Synthetic "music" signal ---
|
||||
# Bass drum: ~80 Hz with slow amplitude envelope (~2 Hz beat)
|
||||
bass_freq = 80.0
|
||||
beat_rate = 2.0 # beats per second
|
||||
bass_env = np.maximum(0.0, np.sin(2.0 * np.pi * beat_rate * t)) ** 4
|
||||
bass = 0.5 * bass_env * np.sin(2.0 * np.pi * bass_freq * t)
|
||||
|
||||
# Mid-range tone: slowly sweeping between 300-600 Hz
|
||||
mid_freq = 450.0 + 150.0 * np.sin(2.0 * np.pi * 0.1 * t_now)
|
||||
mid = 0.25 * np.sin(2.0 * np.pi * mid_freq * t)
|
||||
|
||||
# High shimmer: ~3 kHz with faster modulation
|
||||
hi_freq = 3000.0 + 500.0 * np.sin(2.0 * np.pi * 0.3 * t_now)
|
||||
hi_env = 0.5 + 0.5 * np.sin(2.0 * np.pi * 4.0 * t)
|
||||
hi = 0.1 * hi_env * np.sin(2.0 * np.pi * hi_freq * t)
|
||||
|
||||
# Mix mono signal
|
||||
mono = (bass + mid + hi).astype(np.float32)
|
||||
|
||||
# Interleave stereo (identical L/R)
|
||||
stereo = np.empty(n * self._channels, dtype=np.float32)
|
||||
stereo[0::2] = mono
|
||||
stereo[1::2] = mono
|
||||
|
||||
return stereo
|
||||
|
||||
|
||||
class DemoAudioEngine(AudioCaptureEngine):
|
||||
"""Virtual audio engine for demo mode.
|
||||
|
||||
Provides virtual audio devices and produces synthetic audio data
|
||||
so the full audio-reactive pipeline works without real audio hardware.
|
||||
"""
|
||||
|
||||
ENGINE_TYPE = "demo"
|
||||
ENGINE_PRIORITY = 1000 # Highest priority in demo mode
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return is_demo_mode()
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"sample_rate": 44100,
|
||||
"chunk_size": 1024,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
|
||||
devices = []
|
||||
for idx, (name, is_loopback, channels, samplerate) in enumerate(_VIRTUAL_DEVICES):
|
||||
devices.append(AudioDeviceInfo(
|
||||
index=idx,
|
||||
name=name,
|
||||
is_input=True,
|
||||
is_loopback=is_loopback,
|
||||
channels=channels,
|
||||
default_samplerate=samplerate,
|
||||
))
|
||||
logger.debug(f"Demo audio engine: {len(devices)} virtual device(s)")
|
||||
return devices
|
||||
|
||||
@classmethod
|
||||
def create_stream(
|
||||
cls,
|
||||
device_index: int,
|
||||
is_loopback: bool,
|
||||
config: Dict[str, Any],
|
||||
) -> DemoAudioCaptureStream:
|
||||
if device_index < 0 or device_index >= len(_VIRTUAL_DEVICES):
|
||||
raise ValueError(
|
||||
f"Invalid demo audio device index {device_index}. "
|
||||
f"Available: 0-{len(_VIRTUAL_DEVICES) - 1}"
|
||||
)
|
||||
merged = {**cls.get_default_config(), **config}
|
||||
return DemoAudioCaptureStream(device_index, is_loopback, merged)
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from wled_controller.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
|
||||
from wled_controller.config import is_demo_mode
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -67,9 +68,13 @@ class AudioEngineRegistry:
|
||||
Returns:
|
||||
List of engine type identifiers that are available
|
||||
"""
|
||||
demo = is_demo_mode()
|
||||
available = []
|
||||
for engine_type, engine_class in cls._engines.items():
|
||||
try:
|
||||
# In demo mode, only demo engines are available
|
||||
if demo and engine_type != "demo":
|
||||
continue
|
||||
if engine_class.is_available():
|
||||
available.append(engine_type)
|
||||
except Exception as e:
|
||||
@@ -85,10 +90,13 @@ class AudioEngineRegistry:
|
||||
Returns:
|
||||
Engine type string, or None if no engines are available.
|
||||
"""
|
||||
demo = is_demo_mode()
|
||||
best_type = None
|
||||
best_priority = -1
|
||||
for engine_type, engine_class in cls._engines.items():
|
||||
try:
|
||||
if demo and engine_type != "demo":
|
||||
continue
|
||||
if engine_class.is_available() and engine_class.ENGINE_PRIORITY > best_priority:
|
||||
best_priority = engine_class.ENGINE_PRIORITY
|
||||
best_type = engine_type
|
||||
@@ -102,9 +110,13 @@ class AudioEngineRegistry:
|
||||
def get_all_engines(cls) -> Dict[str, Type[AudioCaptureEngine]]:
|
||||
"""Get all registered engines (available or not).
|
||||
|
||||
In demo mode, only demo engines are returned.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping engine type to engine class
|
||||
"""
|
||||
if is_demo_mode():
|
||||
return {k: v for k, v in cls._engines.items() if k == "demo"}
|
||||
return cls._engines.copy()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Set
|
||||
from typing import Dict, Optional, Set
|
||||
|
||||
from wled_controller.core.automations.platform_detector import PlatformDetector
|
||||
from wled_controller.storage.automation import (
|
||||
@@ -398,8 +398,8 @@ class AutomationEngine:
|
||||
"automation_id": automation_id,
|
||||
"action": action,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("Automation action failed: %s", e, exc_info=True)
|
||||
|
||||
# ===== Public query methods (used by API) =====
|
||||
|
||||
|
||||
@@ -211,6 +211,14 @@ class AutoBackupEngine:
|
||||
raise ValueError("Invalid filename")
|
||||
return target
|
||||
|
||||
async def trigger_backup(self) -> dict:
|
||||
"""Manually trigger a backup and prune old ones. Returns the created backup info."""
|
||||
await self._perform_backup()
|
||||
self._prune_old_backups()
|
||||
# Return the most recent backup entry
|
||||
backups = self.list_backups()
|
||||
return backups[0] if backups else {}
|
||||
|
||||
def delete_backup(self, filename: str) -> None:
|
||||
target = self._safe_backup_path(filename)
|
||||
if not target.exists():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Calibration system for mapping screen pixels to LED positions."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Literal, Optional, Set, Tuple
|
||||
from typing import Dict, List, Literal, Set, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Screen capture functionality using mss library."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
from typing import List
|
||||
|
||||
import mss
|
||||
import numpy as np
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Screen overlay visualization for LED calibration testing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tkinter as tk
|
||||
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig
|
||||
from wled_controller.core.capture_engines.base import DisplayInfo
|
||||
@@ -45,6 +48,8 @@ class OverlayWindow:
|
||||
|
||||
def start(self, root: tk.Tk) -> None:
|
||||
"""Create and show the overlay Toplevel (runs in Tk thread)."""
|
||||
import tkinter as tk # lazy import — tkinter unavailable in headless CI
|
||||
|
||||
self._window = tk.Toplevel(root)
|
||||
self._setup_window()
|
||||
self._draw_visualization()
|
||||
@@ -75,6 +80,8 @@ class OverlayWindow:
|
||||
win.overrideredirect(True)
|
||||
win.attributes("-topmost", True)
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
self._canvas = tk.Canvas(
|
||||
win,
|
||||
width=self.display_info.width,
|
||||
@@ -271,6 +278,8 @@ class OverlayManager:
|
||||
|
||||
def _start_tk_thread(self) -> None:
|
||||
def _run():
|
||||
import tkinter as tk # lazy import — tkinter unavailable in headless CI
|
||||
|
||||
try:
|
||||
self._tk_root = tk.Tk()
|
||||
self._tk_root.withdraw() # invisible root — never shown
|
||||
|
||||
@@ -13,6 +13,7 @@ from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngin
|
||||
from wled_controller.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
|
||||
from wled_controller.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
||||
from wled_controller.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
|
||||
from wled_controller.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
|
||||
|
||||
# Auto-register available engines
|
||||
EngineRegistry.register(MSSEngine)
|
||||
@@ -21,6 +22,7 @@ EngineRegistry.register(BetterCamEngine)
|
||||
EngineRegistry.register(WGCEngine)
|
||||
EngineRegistry.register(ScrcpyEngine)
|
||||
EngineRegistry.register(CameraEngine)
|
||||
EngineRegistry.register(DemoCaptureEngine)
|
||||
|
||||
__all__ = [
|
||||
"CaptureEngine",
|
||||
@@ -40,4 +42,6 @@ __all__ = [
|
||||
"ScrcpyCaptureStream",
|
||||
"CameraEngine",
|
||||
"CameraCaptureStream",
|
||||
"DemoCaptureEngine",
|
||||
"DemoCaptureStream",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -145,9 +144,9 @@ class BetterCamEngine(CaptureEngine):
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import bettercam
|
||||
return True
|
||||
except ImportError:
|
||||
import importlib.util
|
||||
return importlib.util.find_spec("bettercam") is not None
|
||||
except (ImportError, ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -10,10 +10,10 @@ Prerequisites (optional dependency):
|
||||
|
||||
import platform
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -26,6 +26,13 @@ from wled_controller.utils import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
||||
|
||||
# Process-wide registry of cv2 camera indices currently held open.
|
||||
# Prevents _enumerate_cameras from probing an in-use camera (which can
|
||||
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
|
||||
# from opening the same physical camera concurrently.
|
||||
_active_cv2_indices: Set[int] = set()
|
||||
_camera_lock = threading.Lock()
|
||||
_CV2_BACKENDS = {
|
||||
"auto": None,
|
||||
"dshow": 700, # cv2.CAP_DSHOW
|
||||
@@ -103,7 +110,29 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
|
||||
cameras: List[Dict[str, Any]] = []
|
||||
sequential_idx = 0
|
||||
|
||||
with _camera_lock:
|
||||
active = set(_active_cv2_indices)
|
||||
|
||||
for i in range(max_probe):
|
||||
if i in active:
|
||||
# Camera already held open — use cached metadata if available,
|
||||
# otherwise add a placeholder so display_index mapping stays stable.
|
||||
if _camera_cache is not None:
|
||||
prev = [c for c in _camera_cache if c["cv2_index"] == i]
|
||||
if prev:
|
||||
cameras.append(prev[0])
|
||||
sequential_idx += 1
|
||||
continue
|
||||
cameras.append({
|
||||
"cv2_index": i,
|
||||
"name": friendly_names.get(sequential_idx, f"Camera {sequential_idx}"),
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"fps": 30.0,
|
||||
})
|
||||
sequential_idx += 1
|
||||
continue
|
||||
|
||||
if backend_id is not None:
|
||||
cap = cv2.VideoCapture(i, backend_id)
|
||||
else:
|
||||
@@ -149,6 +178,7 @@ class CameraCaptureStream(CaptureStream):
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._cap = None
|
||||
self._cv2_index: Optional[int] = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
@@ -173,6 +203,16 @@ class CameraCaptureStream(CaptureStream):
|
||||
camera = cameras[self.display_index]
|
||||
cv2_index = camera["cv2_index"]
|
||||
|
||||
# Prevent concurrent opens of the same physical camera (crashes DSHOW)
|
||||
with _camera_lock:
|
||||
if cv2_index in _active_cv2_indices:
|
||||
raise RuntimeError(
|
||||
f"Camera {self.display_index} (cv2 index {cv2_index}) "
|
||||
f"is already in use by another stream"
|
||||
)
|
||||
_active_cv2_indices.add(cv2_index)
|
||||
|
||||
try:
|
||||
# Open the camera
|
||||
backend_id = _cv2_backend_id(backend_name)
|
||||
if backend_id is not None:
|
||||
@@ -185,6 +225,12 @@ class CameraCaptureStream(CaptureStream):
|
||||
f"Failed to open camera {self.display_index} "
|
||||
f"(cv2 index {cv2_index})"
|
||||
)
|
||||
except Exception:
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(cv2_index)
|
||||
raise
|
||||
|
||||
self._cv2_index = cv2_index
|
||||
|
||||
# Apply optional resolution override
|
||||
res_w = self.config.get("resolution_width", 0)
|
||||
@@ -198,6 +244,9 @@ class CameraCaptureStream(CaptureStream):
|
||||
if not ret or frame is None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(cv2_index)
|
||||
self._cv2_index = None
|
||||
raise RuntimeError(
|
||||
f"Camera {self.display_index} opened but test read failed"
|
||||
)
|
||||
@@ -234,6 +283,10 @@ class CameraCaptureStream(CaptureStream):
|
||||
if self._cap is not None:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
if self._cv2_index is not None:
|
||||
with _camera_lock:
|
||||
_active_cv2_indices.discard(self._cv2_index)
|
||||
self._cv2_index = None
|
||||
self._initialized = False
|
||||
logger.info(f"Camera capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
|
||||
170
server/src/wled_controller/core/capture_engines/demo_engine.py
Normal file
170
server/src/wled_controller/core/capture_engines/demo_engine.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Demo capture engine — virtual displays with animated test patterns."""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.config import is_demo_mode
|
||||
from wled_controller.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
DisplayInfo,
|
||||
ScreenCapture,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Virtual display definitions: (name, width, height, x, y, is_primary)
|
||||
_VIRTUAL_DISPLAYS = [
|
||||
("Demo Display 1080p", 1920, 1080, 0, 360, True),
|
||||
("Demo Ultrawide", 3440, 1440, 1920, 0, False),
|
||||
("Demo Portrait", 1080, 1920, 5360, 0, False),
|
||||
]
|
||||
|
||||
|
||||
class DemoCaptureStream(CaptureStream):
|
||||
"""Demo capture stream producing a radial rainbow centred on the screen.
|
||||
|
||||
The rainbow rotates slowly over time — hue is mapped to the angle from
|
||||
the screen centre, and brightness fades toward the edges.
|
||||
"""
|
||||
|
||||
_RENDER_SCALE = 4 # render at 1/4 resolution, then upscale
|
||||
|
||||
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||
super().__init__(display_index, config)
|
||||
self._width: int = config.get("width", 1920)
|
||||
self._height: int = config.get("height", 1080)
|
||||
# Pre-compute at render resolution
|
||||
rw = max(1, self._width // self._RENDER_SCALE)
|
||||
rh = max(1, self._height // self._RENDER_SCALE)
|
||||
self._rw = rw
|
||||
self._rh = rh
|
||||
# Coordinate grids centred at (0, 0), aspect-corrected so the
|
||||
# gradient is circular even on non-square displays
|
||||
aspect = self._width / max(self._height, 1)
|
||||
x = np.linspace(-aspect, aspect, rw, dtype=np.float32)
|
||||
y = np.linspace(-1.0, 1.0, rh, dtype=np.float32)
|
||||
self._yy, self._xx = np.meshgrid(y, x, indexing="ij")
|
||||
# Pre-compute angle (atan2) and radius — they don't change per frame
|
||||
self._angle = np.arctan2(self._yy, self._xx) # -pi..pi
|
||||
self._radius = np.sqrt(self._xx ** 2 + self._yy ** 2)
|
||||
|
||||
def initialize(self) -> None:
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
f"Demo capture stream initialized "
|
||||
f"(display={self.display_index}, {self._width}x{self._height})"
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._initialized = False
|
||||
logger.info(f"Demo capture stream cleaned up (display={self.display_index})")
|
||||
|
||||
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
t = time.time() % 1e6
|
||||
|
||||
# Hue = angle from centre, rotating over time
|
||||
rotation = t * 0.15 # radians per second
|
||||
hue = ((self._angle + rotation) / (2.0 * np.pi)) % 1.0
|
||||
|
||||
# Saturation: full
|
||||
|
||||
# Value: bright at centre, fading toward edges
|
||||
max_r = float(self._radius.max()) or 1.0
|
||||
val = np.clip(1.0 - 0.6 * (self._radius / max_r), 0.0, 1.0)
|
||||
|
||||
# Vectorised HSV → RGB (S=1 simplification)
|
||||
h6 = hue * 6.0
|
||||
sector = h6.astype(np.int32) % 6
|
||||
frac = h6 - np.floor(h6)
|
||||
q = val * (1.0 - frac)
|
||||
t_ch = val * frac # "t" channel in HSV conversion
|
||||
|
||||
r = np.where(sector == 0, val,
|
||||
np.where(sector == 1, q,
|
||||
np.where(sector == 2, 0,
|
||||
np.where(sector == 3, 0,
|
||||
np.where(sector == 4, t_ch, val)))))
|
||||
g = np.where(sector == 0, t_ch,
|
||||
np.where(sector == 1, val,
|
||||
np.where(sector == 2, val,
|
||||
np.where(sector == 3, q,
|
||||
np.where(sector == 4, 0, 0)))))
|
||||
b = np.where(sector == 0, 0,
|
||||
np.where(sector == 1, 0,
|
||||
np.where(sector == 2, t_ch,
|
||||
np.where(sector == 3, val,
|
||||
np.where(sector == 4, val, q)))))
|
||||
|
||||
small_u8 = (np.stack([r, g, b], axis=-1) * 255.0).astype(np.uint8)
|
||||
|
||||
# Upscale to full resolution
|
||||
if self._RENDER_SCALE > 1:
|
||||
image = np.repeat(
|
||||
np.repeat(small_u8, self._RENDER_SCALE, axis=0),
|
||||
self._RENDER_SCALE, axis=1,
|
||||
)[: self._height, : self._width]
|
||||
else:
|
||||
image = small_u8
|
||||
|
||||
return ScreenCapture(
|
||||
image=image,
|
||||
width=self._width,
|
||||
height=self._height,
|
||||
display_index=self.display_index,
|
||||
)
|
||||
|
||||
|
||||
class DemoCaptureEngine(CaptureEngine):
|
||||
"""Virtual capture engine for demo mode.
|
||||
|
||||
Provides virtual displays and produces animated test-pattern frames
|
||||
so the full capture pipeline works without real monitors.
|
||||
"""
|
||||
|
||||
ENGINE_TYPE = "demo"
|
||||
ENGINE_PRIORITY = 1000 # Highest priority in demo mode
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return is_demo_mode()
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||
displays = []
|
||||
for idx, (name, width, height, x, y, primary) in enumerate(_VIRTUAL_DISPLAYS):
|
||||
displays.append(DisplayInfo(
|
||||
index=idx,
|
||||
name=name,
|
||||
width=width,
|
||||
height=height,
|
||||
x=x,
|
||||
y=y,
|
||||
is_primary=primary,
|
||||
refresh_rate=60,
|
||||
))
|
||||
logger.debug(f"Demo engine: {len(displays)} virtual display(s)")
|
||||
return displays
|
||||
|
||||
@classmethod
|
||||
def create_stream(
|
||||
cls, display_index: int, config: Dict[str, Any],
|
||||
) -> DemoCaptureStream:
|
||||
if display_index < 0 or display_index >= len(_VIRTUAL_DISPLAYS):
|
||||
raise ValueError(
|
||||
f"Invalid demo display index {display_index}. "
|
||||
f"Available: 0-{len(_VIRTUAL_DISPLAYS) - 1}"
|
||||
)
|
||||
name, width, height, *_ = _VIRTUAL_DISPLAYS[display_index]
|
||||
stream_config = {**config, "width": width, "height": height}
|
||||
return DemoCaptureStream(display_index, stream_config)
|
||||
@@ -4,7 +4,6 @@ import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -145,9 +144,9 @@ class DXcamEngine(CaptureEngine):
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import dxcam
|
||||
return True
|
||||
except ImportError:
|
||||
import importlib.util
|
||||
return importlib.util.find_spec("dxcam") is not None
|
||||
except (ImportError, ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from wled_controller.core.capture_engines.base import CaptureEngine, CaptureStream
|
||||
from wled_controller.config import is_demo_mode
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -67,9 +68,13 @@ class EngineRegistry:
|
||||
Returns:
|
||||
List of engine type identifiers that are available
|
||||
"""
|
||||
demo = is_demo_mode()
|
||||
available = []
|
||||
for engine_type, engine_class in cls._engines.items():
|
||||
try:
|
||||
# In demo mode, only demo engines are available
|
||||
if demo and engine_type != "demo":
|
||||
continue
|
||||
if engine_class.is_available():
|
||||
available.append(engine_type)
|
||||
except Exception as e:
|
||||
@@ -86,10 +91,13 @@ class EngineRegistry:
|
||||
Returns:
|
||||
Engine type string, or None if no engines are available.
|
||||
"""
|
||||
demo = is_demo_mode()
|
||||
best_type = None
|
||||
best_priority = -1
|
||||
for engine_type, engine_class in cls._engines.items():
|
||||
try:
|
||||
if demo and engine_type != "demo":
|
||||
continue
|
||||
if engine_class.is_available() and engine_class.ENGINE_PRIORITY > best_priority:
|
||||
best_priority = engine_class.ENGINE_PRIORITY
|
||||
best_type = engine_type
|
||||
@@ -103,9 +111,13 @@ class EngineRegistry:
|
||||
def get_all_engines(cls) -> Dict[str, Type[CaptureEngine]]:
|
||||
"""Get all registered engines (available or not).
|
||||
|
||||
In demo mode, only demo engines are returned.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping engine type to engine class
|
||||
"""
|
||||
if is_demo_mode():
|
||||
return {k: v for k, v in cls._engines.items() if k == "demo"}
|
||||
return cls._engines.copy()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -91,9 +91,9 @@ class MSSEngine(CaptureEngine):
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
try:
|
||||
import mss
|
||||
return True
|
||||
except ImportError:
|
||||
import importlib.util
|
||||
return importlib.util.find_spec("mss") is not None
|
||||
except (ImportError, ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -5,7 +5,6 @@ import sys
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -220,9 +219,9 @@ class WGCEngine(CaptureEngine):
|
||||
pass
|
||||
|
||||
try:
|
||||
import windows_capture
|
||||
return True
|
||||
except ImportError:
|
||||
import importlib.util
|
||||
return importlib.util.find_spec("windows_capture") is not None
|
||||
except (ImportError, ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
|
||||
412
server/src/wled_controller/core/demo_seed.py
Normal file
412
server/src/wled_controller/core/demo_seed.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""Seed data generator for demo mode.
|
||||
|
||||
Populates the demo data directory with sample entities on first run,
|
||||
giving new users a realistic out-of-the-box experience without needing
|
||||
real hardware.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.config import StorageConfig
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Fixed IDs so cross-references are stable
|
||||
_DEVICE_IDS = {
|
||||
"strip": "device_demo0001",
|
||||
"matrix": "device_demo0002",
|
||||
"ring": "device_demo0003",
|
||||
}
|
||||
|
||||
_TARGET_IDS = {
|
||||
"strip": "pt_demo0001",
|
||||
"matrix": "pt_demo0002",
|
||||
}
|
||||
|
||||
_PS_IDS = {
|
||||
"main": "ps_demo0001",
|
||||
"secondary": "ps_demo0002",
|
||||
}
|
||||
|
||||
_CSS_IDS = {
|
||||
"gradient": "css_demo0001",
|
||||
"cycle": "css_demo0002",
|
||||
"picture": "css_demo0003",
|
||||
"audio": "css_demo0004",
|
||||
}
|
||||
|
||||
_AS_IDS = {
|
||||
"system": "as_demo0001",
|
||||
"mono": "as_demo0002",
|
||||
}
|
||||
|
||||
_TPL_ID = "tpl_demo0001"
|
||||
|
||||
_SCENE_ID = "scene_demo0001"
|
||||
|
||||
_NOW = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _write_store(path: Path, json_key: str, items: dict) -> None:
|
||||
"""Write a store JSON file with version wrapper."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
json_key: items,
|
||||
}
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
logger.info(f"Seeded {len(items)} {json_key} -> {path}")
|
||||
|
||||
|
||||
def _has_data(storage_config: StorageConfig) -> bool:
|
||||
"""Check if any demo store file already has entities."""
|
||||
for field_name in storage_config.model_fields:
|
||||
value = getattr(storage_config, field_name)
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
p = Path(value)
|
||||
if p.exists() and p.stat().st_size > 20:
|
||||
# File exists and is non-trivial — check if it has entities
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
for key, val in raw.items():
|
||||
if key != "version" and isinstance(val, dict) and val:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def seed_demo_data(storage_config: StorageConfig) -> None:
|
||||
"""Populate demo data directory with sample entities.
|
||||
|
||||
Only runs when the demo data directory is empty (no existing entities).
|
||||
Must be called BEFORE store constructors run so they load the seeded data.
|
||||
"""
|
||||
if _has_data(storage_config):
|
||||
logger.info("Demo data already exists — skipping seed")
|
||||
return
|
||||
|
||||
logger.info("Seeding demo data for first-run experience")
|
||||
|
||||
_seed_devices(Path(storage_config.devices_file))
|
||||
_seed_capture_templates(Path(storage_config.templates_file))
|
||||
_seed_output_targets(Path(storage_config.output_targets_file))
|
||||
_seed_picture_sources(Path(storage_config.picture_sources_file))
|
||||
_seed_color_strip_sources(Path(storage_config.color_strip_sources_file))
|
||||
_seed_audio_sources(Path(storage_config.audio_sources_file))
|
||||
_seed_scene_presets(Path(storage_config.scene_presets_file))
|
||||
|
||||
logger.info("Demo seed data complete")
|
||||
|
||||
|
||||
# ── Devices ────────────────────────────────────────────────────────
|
||||
|
||||
def _seed_devices(path: Path) -> None:
|
||||
devices = {
|
||||
_DEVICE_IDS["strip"]: {
|
||||
"id": _DEVICE_IDS["strip"],
|
||||
"name": "Demo LED Strip",
|
||||
"url": "demo://demo-strip",
|
||||
"led_count": 60,
|
||||
"enabled": True,
|
||||
"device_type": "demo",
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_DEVICE_IDS["matrix"]: {
|
||||
"id": _DEVICE_IDS["matrix"],
|
||||
"name": "Demo LED Matrix",
|
||||
"url": "demo://demo-matrix",
|
||||
"led_count": 256,
|
||||
"enabled": True,
|
||||
"device_type": "demo",
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_DEVICE_IDS["ring"]: {
|
||||
"id": _DEVICE_IDS["ring"],
|
||||
"name": "Demo LED Ring",
|
||||
"url": "demo://demo-ring",
|
||||
"led_count": 24,
|
||||
"enabled": True,
|
||||
"device_type": "demo",
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "devices", devices)
|
||||
|
||||
|
||||
# ── Capture Templates ──────────────────────────────────────────────
|
||||
|
||||
def _seed_capture_templates(path: Path) -> None:
|
||||
templates = {
|
||||
_TPL_ID: {
|
||||
"id": _TPL_ID,
|
||||
"name": "Demo Capture",
|
||||
"engine_type": "demo",
|
||||
"engine_config": {},
|
||||
"description": "Default capture template using demo engine",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "templates", templates)
|
||||
|
||||
|
||||
# ── Output Targets ─────────────────────────────────────────────────
|
||||
|
||||
def _seed_output_targets(path: Path) -> None:
|
||||
targets = {
|
||||
_TARGET_IDS["strip"]: {
|
||||
"id": _TARGET_IDS["strip"],
|
||||
"name": "Strip — Gradient",
|
||||
"target_type": "led",
|
||||
"device_id": _DEVICE_IDS["strip"],
|
||||
"color_strip_source_id": _CSS_IDS["gradient"],
|
||||
"brightness_value_source_id": "",
|
||||
"fps": 30,
|
||||
"keepalive_interval": 1.0,
|
||||
"state_check_interval": 30,
|
||||
"min_brightness_threshold": 0,
|
||||
"adaptive_fps": False,
|
||||
"protocol": "ddp",
|
||||
"description": "Demo LED strip with gradient effect",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_TARGET_IDS["matrix"]: {
|
||||
"id": _TARGET_IDS["matrix"],
|
||||
"name": "Matrix — Screen Capture",
|
||||
"target_type": "led",
|
||||
"device_id": _DEVICE_IDS["matrix"],
|
||||
"color_strip_source_id": _CSS_IDS["picture"],
|
||||
"brightness_value_source_id": "",
|
||||
"fps": 30,
|
||||
"keepalive_interval": 1.0,
|
||||
"state_check_interval": 30,
|
||||
"min_brightness_threshold": 0,
|
||||
"adaptive_fps": False,
|
||||
"protocol": "ddp",
|
||||
"description": "Demo LED matrix with screen capture",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "output_targets", targets)
|
||||
|
||||
|
||||
# ── Picture Sources ────────────────────────────────────────────────
|
||||
|
||||
def _seed_picture_sources(path: Path) -> None:
|
||||
sources = {
|
||||
_PS_IDS["main"]: {
|
||||
"id": _PS_IDS["main"],
|
||||
"name": "Demo Display 1080p",
|
||||
"stream_type": "raw",
|
||||
"display_index": 0,
|
||||
"capture_template_id": _TPL_ID,
|
||||
"target_fps": 30,
|
||||
"description": "Virtual 1920x1080 display capture",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
# Nulls for non-applicable subclass fields
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
"url": None,
|
||||
"loop": None,
|
||||
"playback_speed": None,
|
||||
"start_time": None,
|
||||
"end_time": None,
|
||||
"resolution_limit": None,
|
||||
"clock_id": None,
|
||||
},
|
||||
_PS_IDS["secondary"]: {
|
||||
"id": _PS_IDS["secondary"],
|
||||
"name": "Demo Display 4K",
|
||||
"stream_type": "raw",
|
||||
"display_index": 1,
|
||||
"capture_template_id": _TPL_ID,
|
||||
"target_fps": 30,
|
||||
"description": "Virtual 3840x2160 display capture",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
"url": None,
|
||||
"loop": None,
|
||||
"playback_speed": None,
|
||||
"start_time": None,
|
||||
"end_time": None,
|
||||
"resolution_limit": None,
|
||||
"clock_id": None,
|
||||
},
|
||||
}
|
||||
_write_store(path, "picture_sources", sources)
|
||||
|
||||
|
||||
# ── Color Strip Sources ────────────────────────────────────────────
|
||||
|
||||
def _seed_color_strip_sources(path: Path) -> None:
|
||||
sources = {
|
||||
_CSS_IDS["gradient"]: {
|
||||
"id": _CSS_IDS["gradient"],
|
||||
"name": "Rainbow Gradient",
|
||||
"source_type": "gradient",
|
||||
"description": "Smooth rainbow gradient across all LEDs",
|
||||
"clock_id": None,
|
||||
"tags": ["demo"],
|
||||
"stops": [
|
||||
{"position": 0.0, "color": [255, 0, 0]},
|
||||
{"position": 0.25, "color": [255, 255, 0]},
|
||||
{"position": 0.5, "color": [0, 255, 0]},
|
||||
{"position": 0.75, "color": [0, 0, 255]},
|
||||
{"position": 1.0, "color": [255, 0, 255]},
|
||||
],
|
||||
"animation": {"enabled": True, "type": "gradient_shift", "speed": 0.5},
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_CSS_IDS["cycle"]: {
|
||||
"id": _CSS_IDS["cycle"],
|
||||
"name": "Warm Color Cycle",
|
||||
"source_type": "color_cycle",
|
||||
"description": "Smoothly cycles through warm colors",
|
||||
"clock_id": None,
|
||||
"tags": ["demo"],
|
||||
"colors": [
|
||||
[255, 60, 0],
|
||||
[255, 140, 0],
|
||||
[255, 200, 50],
|
||||
[255, 100, 20],
|
||||
],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_CSS_IDS["picture"]: {
|
||||
"id": _CSS_IDS["picture"],
|
||||
"name": "Screen Capture — Main Display",
|
||||
"source_type": "picture",
|
||||
"description": "Captures colors from the main demo display",
|
||||
"clock_id": None,
|
||||
"tags": ["demo"],
|
||||
"picture_source_id": _PS_IDS["main"],
|
||||
"fps": 30,
|
||||
"smoothing": 0.3,
|
||||
"interpolation_mode": "average",
|
||||
"calibration": {
|
||||
"mode": "simple",
|
||||
"layout": "clockwise",
|
||||
"start_position": "bottom_left",
|
||||
"leds_top": 28,
|
||||
"leds_bottom": 28,
|
||||
"leds_left": 16,
|
||||
"leds_right": 16,
|
||||
},
|
||||
"led_count": 0,
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
_CSS_IDS["audio"]: {
|
||||
"id": _CSS_IDS["audio"],
|
||||
"name": "Audio Spectrum",
|
||||
"source_type": "audio",
|
||||
"description": "Audio-reactive spectrum visualization",
|
||||
"clock_id": None,
|
||||
"tags": ["demo"],
|
||||
"visualization_mode": "spectrum",
|
||||
"audio_source_id": _AS_IDS["mono"],
|
||||
"sensitivity": 1.0,
|
||||
"smoothing": 0.3,
|
||||
"palette": "rainbow",
|
||||
"color": [0, 255, 0],
|
||||
"color_peak": [255, 0, 0],
|
||||
"led_count": 0,
|
||||
"mirror": False,
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "color_strip_sources", sources)
|
||||
|
||||
|
||||
# ── Audio Sources ──────────────────────────────────────────────────
|
||||
|
||||
def _seed_audio_sources(path: Path) -> None:
|
||||
sources = {
|
||||
_AS_IDS["system"]: {
|
||||
"id": _AS_IDS["system"],
|
||||
"name": "Demo System Audio",
|
||||
"source_type": "multichannel",
|
||||
"device_index": 1,
|
||||
"is_loopback": True,
|
||||
"audio_template_id": None,
|
||||
"description": "Virtual system audio (loopback)",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
# Forward-compat null fields
|
||||
"audio_source_id": None,
|
||||
"channel": None,
|
||||
},
|
||||
_AS_IDS["mono"]: {
|
||||
"id": _AS_IDS["mono"],
|
||||
"name": "Demo Audio — Mono",
|
||||
"source_type": "mono",
|
||||
"audio_source_id": _AS_IDS["system"],
|
||||
"channel": "mono",
|
||||
"description": "Mono mix of demo system audio",
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
# Forward-compat null fields
|
||||
"device_index": None,
|
||||
"is_loopback": None,
|
||||
"audio_template_id": None,
|
||||
},
|
||||
}
|
||||
_write_store(path, "audio_sources", sources)
|
||||
|
||||
|
||||
# ── Scene Presets ──────────────────────────────────────────────────
|
||||
|
||||
def _seed_scene_presets(path: Path) -> None:
|
||||
presets = {
|
||||
_SCENE_ID: {
|
||||
"id": _SCENE_ID,
|
||||
"name": "Demo Ambient",
|
||||
"description": "Activates gradient on the strip and screen capture on the matrix",
|
||||
"tags": ["demo"],
|
||||
"order": 0,
|
||||
"targets": [
|
||||
{
|
||||
"target_id": _TARGET_IDS["strip"],
|
||||
"running": True,
|
||||
"color_strip_source_id": _CSS_IDS["gradient"],
|
||||
"brightness_value_source_id": "",
|
||||
"fps": 30,
|
||||
},
|
||||
{
|
||||
"target_id": _TARGET_IDS["matrix"],
|
||||
"running": True,
|
||||
"color_strip_source_id": _CSS_IDS["picture"],
|
||||
"brightness_value_source_id": "",
|
||||
"fps": 30,
|
||||
},
|
||||
],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "scene_presets", presets)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -168,9 +168,7 @@ class AdalightClient(LEDClient):
|
||||
else:
|
||||
arr = np.array(pixels, dtype=np.uint16)
|
||||
|
||||
if brightness < 255:
|
||||
arr = arr * brightness // 255
|
||||
|
||||
# Note: brightness already applied by processor loop (_cached_brightness)
|
||||
np.clip(arr, 0, 255, out=arr)
|
||||
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||
return self._header + rgb_bytes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user