refactor: rename project to LedGrab, split HA integration into separate repo
Lint & Test / test (push) Successful in 1m56s

- Rename Python package: wled_controller -> ledgrab
- Rename env var prefix: WLED_ -> LEDGRAB_ (with auto-migration for old vars)
- Rename localStorage key: wled_api_key -> ledgrab_api_key (with migration)
- Rename HA integration domain: wled_screen_controller -> ledgrab
- Update all imports, build scripts, Docker, installer, config, docs
- Remove HA integration (moved to ledgrab-haos-integration repo)
- Remove hacs.json (belongs in HA repo now)
- Add startup warning for users with old WLED_ env vars
- All tests pass (715/715), ruff clean, tsc clean, frontend builds
This commit is contained in:
2026-04-12 22:45:28 +03:00
parent 38f73badbf
commit 02cd9d519c
548 changed files with 3502 additions and 5180 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Claude Instructions for WLED Screen Controller
# Claude Instructions for LedGrab
## Code Search
+4 -4
View File
@@ -9,8 +9,8 @@
## Development Setup
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller-mixed/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
# Python environment
python -m venv venv
@@ -29,7 +29,7 @@ npm run build
cd server
export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows
python -m wled_controller.main
python -m ledgrab.main
```
Open http://localhost:8080 to access the dashboard.
@@ -55,7 +55,7 @@ ruff check src/ tests/
## Frontend Changes
After modifying any file under `server/src/wled_controller/static/js/` or `static/css/`, rebuild the bundle:
After modifying any file under `server/src/ledgrab/static/js/` or `static/css/`, rebuild the bundle:
```bash
cd server
+29 -79
View File
@@ -1,15 +1,17 @@
# Installation Guide
Complete installation guide for LED Grab (WLED Screen Controller) server and Home Assistant integration.
Complete installation guide for the LedGrab server.
## Table of Contents
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)
4. [Configuration Reference](#configuration-reference)
5. [Troubleshooting](#troubleshooting)
> **Home Assistant integration** has moved to a separate repository:
> [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration)
---
@@ -20,8 +22,8 @@ The fastest way to get running. Requires [Docker](https://docs.docker.com/get-do
1. **Clone and start:**
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
docker compose up -d
```
@@ -54,7 +56,7 @@ cd server
docker build -t ledgrab .
docker run -d \
--name wled-screen-controller \
--name ledgrab \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
@@ -84,8 +86,8 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
1. **Clone the repository:**
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
```
2. **Build the frontend bundle:**
@@ -95,7 +97,7 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
npm run build
```
This compiles TypeScript and bundles JS/CSS into `src/wled_controller/static/dist/`.
This compiles TypeScript and bundles JS/CSS into `src/ledgrab/static/dist/`.
3. **Create a virtual environment:**
@@ -131,11 +133,11 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
```bash
# Linux / macOS
export PYTHONPATH=$(pwd)/src
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.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
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
6. **Verify:** open <http://localhost:8080> in your browser.
@@ -160,7 +162,7 @@ auth:
Option B -- set an environment variable:
```bash
export WLED_AUTH__API_KEYS__dev="your-secure-key-here"
export LEDGRAB_AUTH__API_KEYS__dev="your-secure-key-here"
```
Generate a random key:
@@ -184,7 +186,7 @@ server:
Or via environment variable:
```bash
WLED_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
LEDGRAB_SERVER__CORS_ORIGINS='["http://localhost:8080","http://192.168.1.100:8080"]'
```
### Discover devices
@@ -193,57 +195,12 @@ Open the dashboard and go to the **Devices** tab. Click **Discover** to find WLE
---
## Home Assistant Integration
### Option 1: HACS (recommended)
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.
### Option 2: Manual
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.
### Automation example
```yaml
automation:
- alias: "Start ambient lighting when TV turns on"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "on"
action:
- service: switch.turn_on
target:
entity_id: switch.living_room_tv_processing
- alias: "Stop ambient lighting when TV turns off"
trigger:
- platform: state
entity_id: media_player.living_room_tv
to: "off"
action:
- service: switch.turn_off
target:
entity_id: switch.living_room_tv_processing
```
---
## 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)
1. **Environment variables** -- prefix `LEDGRAB_`, double underscore as nesting delimiter (e.g., `LEDGRAB_SERVER__PORT=9090`)
2. **YAML config file** -- `server/config/default_config.yaml` (or set `LEDGRAB_CONFIG_PATH` to override)
3. **Built-in defaults**
See [`server/.env.example`](server/.env.example) for every available variable with descriptions.
@@ -252,14 +209,14 @@ See [`server/.env.example`](server/.env.example) for every available variable wi
| 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_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
| `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) |
| `LEDGRAB_SERVER__PORT` | `8080` | HTTP listen port |
| `LEDGRAB_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `LEDGRAB_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
| `LEDGRAB_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
| `LEDGRAB_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
| `LEDGRAB_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
| `LEDGRAB_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
| `LEDGRAB_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) |
---
@@ -276,7 +233,7 @@ python --version # must be 3.11+
**Check the frontend bundle exists:**
```bash
ls server/src/wled_controller/static/dist/app.bundle.js
ls server/src/ledgrab/static/dist/app.bundle.js
```
If missing, run `cd server && npm ci && npm run build`.
@@ -288,7 +245,7 @@ If missing, run `cd server && npm ci && npm run build`.
docker compose logs -f
# Manual install
tail -f logs/wled_controller.log
tail -f logs/ledgrab.log
```
### Cannot access the dashboard from another machine
@@ -297,13 +254,6 @@ tail -f logs/wled_controller.log
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.
@@ -324,4 +274,4 @@ tail -f logs/wled_controller.log
- [API Documentation](docs/API.md)
- [Calibration Guide](docs/CALIBRATION.md)
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
- [Repository Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues)
+15 -19
View File
@@ -87,8 +87,8 @@ A Home Assistant integration exposes devices as entities for smart home automati
### Docker (recommended)
```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git
cd wled-screen-controller/server
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
docker compose up -d
```
@@ -97,8 +97,8 @@ docker compose up -d
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
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd ledgrab/server
# Build the frontend bundle
npm ci && npm run build
@@ -112,7 +112,7 @@ 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
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
Open **http://localhost:8080** to access the dashboard.
@@ -125,17 +125,17 @@ See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, includin
Demo mode runs the server with virtual devices, sample data, and isolated storage — useful for exploring the UI without real hardware.
Set the `WLED_DEMO` environment variable to `true`, `1`, or `yes`:
Set the `LEDGRAB_DEMO` environment variable to `true`, `1`, or `yes`:
```bash
# Docker
docker compose run -e WLED_DEMO=true server
docker compose run -e LEDGRAB_DEMO=true server
# Python
WLED_DEMO=true uvicorn wled_controller.main:app --host 0.0.0.0 --port 8081
LEDGRAB_DEMO=true uvicorn ledgrab.main:app --host 0.0.0.0 --port 8081
# Windows (installed app)
set WLED_DEMO=true
set LEDGRAB_DEMO=true
LedGrab.bat
```
@@ -144,9 +144,9 @@ Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores
## Architecture
```text
wled-screen-controller/
ledgrab/
├── server/ # Python FastAPI backend
│ ├── src/wled_controller/
│ ├── src/ledgrab/
│ │ ├── main.py # Application entry point
│ │ ├── config.py # YAML + env var configuration
│ │ ├── api/
@@ -171,8 +171,6 @@ wled-screen-controller/
│ ├── tests/ # pytest suite
│ ├── Dockerfile
│ └── docker-compose.yml
├── custom_components/ # Home Assistant integration (HACS)
│ └── wled_screen_controller/
├── docs/
│ ├── API.md # REST API reference
│ └── CALIBRATION.md # LED calibration guide
@@ -182,7 +180,7 @@ wled-screen-controller/
## Configuration
Edit `server/config/default_config.yaml` or use environment variables with the `WLED_` prefix:
Edit `server/config/default_config.yaml` or use environment variables with the `LEDGRAB_` prefix:
```yaml
server:
@@ -200,11 +198,11 @@ storage:
logging:
format: "json"
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
max_size_mb: 100
```
Environment variable override example: `WLED_SERVER__PORT=9090`.
Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
## API
@@ -234,9 +232,7 @@ See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
## Home Assistant
Install via HACS (add as a custom repository) or manually copy `custom_components/wled_screen_controller/` into your HA config directory. The integration creates light, switch, sensor, and number entities for each configured device.
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
For Home Assistant integration, see the separate [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration) repository.
## Development
+125 -125
View File
@@ -5,96 +5,96 @@ This release brings a major expansion of integrations and source types: Home Ass
### Features
#### Home Assistant Integration
- Home Assistant integration with WebSocket connection, automation conditions, and UI ([2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde))
- HA light output targets — cast LED colors to Home Assistant lights ([cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f))
- Entity picker for HA light mapping — searchable EntitySelect for light entities ([324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308))
- HA light target live color preview — per-entity swatches via WebSocket ([40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe))
- HA source cards use health-dot indicators ([e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56))
- Home Assistant integration with WebSocket connection, automation conditions, and UI ([2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2153dde))
- HA light output targets — cast LED colors to Home Assistant lights ([cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cb9289f))
- Entity picker for HA light mapping — searchable EntitySelect for light entities ([324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/324a308))
- HA light target live color preview — per-entity swatches via WebSocket ([40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/40751fe))
- HA source cards use health-dot indicators ([e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e7c9a56))
#### Integrations & Tabs
- New **Integrations** tab and responsive icon-only tabs ([b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab))
- Multi-instance MQTT — refactored from global config to entity model ([c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c))
- Game integration system ([492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9))
- New **Integrations** tab and responsive icon-only tabs ([b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b7da4ab))
- Multi-instance MQTT — refactored from global config to entity model ([c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c59107c))
- Game integration system ([492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/492bdb9))
#### Audio
- Processed audio sources — audio filter framework ([86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34))
- 11 audio filters implemented ([eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066))
- Processed audio source model + runtime filter integration ([353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090), [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578))
- Frontend audio processing templates + source type cleanup ([5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639), [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6))
- Music sync viz modes and `auto_gain` audio filter ([b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a))
- Processed audio sources — audio filter framework ([86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/86a9d34))
- 11 audio filters implemented ([eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/eb94066))
- Processed audio source model + runtime filter integration ([353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/353c090), [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ab43578))
- Frontend audio processing templates + source type cleanup ([5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5534639), [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ce0dc6))
- Music sync viz modes and `auto_gain` audio filter ([b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b04978a))
#### Value Sources
- **BindableFloat** — universal value source binding for all scalar properties ([8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5))
- New value source types: HA entity, gradient map, strip extract ([384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c))
- `system_metrics` value source type ([b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be))
- Color value source test visualization ([f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd))
- HA value source test — raw value axis + behavior IconSelect ([0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371))
- Value source card crosslinks + gradient_map test shows input value ([4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7))
- **BindableFloat** — universal value source binding for all scalar properties ([8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8a17bb5))
- New value source types: HA entity, gradient map, strip extract ([384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/384362c))
- `system_metrics` value source type ([b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b6713be))
- Color value source test visualization ([f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6c25cd))
- HA value source test — raw value axis + behavior IconSelect ([0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0a87371))
- Value source card crosslinks + gradient_map test shows input value ([4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b7a8d7))
#### Sources & Assets
- Asset-based image/video sources, notification sounds, UI improvements ([e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107))
- `math_wave` color strip source type ([ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471))
- `api_input` LED interpolation; fixes for LED preview, FPS charts, dashboard layout ([3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85))
- Custom file drop zone for asset upload modal ([f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020))
- Asset-based image/video sources, notification sounds, UI improvements ([e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e2e1107))
- `math_wave` color strip source type ([ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ace2471))
- `api_input` LED interpolation; fixes for LED preview, FPS charts, dashboard layout ([3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3e0bf85))
- Custom file drop zone for asset upload modal ([f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f61a020))
#### UI & UX
- Card glare effect on dashboard and perf chart cards ([ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6))
- System theme option + toast timer overlap fix ([db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a))
- Donation banner, About tab, settings UI improvements ([f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc))
- Custom app icon for shortcuts and installer ([5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302))
- Card glare effect on dashboard and perf chart cards ([ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ce53ca6))
- System theme option + toast timer overlap fix ([db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/db5008a))
- Donation banner, About tab, settings UI improvements ([f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f3d07fc))
- Custom app icon for shortcuts and installer ([5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5f70302))
#### Runtime
- Port busy check before starting the server ([ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb))
- Port busy check before starting the server ([ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea812bb))
### Bug Fixes
- Tray: replace tkinter messagebox with Win32 `MessageBoxW` ([d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e))
- Launcher: `start-hidden.vbs` must be ASCII + CRLF, use `python.exe` ([fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34))
- Launcher: set `PYTHONPATH` and `WLED_CONFIG_PATH` in `start-hidden.vbs` ([e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b))
- Weather CSS card shows empty source name after hard refresh ([6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159))
- Replace HA test icon with refresh; make automation rules collapsible ([edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27))
- `pystray` is a core dependency on Windows (no longer optional extra) ([99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8))
- Audio tree structure, filter i18n, IconSelect for filter options ([af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c))
- Reference check before deleting audio processing template ([d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f))
- Device card header layout — URL badge overflow and hide button gap ([11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b))
- KC color strip test preview uses `LiveStreamManager` instead of raw engine ([a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c))
- Composite layer opacity/brightness widgets + CSS layout ([78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8))
- HA light target — brightness source, `transition=0`, dashboard type label ([381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75))
- Rename HA Lights → Home Assistant, HA Light Targets → Light Targets ([89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13))
- Command palette actions and automation condition button ([c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce))
- Show template name instead of ID in filter list and card badges ([be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b))
- Clip graph node title and subtitle to prevent overflow ([dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21))
- Replace emoji with SVG icons on weather and daylight cards ([53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8))
- Send `gradient_id` instead of palette in effect transient preview ([a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f))
- Tray: replace tkinter messagebox with Win32 `MessageBoxW` ([d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d037a2e))
- Launcher: `start-hidden.vbs` must be ASCII + CRLF, use `python.exe` ([fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fc8ee34))
- Launcher: set `PYTHONPATH` and `LEDGRAB_CONFIG_PATH` in `start-hidden.vbs` ([e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e262a8b))
- Weather CSS card shows empty source name after hard refresh ([6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e8b159))
- Replace HA test icon with refresh; make automation rules collapsible ([edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/edc6d27))
- `pystray` is a core dependency on Windows (no longer optional extra) ([99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/99460a8))
- Audio tree structure, filter i18n, IconSelect for filter options ([af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/af2c89c))
- Reference check before deleting audio processing template ([d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d04192f))
- Device card header layout — URL badge overflow and hide button gap ([11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/11d5d6b))
- KC color strip test preview uses `LiveStreamManager` instead of raw engine ([a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a9e6e8c))
- Composite layer opacity/brightness widgets + CSS layout ([78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/78ce6c8))
- HA light target — brightness source, `transition=0`, dashboard type label ([381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/381ee75))
- Rename HA Lights → Home Assistant, HA Light Targets → Light Targets ([89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/89d1b13))
- Command palette actions and automation condition button ([c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c0853ce))
- Show template name instead of ID in filter list and card badges ([be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be4c98b))
- Clip graph node title and subtitle to prevent overflow ([dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dca2d21))
- Replace emoji with SVG icons on weather and daylight cards ([53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/53986f8))
- Send `gradient_id` instead of palette in effect transient preview ([a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a4a9f6f))
---
### Development / Internal
#### Build
- Drop `packaging` dependency, inline version parsing ([d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e))
- Fix shell syntax error in `smoke_test_imports` heredoc ([feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad))
- Keep `.py` sources; smoke test skips uninstalled modules ([17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02))
- Stop stripping `zeroconf/_services`; add import smoke test ([fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a))
- Stop stripping `numpy.lib`/`linalg` from site-packages ([9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb))
- Normalize non-PEP440 versions, fix `.py`/`compileall` ordering, wipe NSIS payload dirs ([b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6))
- Drop `packaging` dependency, inline version parsing ([d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d4ffe2e))
- Fix shell syntax error in `smoke_test_imports` heredoc ([feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/feb91ad))
- Keep `.py` sources; smoke test skips uninstalled modules ([17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17c5c02))
- Stop stripping `zeroconf/_services`; add import smoke test ([fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd6776a))
- Stop stripping `numpy.lib`/`linalg` from site-packages ([9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f34ffb))
- Normalize non-PEP440 versions, fix `.py`/`compileall` ordering, wipe NSIS payload dirs ([b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b5842e6))
#### CI
- Add manual build workflow for testing artifacts ([fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e))
- Use sparse checkout for release notes in release workflow ([9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8))
- Add manual build workflow for testing artifacts ([fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fb98e6e))
- Use sparse checkout for release notes in release workflow ([9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9fcfdb8))
#### Refactoring
- Split `color-strips.ts` into focused modules under `color-strips/` folder ([7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368))
- Key colors targets → CSS source type; HA target improvements ([3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f))
- Move Weather and Home Assistant sources to Integrations tree group ([3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5))
- Split `color-strips.ts` into focused modules under `color-strips/` folder ([7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7a9c368))
- Key colors targets → CSS source type; HA target improvements ([3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3e6760f))
- Move Weather and Home Assistant sources to Integrations tree group ([3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3c2efd5))
#### Tests
- Isolate tests from production database ([992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e))
- Processed audio sources: phase 7 testing and polish + phase 8 design review ([ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484), [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5))
- Isolate tests from production database ([992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/992495e))
- Processed audio sources: phase 7 testing and polish + phase 8 design review ([ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ce1f484), [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6b0e4e5))
#### Chores
- Remove processed-audio-sources plan files ([89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8))
- Remove python3.11 version pin from pre-commit config ([f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687))
- Remove processed-audio-sources plan files ([89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/89990f8))
- Remove python3.11 version pin from pre-commit config ([f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f345687))
---
@@ -103,69 +103,69 @@ This release brings a major expansion of integrations and source types: Home Ass
| Hash | Message |
|------|---------|
| [d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e) | fix(tray): replace tkinter messagebox with Win32 MessageBoxW |
| [fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fc8ee34) | fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe |
| [e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b) | fix(launcher): set PYTHONPATH and WLED_CONFIG_PATH in start-hidden.vbs |
| [d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e) | refactor: drop packaging dependency, inline version parsing |
| [feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/feb91ad) | fix(build): fix shell syntax error in smoke_test_imports heredoc |
| [17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/17c5c02) | fix(build): keep .py sources + make smoke test skip uninstalled modules |
| [fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fd6776a) | fix(build): stop stripping zeroconf/_services + add import smoke test |
| [9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9f34ffb) | fix(build): stop stripping numpy.lib/linalg from site-packages |
| [b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b5842e6) | fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs |
| [7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/7a9c368) | refactor: split color-strips.ts into focused modules under color-strips/ folder |
| [ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce53ca6) | feat: add card glare effect to dashboard and perf chart cards |
| [b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b04978a) | feat: add music sync viz modes and auto_gain audio filter |
| [6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6e8b159) | fix: weather CSS card shows empty source name after hard refresh |
| [ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ace2471) | feat: add math_wave color strip source type |
| [edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/edc6d27) | fix: replace HA test icon with refresh, make automation rules collapsible |
| [b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab) | feat: add Integrations tab and responsive icon-only tabs |
| [99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/99460a8) | fix: make pystray a core dependency on Windows instead of optional extra |
| [89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8) | chore: remove processed-audio-sources plan files |
| [af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/af2c89c) | fix: audio tree structure, filter i18n, and IconSelect for filter options |
| [d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d04192f) | fix: add reference check before deleting audio processing template |
| [992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e) | fix: isolate tests from production database |
| [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5) | feat(processed-audio-sources): phase 8 - frontend design consistency review |
| [ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ce1f484) | feat(processed-audio-sources): phase 7 - testing and polish |
| [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6) | feat(processed-audio-sources): phase 6 - frontend source type cleanup |
| [5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5534639) | feat(processed-audio-sources): phase 5 - frontend audio processing templates |
| [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578) | feat(processed-audio-sources): phase 4 - runtime filter integration |
| [353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/353c090) | feat(processed-audio-sources): phase 3 - processed audio source model |
| [eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/eb94066) | feat(processed-audio-sources): phase 2 - implement 11 audio filters |
| [86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34) | feat(processed-audio-sources): phase 1 - audio filter framework |
| [c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c59107c) | feat: refactor MQTT from global config to multi-instance entity model |
| [e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e7c9a56) | feat: HA source cards use health-dot indicators |
| [492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/492bdb9) | feat: game integration system |
| [b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b6713be) | feat: system_metrics value source type |
| [db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/db5008a) | feat: system theme option + fix toast timer overlap |
| [4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/4b7a8d7) | feat: value source card crosslinks + gradient_map test shows input value |
| [f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f6c25cd) | feat: color value source test visualization |
| [0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/0a87371) | feat: HA value source test — raw value axis + behavior IconSelect |
| [11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/11d5d6b) | fix: device card header layout — URL badge overflow and hide button gap |
| [384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/384362c) | feat: new value source types (HA entity, gradient map, strip extract) + UI fixes |
| [ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ea812bb) | feat: check if port is busy before starting the server |
| [a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a9e6e8c) | fix: KC color strip test preview — use LiveStreamManager instead of raw engine |
| [78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/78ce6c8) | fix: composite layer opacity/brightness widgets + CSS layout |
| [8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/8a17bb5) | feat: BindableFloat — universal value source binding for all scalar properties |
| [5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/5f70302) | feat: use custom app icon for shortcuts and installer |
| [40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/40751fe) | feat: HA light target live color preview — per-entity swatches via WebSocket |
| [381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/381ee75) | fix: HA light target — brightness source, transition=0, dashboard type label |
| [3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e6760f) | refactor: key colors targets → CSS source type, HA target improvements |
| [89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89d1b13) | fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets |
| [324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/324a308) | feat: entity picker for HA light mapping — searchable EntitySelect for light entities |
| [cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/cb9289f) | feat: HA light output targets — cast LED colors to Home Assistant lights |
| [fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e) | ci: add manual build workflow for testing artifacts |
| [3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3c2efd5) | refactor: move Weather and Home Assistant sources to Integrations tree group |
| [2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/2153dde) | feat: Home Assistant integration — WebSocket connection, automation conditions, UI |
| [f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f3d07fc) | feat: donation banner, About tab, settings UI improvements |
| [f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f61a020) | feat: custom file drop zone for asset upload modal; fix review issues |
| [f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/f345687) | chore: remove python3.11 version pin from pre-commit config |
| [e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e2e1107) | feat: asset-based image/video sources, notification sounds, UI improvements |
| [c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/c0853ce) | fix: improve command palette actions and automation condition button |
| [3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/3e0bf85) | feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout |
| [be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/be4c98b) | fix: show template name instead of ID in filter list and card badges |
| [dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/dca2d21) | fix: clip graph node title and subtitle to prevent overflow |
| [53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/53986f8) | fix: replace emoji with SVG icons on weather and daylight cards |
| [a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/a4a9f6f) | fix: send gradient_id instead of palette in effect transient preview |
| [9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/9fcfdb8) | ci: use sparse checkout for release notes in release workflow |
| [d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d037a2e) | fix(tray): replace tkinter messagebox with Win32 MessageBoxW |
| [fc8ee34](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fc8ee34) | fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe |
| [e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e262a8b) | fix(launcher): set PYTHONPATH and LEDGRAB_CONFIG_PATH in start-hidden.vbs |
| [d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d4ffe2e) | refactor: drop packaging dependency, inline version parsing |
| [feb91ad](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/feb91ad) | fix(build): fix shell syntax error in smoke_test_imports heredoc |
| [17c5c02](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/17c5c02) | fix(build): keep .py sources + make smoke test skip uninstalled modules |
| [fd6776a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fd6776a) | fix(build): stop stripping zeroconf/_services + add import smoke test |
| [9f34ffb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9f34ffb) | fix(build): stop stripping numpy.lib/linalg from site-packages |
| [b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b5842e6) | fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs |
| [7a9c368](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/7a9c368) | refactor: split color-strips.ts into focused modules under color-strips/ folder |
| [ce53ca6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ce53ca6) | feat: add card glare effect to dashboard and perf chart cards |
| [b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b04978a) | feat: add music sync viz modes and auto_gain audio filter |
| [6e8b159](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6e8b159) | fix: weather CSS card shows empty source name after hard refresh |
| [ace2471](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ace2471) | feat: add math_wave color strip source type |
| [edc6d27](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/edc6d27) | fix: replace HA test icon with refresh, make automation rules collapsible |
| [b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b7da4ab) | feat: add Integrations tab and responsive icon-only tabs |
| [99460a8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/99460a8) | fix: make pystray a core dependency on Windows instead of optional extra |
| [89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/89990f8) | chore: remove processed-audio-sources plan files |
| [af2c89c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/af2c89c) | fix: audio tree structure, filter i18n, and IconSelect for filter options |
| [d04192f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/d04192f) | fix: add reference check before deleting audio processing template |
| [992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/992495e) | fix: isolate tests from production database |
| [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/6b0e4e5) | feat(processed-audio-sources): phase 8 - frontend design consistency review |
| [ce1f484](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ce1f484) | feat(processed-audio-sources): phase 7 - testing and polish |
| [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/1ce0dc6) | feat(processed-audio-sources): phase 6 - frontend source type cleanup |
| [5534639](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5534639) | feat(processed-audio-sources): phase 5 - frontend audio processing templates |
| [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ab43578) | feat(processed-audio-sources): phase 4 - runtime filter integration |
| [353c090](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/353c090) | feat(processed-audio-sources): phase 3 - processed audio source model |
| [eb94066](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/eb94066) | feat(processed-audio-sources): phase 2 - implement 11 audio filters |
| [86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/86a9d34) | feat(processed-audio-sources): phase 1 - audio filter framework |
| [c59107c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c59107c) | feat: refactor MQTT from global config to multi-instance entity model |
| [e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e7c9a56) | feat: HA source cards use health-dot indicators |
| [492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/492bdb9) | feat: game integration system |
| [b6713be](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b6713be) | feat: system_metrics value source type |
| [db5008a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/db5008a) | feat: system theme option + fix toast timer overlap |
| [4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b7a8d7) | feat: value source card crosslinks + gradient_map test shows input value |
| [f6c25cd](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f6c25cd) | feat: color value source test visualization |
| [0a87371](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/0a87371) | feat: HA value source test — raw value axis + behavior IconSelect |
| [11d5d6b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/11d5d6b) | fix: device card header layout — URL badge overflow and hide button gap |
| [384362c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/384362c) | feat: new value source types (HA entity, gradient map, strip extract) + UI fixes |
| [ea812bb](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/ea812bb) | feat: check if port is busy before starting the server |
| [a9e6e8c](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a9e6e8c) | fix: KC color strip test preview — use LiveStreamManager instead of raw engine |
| [78ce6c8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/78ce6c8) | fix: composite layer opacity/brightness widgets + CSS layout |
| [8a17bb5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/8a17bb5) | feat: BindableFloat — universal value source binding for all scalar properties |
| [5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5f70302) | feat: use custom app icon for shortcuts and installer |
| [40751fe](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/40751fe) | feat: HA light target live color preview — per-entity swatches via WebSocket |
| [381ee75](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/381ee75) | fix: HA light target — brightness source, transition=0, dashboard type label |
| [3e6760f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3e6760f) | refactor: key colors targets → CSS source type, HA target improvements |
| [89d1b13](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/89d1b13) | fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets |
| [324a308](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/324a308) | feat: entity picker for HA light mapping — searchable EntitySelect for light entities |
| [cb9289f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/cb9289f) | feat: HA light output targets — cast LED colors to Home Assistant lights |
| [fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/fb98e6e) | ci: add manual build workflow for testing artifacts |
| [3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3c2efd5) | refactor: move Weather and Home Assistant sources to Integrations tree group |
| [2153dde](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/2153dde) | feat: Home Assistant integration — WebSocket connection, automation conditions, UI |
| [f3d07fc](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f3d07fc) | feat: donation banner, About tab, settings UI improvements |
| [f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f61a020) | feat: custom file drop zone for asset upload modal; fix review issues |
| [f345687](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f345687) | chore: remove python3.11 version pin from pre-commit config |
| [e2e1107](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e2e1107) | feat: asset-based image/video sources, notification sounds, UI improvements |
| [c0853ce](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/c0853ce) | fix: improve command palette actions and automation condition button |
| [3e0bf85](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3e0bf85) | feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout |
| [be4c98b](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/be4c98b) | fix: show template name instead of ID in filter list and card badges |
| [dca2d21](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/dca2d21) | fix: clip graph node title and subtitle to prevent overflow |
| [53986f8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/53986f8) | fix: replace emoji with SVG icons on weather and daylight cards |
| [a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a4a9f6f) | fix: send gradient_id instead of palette in effect transient preview |
| [9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9fcfdb8) | ci: use sparse checkout for release notes in release workflow |
</details>
+1 -1
View File
@@ -5,7 +5,7 @@
- [ ] Rename all references to "WLED" in user-facing strings, class names, module names, config keys, file paths, and documentation
- [ ] The app is **LedGrab** — not tied to WLED specifically. WLED is just one of many supported output protocols
- [ ] Audit: i18n keys, page titles, tray labels, installer text, pyproject.toml description, README, CLAUDE.md, context files, API docs
- [ ] Rename `wled_controller` package → decide on new package name (e.g. `ledgrab`)
- [ ] Rename `ledgrab` package → decide on new package name (e.g. `ledgrab`)
- [ ] Update import paths, entry points, config references, build scripts, Docker, CI/CD
- [ ] **Migration required** if renaming storage paths or config keys (see data migration policy in CLAUDE.md)
+4 -4
View File
@@ -143,8 +143,8 @@ cleanup_site_packages() {
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
fi
# ── Remove wled_controller if pip-installed ───────────────
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true
# ── Remove ledgrab if pip-installed ───────────────
rm -rf "$sp_dir"/ledgrab* "$sp_dir"/ledgrab*.dist-info 2>/dev/null || true
local cleaned_size
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
@@ -191,7 +191,7 @@ compile_and_strip_sources() {
# ── Import smoke test ────────────────────────────────────────
#
# Verifies that every top-level dependency that wled_controller actually
# Verifies that every top-level dependency that ledgrab actually
# uses can be imported from the stripped site-packages. Catches regressions
# where cleanup_site_packages removes a submodule that turns out to be
# imported internally by the package (e.g. numpy.linalg, zeroconf._services).
@@ -200,7 +200,7 @@ compile_and_strip_sources() {
# Args:
# $1 — path to site-packages to test against
# $2 — python executable
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for wled_controller)
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for ledgrab)
smoke_test_imports() {
local sp_dir="$1"
+3 -3
View File
@@ -66,7 +66,7 @@ if ! grep -q 'Lib\\site-packages' "$PTH_FILE"; then
echo 'Lib\site-packages' >> "$PTH_FILE"
fi
# Embedded Python ._pth overrides PYTHONPATH, so we must add the app
# source directory here for wled_controller to be importable
# source directory here for ledgrab to be importable
if ! grep -q '\.\./app/src' "$PTH_FILE"; then
echo '../app/src' >> "$PTH_FILE"
fi
@@ -325,14 +325,14 @@ cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m wled_controller
"%~dp0python\pythonw.exe" -m ledgrab
LAUNCHER
# Convert launcher to Windows line endings
+7 -7
View File
@@ -58,7 +58,7 @@ if (-not $Version) {
}
if (-not $Version) {
# Parse from __init__.py
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
$initFile = Join-Path $ServerDir "src\ledgrab\__init__.py"
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
}
@@ -107,7 +107,7 @@ if ($pthContent -notmatch 'Lib\\site-packages') {
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
}
# Embedded Python ._pth overrides PYTHONPATH, so add the app source path
# directly for wled_controller to be importable
# directly for ledgrab to be importable
if ($pthContent -notmatch '\.\.[/\\]app[/\\]src') {
$pthContent = $pthContent.TrimEnd() + "`n..\app\src`n"
}
@@ -144,10 +144,10 @@ if ($LASTEXITCODE -ne 0) {
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
}
# Remove the installed wled_controller package to avoid duplication
# Remove the installed ledgrab 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
Get-ChildItem -Path $sitePackages -Filter "ledgrab*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "ledgrab*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Clean up caches and test files to reduce size
Write-Host " Cleaning up caches..."
@@ -206,14 +206,14 @@ cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
set LEDGRAB_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m wled_controller
"%~dp0python\pythonw.exe" -m ledgrab
'@
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
+2 -2
View File
@@ -83,12 +83,12 @@ 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"
export LEDGRAB_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
mkdir -p "$SCRIPT_DIR/data" "$SCRIPT_DIR/logs"
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m wled_controller.main
exec python -m ledgrab.main
LAUNCHER
sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh"
+1 -1
View File
@@ -4,7 +4,7 @@
## CSS Custom Properties (Variables)
Defined in `server/src/wled_controller/static/css/base.css`.
Defined in `server/src/ledgrab/static/css/base.css`.
**IMPORTANT:** There is NO `--accent` variable. Always use `--primary-color` for accent/brand color.
+7 -7
View File
@@ -8,10 +8,10 @@ Two independent server modes with separate configs, ports, and data directories:
| Mode | Command | Config | Port | API Key | Data |
| ---- | ------- | ------ | ---- | ------- | ---- |
| **Real** | `python -m wled_controller` | `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/` |
| **Real** | `python -m ledgrab` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m ledgrab.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Demo mode can also be triggered via the `WLED_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e WLED_DEMO=true`), or the installed app (`set WLED_DEMO=true` before `LedGrab.bat`).
Demo mode can also be triggered via the `LEDGRAB_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e LEDGRAB_DEMO=true`), or the installed app (`set LEDGRAB_DEMO=true` before `LedGrab.bat`).
Both modes can run simultaneously on different ports.
@@ -22,7 +22,7 @@ Both modes can run simultaneously on different ports.
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"
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\ledgrab\server\restart.ps1"
```
### Demo server
@@ -35,7 +35,7 @@ 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
cd server && python -m ledgrab.demo
```
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
@@ -68,13 +68,13 @@ Auto-reload is disabled (`reload=False` in `main.py`) due to watchfiles causing
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.
5. **New seed data**: Update `server/src/ledgrab/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()`
- Config flag: `server/src/ledgrab/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`
@@ -1,182 +0,0 @@
"""The LED Screen Controller integration."""
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
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
CONF_SERVER_NAME,
CONF_SERVER_URL,
CONF_API_KEY,
DEFAULT_SCAN_INTERVAL,
TARGET_TYPE_HA_LIGHT,
DATA_COORDINATOR,
DATA_EVENT_LISTENER,
)
from .coordinator import WLEDScreenControllerCoordinator
from .event_listener import EventStreamListener
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
Platform.SENSOR,
Platform.NUMBER,
Platform.SELECT,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LED Screen Controller from a config entry."""
server_name = entry.data.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = entry.data[CONF_SERVER_URL]
api_key = entry.data[CONF_API_KEY]
session = async_get_clientsession(hass)
coordinator = WLEDScreenControllerCoordinator(
hass,
session,
server_url,
api_key,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
await coordinator.async_config_entry_first_refresh()
event_listener = EventStreamListener(hass, server_url, api_key, coordinator)
await event_listener.start()
# Create device entries for each target and remove stale ones
device_registry = dr.async_get(hass)
current_identifiers: set[tuple[str, str]] = set()
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
if target_type == TARGET_TYPE_HA_LIGHT:
model = "HA Light Target"
else:
model = "LED Target"
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, target_id)},
name=info.get("name", target_id),
manufacturer=server_name,
model=model,
configuration_url=server_url,
)
current_identifiers.add((DOMAIN, target_id))
# Create a single "Scenes" device for scene preset buttons
scenes_identifier = (DOMAIN, f"{entry.entry_id}_scenes")
scene_presets = coordinator.data.get("scene_presets", []) if coordinator.data else []
if scene_presets:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={scenes_identifier},
name="Scenes",
manufacturer=server_name,
model="Scene Presets",
configuration_url=server_url,
)
current_identifiers.add(scenes_identifier)
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
if not device_entry.identifiers & current_identifiers:
_LOGGER.info("Removing stale device: %s", device_entry.name)
device_registry.async_remove_device(device_entry.id)
# Store data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
DATA_EVENT_LISTENER: event_listener,
}
# Track target and scene IDs to detect changes
known_target_ids = set(coordinator.data.get("targets", {}).keys() if coordinator.data else [])
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
def _on_coordinator_update() -> None:
"""Detect target/scene list changes and trigger reload."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data:
return
targets = coordinator.data.get("targets", {})
# Reload if target or scene list changed
current_ids = set(targets.keys())
current_scene_ids = set(p["id"] for p in coordinator.data.get("scene_presets", []))
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))
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
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
await entry_data[DATA_EVENT_LISTENER].shutdown()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
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
@@ -1,74 +0,0 @@
"""Button platform for LED Screen Controller — scene preset activation."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.button import ButtonEntity
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 scene preset buttons."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for preset in coordinator.data.get("scene_presets", []):
entities.append(
SceneActivateButton(coordinator, preset, entry.entry_id)
)
async_add_entities(entities)
class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Button that activates a scene preset."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
preset: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self._preset_id = preset["id"]
self._entry_id = entry_id
self._attr_unique_id = f"{entry_id}_scene_{preset['id']}"
self._attr_translation_key = "activate_scene"
self._attr_translation_placeholders = {"scene_name": preset["name"]}
self._attr_icon = "mdi:palette"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — all scene buttons belong to the Scenes device."""
return {"identifiers": {(DOMAIN, f"{self._entry_id}_scenes")}}
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
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."""
await self.coordinator.activate_scene(self._preset_id)
@@ -1,127 +0,0 @@
"""Config flow for LED Screen Controller integration."""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse, urlunparse
import aiohttp
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, CONF_SERVER_NAME, CONF_SERVER_URL, CONF_API_KEY, DEFAULT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
vol.Optional(CONF_API_KEY, default=""): str,
}
)
def normalize_url(url: str) -> str:
"""Normalize URL to ensure port is an integer."""
parsed = urlparse(url)
if parsed.port is not None:
netloc = parsed.hostname or "localhost"
port = int(parsed.port)
if port != (443 if parsed.scheme == "https" else 80):
netloc = f"{netloc}:{port}"
parsed = parsed._replace(netloc=netloc)
return urlunparse(parsed)
async def validate_server(
hass: HomeAssistant, server_url: str, api_key: str
) -> dict[str, Any]:
"""Validate server connectivity and API key."""
session = async_get_clientsession(hass)
timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
# Step 1: Check connectivity via health endpoint (no auth needed)
try:
async with session.get(f"{server_url}/health", timeout=timeout) as resp:
if resp.status != 200:
raise ConnectionError(f"Server returned status {resp.status}")
data = await resp.json()
version = data.get("version", "unknown")
except aiohttp.ClientError as err:
raise ConnectionError(f"Cannot connect to server: {err}") from err
# Step 2: Validate API key via authenticated endpoint (skip if no key and auth not required)
auth_required = data.get("auth_required", True)
if api_key:
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with session.get(
f"{server_url}/api/v1/output-targets",
headers=headers,
timeout=timeout,
) as resp:
if resp.status == 401:
raise PermissionError("Invalid API key")
resp.raise_for_status()
except PermissionError:
raise
except aiohttp.ClientError as err:
raise ConnectionError(f"API request failed: {err}") from err
elif auth_required:
raise PermissionError("Server requires an API key")
return {"version": version}
class WLEDScreenControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LED Screen Controller."""
VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
server_name = user_input.get(CONF_SERVER_NAME, "LED Screen Controller")
server_url = normalize_url(user_input[CONF_SERVER_URL].rstrip("/"))
api_key = user_input[CONF_API_KEY]
try:
await validate_server(self.hass, server_url, api_key)
await self.async_set_unique_id(server_url)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=server_name,
data={
CONF_SERVER_NAME: server_name,
CONF_SERVER_URL: server_url,
CONF_API_KEY: api_key,
},
)
except ConnectionError as err:
_LOGGER.error("Connection error: %s", err)
errors["base"] = "cannot_connect"
except PermissionError:
errors["base"] = "invalid_api_key"
except Exception as err:
_LOGGER.exception("Unexpected exception: %s", err)
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -1,22 +0,0 @@
"""Constants for the LED Screen Controller integration."""
DOMAIN = "wled_screen_controller"
# Configuration
CONF_SERVER_NAME = "server_name"
CONF_SERVER_URL = "server_url"
CONF_API_KEY = "api_key"
# Default values
DEFAULT_SCAN_INTERVAL = 3 # seconds
DEFAULT_TIMEOUT = 10 # seconds
WS_RECONNECT_DELAY = 5 # seconds
WS_MAX_RECONNECT_DELAY = 60 # seconds
# Target types
TARGET_TYPE_LED = "led"
TARGET_TYPE_HA_LIGHT = "ha_light"
# Data keys stored in hass.data[DOMAIN][entry_id]
DATA_COORDINATOR = "coordinator"
DATA_EVENT_LISTENER = "event_listener"
@@ -1,426 +0,0 @@
"""Data update coordinator for LED Screen Controller."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DOMAIN,
DEFAULT_TIMEOUT,
)
_LOGGER = logging.getLogger(__name__)
class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"""Class to manage fetching LED Screen Controller data."""
def __init__(
self,
hass: HomeAssistant,
session: aiohttp.ClientSession,
server_url: str,
api_key: str,
update_interval: timedelta,
) -> None:
"""Initialize the coordinator."""
self.server_url = server_url
self.session = session
self.api_key = api_key
self.server_version = "unknown"
self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from API."""
try:
async with asyncio.timeout(DEFAULT_TIMEOUT * 3):
if self.server_version == "unknown":
await self._fetch_server_version()
targets_list = await self._fetch_targets()
# Fetch state and metrics for all targets in parallel
targets_data: dict[str, dict[str, Any]] = {}
async def fetch_target_data(target: dict) -> tuple[str, dict]:
target_id = target["id"]
try:
state, metrics = await asyncio.gather(
self._fetch_target_state(target_id),
self._fetch_target_metrics(target_id),
)
except Exception as err:
_LOGGER.warning(
"Failed to fetch data for target %s: %s",
target_id,
err,
)
state = None
metrics = None
return target_id, {
"info": target,
"state": state,
"metrics": metrics,
}
results = await asyncio.gather(
*(fetch_target_data(t) for t in targets_list),
return_exceptions=True,
)
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Target fetch failed: %s", r)
continue
target_id, data = r
targets_data[target_id] = data
# Fetch devices, CSS sources, value sources, and scene presets in parallel
devices_data, css_sources, value_sources, scene_presets = await asyncio.gather(
self._fetch_devices(),
self._fetch_css_sources(),
self._fetch_value_sources(),
self._fetch_scene_presets(),
)
return {
"targets": targets_data,
"devices": devices_data,
"css_sources": css_sources,
"value_sources": value_sources,
"scene_presets": scene_presets,
"server_version": self.server_version,
}
except asyncio.TimeoutError as err:
raise UpdateFailed(f"Timeout fetching data: {err}") from err
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
async def _fetch_server_version(self) -> None:
"""Fetch server version from health endpoint."""
try:
async with self.session.get(
f"{self.server_url}/health",
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
self.server_version = data.get("version", "unknown")
except Exception as err:
_LOGGER.warning("Failed to fetch server version: %s", err)
self.server_version = "unknown"
async def _fetch_targets(self) -> list[dict[str, Any]]:
"""Fetch all output targets."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("targets", [])
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
"""Fetch target processing state."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
"""Fetch target metrics."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_devices(self) -> dict[str, dict[str, Any]]:
"""Fetch all devices with capabilities and brightness."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
devices = data.get("devices", [])
except Exception as err:
_LOGGER.warning("Failed to fetch devices: %s", err)
return {}
# 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=self._timeout,
) as resp:
if resp.status == 200:
bri_data = await resp.json()
entry["brightness"] = bri_data.get("brightness")
except Exception as err:
_LOGGER.warning(
"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
async def set_brightness(self, device_id: str, brightness: int) -> None:
"""Set brightness for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set brightness for device %s: %s %s",
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def set_color(self, device_id: str, color: list[int] | None) -> None:
"""Set or clear the static color for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set color for device %s: %s %s",
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def _fetch_css_sources(self) -> list[dict[str, Any]]:
"""Fetch all color strip sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch CSS sources: %s", err)
return []
async def _fetch_value_sources(self) -> list[dict[str, Any]]:
"""Fetch all value sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch value sources: %s", err)
return []
async def _fetch_scene_presets(self) -> list[dict[str, Any]]:
"""Fetch all scene presets."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("presets", [])
except Exception as err:
_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=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to activate scene %s: %s %s",
preset_id,
resp.status,
body,
)
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 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=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def start_processing(self, target_id: str) -> None:
"""Start processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to start target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def stop_processing(self, target_id: str) -> None:
"""Stop processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to stop target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
@@ -1,95 +0,0 @@
"""WebSocket event listener for server state change notifications."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import WS_RECONNECT_DELAY, WS_MAX_RECONNECT_DELAY
_LOGGER = logging.getLogger(__name__)
class EventStreamListener:
"""Listens to server WS endpoint for state change events.
Triggers a coordinator refresh whenever a target starts or stops processing,
so HAOS entities react near-instantly to external state changes.
"""
def __init__(
self,
hass: HomeAssistant,
server_url: str,
api_key: str,
coordinator: DataUpdateCoordinator,
) -> None:
self._hass = hass
self._server_url = server_url
self._api_key = api_key
self._coordinator = coordinator
self._task: asyncio.Task | None = None
self._shutting_down = False
async def start(self) -> None:
"""Start listening to the event stream."""
self._task = self._hass.async_create_background_task(
self._ws_loop(),
"wled_screen_controller_events",
)
async def _ws_loop(self) -> None:
"""WebSocket connection loop with reconnection."""
delay = WS_RECONNECT_DELAY
session = async_get_clientsession(self._hass)
ws_base = self._server_url.replace("http://", "ws://").replace(
"https://", "wss://"
)
url = f"{ws_base}/api/v1/events/ws?token={self._api_key}"
while not self._shutting_down:
try:
async with session.ws_connect(url) as ws:
delay = WS_RECONNECT_DELAY # reset on successful connect
_LOGGER.debug("Event stream connected")
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
continue
if data.get("type") == "state_change":
await self._coordinator.async_request_refresh()
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
break
except asyncio.CancelledError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as err:
_LOGGER.debug("Event stream connection error: %s", err)
except Exception as err:
_LOGGER.error("Unexpected event stream error: %s", err)
if self._shutting_down:
break
await asyncio.sleep(delay)
delay = min(delay * 2, WS_MAX_RECONNECT_DELAY)
async def shutdown(self) -> None:
"""Stop listening."""
self._shutting_down = True
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
@@ -1,151 +0,0 @@
"""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]
@@ -1,12 +0,0 @@
{
"domain": "wled_screen_controller",
"name": "LED Screen Controller",
"codeowners": ["@alexeidolgolyov"],
"config_flow": true,
"dependencies": [],
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed",
"iot_class": "local_push",
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues",
"requirements": ["aiohttp>=3.9.0"],
"version": "0.2.0"
}
@@ -1,233 +0,0 @@
"""Number platform for LED Screen Controller (device brightness & HA light settings)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
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, TARGET_TYPE_HA_LIGHT
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 number entities."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[NumberEntity] = []
if coordinator.data and "targets" in coordinator.data:
devices = coordinator.data.get("devices") or {}
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
if target_type == TARGET_TYPE_HA_LIGHT:
# HA Light target — expose tunable settings
entities.append(HALightUpdateRate(coordinator, target_id, entry.entry_id))
entities.append(HALightTransition(coordinator, target_id, entry.entry_id))
entities.append(HALightMinBrightness(coordinator, target_id, entry.entry_id))
entities.append(HALightColorTolerance(coordinator, target_id, entry.entry_id))
continue
# LED target — brightness lives on the device
device_id = info.get("device_id", "")
if not device_id:
continue
device_data = devices.get(device_id)
if not device_data:
continue
capabilities = device_data.get("info", {}).get("capabilities") or []
if "brightness_control" not in capabilities or "static_color" in capabilities:
continue
entities.append(
WLEDScreenControllerBrightness(
coordinator,
target_id,
device_id,
entry.entry_id,
)
)
async_add_entities(entities)
class WLEDScreenControllerBrightness(CoordinatorEntity, NumberEntity):
"""Brightness control for an LED device associated with a target."""
_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
device_id: str,
entry_id: str,
) -> None:
"""Initialize the brightness number."""
super().__init__(coordinator)
self._target_id = target_id
self._device_id = device_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness"
self._attr_translation_key = "brightness"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the current brightness value."""
if not self.coordinator.data:
return None
device_data = self.coordinator.data.get("devices", {}).get(self._device_id)
if not device_data:
return None
return device_data.get("brightness")
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.data:
return False
targets = self.coordinator.data.get("targets", {})
devices = self.coordinator.data.get("devices", {})
return self._target_id in targets and self._device_id in devices
async def async_set_native_value(self, value: float) -> None:
"""Set brightness value."""
await self.coordinator.set_brightness(self._device_id, int(value))
# --- HA Light target number entities ---
class _HALightNumberBase(CoordinatorEntity, NumberEntity):
"""Base class for HA Light target number entities."""
_attr_has_entity_name = True
_attr_mode = NumberMode.SLIDER
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
*,
field_name: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._field_name = field_name
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
target_data = self._get_target_data()
if not target_data:
return None
return target_data.get("info", {}).get(self._field_name)
@property
def available(self) -> bool:
return self._get_target_data() is not None
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.update_target(self._target_id, **{self._field_name: round(value, 2)})
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class HALightUpdateRate(_HALightNumberBase):
"""Update rate (Hz) for an HA Light target."""
_attr_native_min_value = 0.5
_attr_native_max_value = 5.0
_attr_native_step = 0.5
_attr_native_unit_of_measurement = "Hz"
_attr_icon = "mdi:update"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="update_rate")
self._attr_unique_id = f"{target_id}_update_rate"
self._attr_translation_key = "ha_light_update_rate"
class HALightTransition(_HALightNumberBase):
"""Transition time (seconds) for an HA Light target."""
_attr_native_min_value = 0.0
_attr_native_max_value = 10.0
_attr_native_step = 0.1
_attr_native_unit_of_measurement = "s"
_attr_icon = "mdi:transition-masked"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="transition")
self._attr_unique_id = f"{target_id}_transition"
self._attr_translation_key = "ha_light_transition"
class HALightMinBrightness(_HALightNumberBase):
"""Minimum brightness threshold for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 255
_attr_native_step = 1
_attr_icon = "mdi:brightness-4"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="min_brightness_threshold")
self._attr_unique_id = f"{target_id}_min_brightness"
self._attr_translation_key = "ha_light_min_brightness"
class HALightColorTolerance(_HALightNumberBase):
"""Color tolerance (RGB delta skip threshold) for an HA Light target."""
_attr_native_min_value = 0
_attr_native_max_value = 50
_attr_native_step = 1
_attr_icon = "mdi:palette-outline"
def __init__(
self, coordinator: WLEDScreenControllerCoordinator, target_id: str, entry_id: str
) -> None:
super().__init__(coordinator, target_id, entry_id, field_name="color_tolerance")
self._attr_unique_id = f"{target_id}_color_tolerance"
self._attr_translation_key = "ha_light_color_tolerance"
@@ -1,178 +0,0 @@
"""Select platform for LED Screen Controller (CSS source & brightness source)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity
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, TARGET_TYPE_HA_LIGHT
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
NONE_OPTION = "\u2014 None \u2014"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller select entities."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[SelectEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
target_type = info.get("target_type", "led")
# Both LED and HA Light targets have a CSS source
entities.append(CSSSourceSelect(coordinator, target_id, entry.entry_id))
# Only LED targets have a brightness value source
if target_type != TARGET_TYPE_HA_LIGHT:
entities.append(BrightnessSourceSelect(coordinator, target_id, entry.entry_id))
async_add_entities(entities)
class CSSSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a color strip source for a target."""
_attr_has_entity_name = True
_attr_icon = "mdi:palette"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_css_source"
self._attr_translation_key = "color_strip_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return []
sources = self.coordinator.data.get("css_sources") or []
return [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
current_id = target_data["info"].get("color_strip_source_id", "")
sources = self.coordinator.data.get("css_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return None
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
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_map().get(option)
if source_id is None:
_LOGGER.error("CSS source not found: %s", option)
return
await self.coordinator.update_target(self._target_id, color_strip_source_id=source_id)
def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or []
return {s["name"]: s["id"] for s in sources}
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
"""Select entity for choosing a brightness value source for an LED target."""
_attr_has_entity_name = True
_attr_icon = "mdi:brightness-auto"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_brightness_source"
self._attr_translation_key = "brightness_source"
@property
def device_info(self) -> dict[str, Any]:
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def options(self) -> list[str]:
if not self.coordinator.data:
return [NONE_OPTION]
sources = self.coordinator.data.get("value_sources") or []
return [NONE_OPTION] + [s["name"] for s in sources]
@property
def current_option(self) -> str | None:
if not self.coordinator.data:
return None
target_data = self.coordinator.data.get("targets", {}).get(self._target_id)
if not target_data:
return None
# BindableFloat: brightness is either a plain float or {"value": float, "source_id": str}
brightness = target_data["info"].get("brightness", "")
if isinstance(brightness, dict):
current_id = brightness.get("source_id", "")
else:
current_id = target_data["info"].get("brightness_value_source_id", "")
if not current_id:
return NONE_OPTION
sources = self.coordinator.data.get("value_sources") or []
for s in sources:
if s["id"] == current_id:
return s["name"]
return NONE_OPTION
@property
def available(self) -> bool:
if not self.coordinator.data:
return False
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
if option == NONE_OPTION:
source_id = ""
else:
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": 1.0, "source_id": source_id} if source_id else 1.0,
)
@@ -1,225 +0,0 @@
"""Sensor platform for LED Screen Controller."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
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,
TARGET_TYPE_HA_LIGHT,
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 sensors."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities: list[SensorEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(WLEDScreenControllerFPSSensor(coordinator, target_id, entry.entry_id))
entities.append(
WLEDScreenControllerStatusSensor(coordinator, target_id, entry.entry_id)
)
# Add mapped lights sensor for HA Light targets
info = target_data["info"]
if info.get("target_type") == TARGET_TYPE_HA_LIGHT:
entities.append(HALightMappedLightsSensor(coordinator, target_id, entry.entry_id))
async_add_entities(entities)
class WLEDScreenControllerFPSSensor(CoordinatorEntity, SensorEntity):
"""FPS sensor for a LED Screen Controller target."""
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = "FPS"
_attr_icon = "mdi:speedometer"
_attr_suggested_display_precision = 1
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_fps"
self._attr_translation_key = "fps"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> float | None:
"""Return the FPS value."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return None
state = target_data["state"]
if not state.get("processing"):
return None
return state.get("fps_actual")
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional attributes."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return {}
return {"fps_target": target_data["state"].get("fps_target")}
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class WLEDScreenControllerStatusSensor(CoordinatorEntity, SensorEntity):
"""Status sensor for a LED Screen Controller target."""
_attr_has_entity_name = True
_attr_icon = "mdi:information-outline"
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = ["processing", "idle", "error", "unavailable"]
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_status"
self._attr_translation_key = "status"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> str:
"""Return the status."""
target_data = self._get_target_data()
if not target_data:
return "unavailable"
state = target_data.get("state")
if not state:
return "unavailable"
if state.get("processing"):
errors = state.get("errors", [])
if errors:
return "error"
return "processing"
return "idle"
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
class HALightMappedLightsSensor(CoordinatorEntity, SensorEntity):
"""Sensor showing the number of mapped HA lights for an HA Light target."""
_attr_has_entity_name = True
_attr_icon = "mdi:lightbulb-group"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_mapped_lights"
self._attr_translation_key = "mapped_lights"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def native_value(self) -> int | None:
"""Return the number of mapped lights."""
target_data = self._get_target_data()
if not target_data:
return None
mappings = target_data.get("info", {}).get("light_mappings", [])
return len(mappings)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return light mapping details as attributes."""
target_data = self._get_target_data()
if not target_data:
return {}
mappings = target_data.get("info", {}).get("light_mappings", [])
entity_ids = [m.get("entity_id", "") for m in mappings]
return {
"entity_ids": entity_ids,
"mappings": [
{
"entity_id": m.get("entity_id", ""),
"led_start": m.get("led_start", 0),
"led_end": m.get("led_end", -1),
"brightness_scale": m.get("brightness_scale", 1.0),
}
for m in mappings
],
}
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
def _get_target_data(self) -> dict[str, Any] | None:
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
@@ -1,19 +0,0 @@
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:
@@ -1,103 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
}
},
"error": {
"cannot_connect": "Failed to connect to server.",
"invalid_api_key": "Invalid API key.",
"unknown": "Unexpected error occurred."
},
"abort": {
"already_configured": "This server is already configured."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Status",
"state": {
"processing": "Processing",
"idle": "Idle",
"error": "Error",
"unavailable": "Unavailable"
}
},
"mapped_lights": {
"name": "Mapped Lights"
}
},
"number": {
"brightness": {
"name": "Brightness"
},
"ha_light_update_rate": {
"name": "Update Rate"
},
"ha_light_transition": {
"name": "Transition"
},
"ha_light_min_brightness": {
"name": "Min Brightness"
},
"ha_light_color_tolerance": {
"name": "Color Tolerance"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"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."
}
}
}
}
}
@@ -1,109 +0,0 @@
"""Switch platform for LED Screen Controller."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity
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 switches."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(
WLEDScreenControllerSwitch(coordinator, target_id, entry.entry_id)
)
async_add_entities(entities)
class WLEDScreenControllerSwitch(CoordinatorEntity, SwitchEntity):
"""Representation of a LED Screen Controller target processing switch."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
target_id: str,
entry_id: str,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._target_id = target_id
self._entry_id = entry_id
self._attr_unique_id = f"{target_id}_processing"
self._attr_translation_key = "processing"
self._attr_icon = "mdi:television-ambient-light"
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {"identifiers": {(DOMAIN, self._target_id)}}
@property
def is_on(self) -> bool:
"""Return true if processing is active."""
target_data = self._get_target_data()
if not target_data or not target_data.get("state"):
return False
return target_data["state"].get("processing", False)
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._get_target_data() is not None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
target_data = self._get_target_data()
if not target_data:
return {}
attrs: dict[str, Any] = {"target_id": self._target_id}
state = target_data.get("state") or {}
metrics = target_data.get("metrics") or {}
if state:
attrs["fps_target"] = state.get("fps_target")
attrs["fps_actual"] = state.get("fps_actual")
if metrics:
attrs["frames_processed"] = metrics.get("frames_processed")
attrs["errors_count"] = metrics.get("errors_count")
attrs["uptime_seconds"] = metrics.get("uptime_seconds")
return attrs
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start processing."""
await self.coordinator.start_processing(self._target_id)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop processing."""
await self.coordinator.stop_processing(self._target_id)
def _get_target_data(self) -> dict[str, Any] | None:
"""Get target data from coordinator."""
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
@@ -1,87 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up LED Screen Controller",
"description": "Enter the URL and API key for your LED Screen Controller server.",
"data": {
"server_name": "Server Name",
"server_url": "Server URL",
"api_key": "API Key"
},
"data_description": {
"server_name": "Display name for this server in Home Assistant",
"server_url": "URL of your LED Screen Controller server (e.g., http://192.168.1.100:8080)",
"api_key": "API key from your server's configuration file"
}
}
},
"error": {
"cannot_connect": "Failed to connect to server.",
"invalid_api_key": "Invalid API key.",
"unknown": "Unexpected error occurred."
},
"abort": {
"already_configured": "This server is already configured."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Status",
"state": {
"processing": "Processing",
"idle": "Idle",
"error": "Error",
"unavailable": "Unavailable"
}
},
"mapped_lights": {
"name": "Mapped Lights"
}
},
"number": {
"brightness": {
"name": "Brightness"
},
"ha_light_update_rate": {
"name": "Update Rate"
},
"ha_light_transition": {
"name": "Transition"
},
"ha_light_min_brightness": {
"name": "Min Brightness"
},
"ha_light_color_tolerance": {
"name": "Color Tolerance"
}
},
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
}
}
@@ -1,87 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Настройка LED Screen Controller",
"description": "Введите URL и API-ключ вашего сервера LED Screen Controller.",
"data": {
"server_name": "Имя сервера",
"server_url": "URL сервера",
"api_key": "API-ключ"
},
"data_description": {
"server_name": "Отображаемое имя сервера в Home Assistant",
"server_url": "URL сервера LED Screen Controller (например, http://192.168.1.100:8080)",
"api_key": "API-ключ из конфигурационного файла сервера"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу.",
"invalid_api_key": "Неверный API-ключ.",
"unknown": "Произошла непредвиденная ошибка."
},
"abort": {
"already_configured": "Этот сервер уже настроен."
}
},
"entity": {
"button": {
"activate_scene": {
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": {
"processing": {
"name": "Обработка"
}
},
"sensor": {
"fps": {
"name": "FPS"
},
"status": {
"name": "Статус",
"state": {
"processing": "Обработка",
"idle": "Ожидание",
"error": "Ошибка",
"unavailable": "Недоступен"
}
},
"mapped_lights": {
"name": "Привязанные светильники"
}
},
"number": {
"brightness": {
"name": "Яркость"
},
"ha_light_update_rate": {
"name": "Частота обновления"
},
"ha_light_transition": {
"name": "Переход"
},
"ha_light_min_brightness": {
"name": "Мин. яркость"
},
"ha_light_color_tolerance": {
"name": "Допуск цвета"
}
},
"select": {
"color_strip_source": {
"name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
}
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# WLED Screen Controller API Documentation
# LedGrab API Documentation
Complete REST API reference for the WLED Screen Controller server.
Complete REST API reference for the LedGrab server.
**Base URL:** `http://localhost:8080`
**API Version:** v1
-6
View File
@@ -1,6 +0,0 @@
{
"name": "WLED Screen Controller",
"render_readme": true,
"country": ["US"],
"homeassistant": "2023.1.0"
}
+7 -7
View File
@@ -30,8 +30,8 @@ SetCompressor /SOLID lzma
; ── Modern UI Configuration ─────────────────────────────────
!define MUI_ICON "server\src\wled_controller\static\icons\icon.ico"
!define MUI_UNICON "server\src\wled_controller\static\icons\icon.ico"
!define MUI_ICON "server\src\ledgrab\static\icons\icon.ico"
!define MUI_UNICON "server\src\ledgrab\static\icons\icon.ico"
!define MUI_ABORTWARNING
; ── Pages ───────────────────────────────────────────────────
@@ -116,7 +116,7 @@ Section "!${APPNAME} (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
; Registry: install location + Add/Remove Programs entry
@@ -132,11 +132,11 @@ Section "!${APPNAME} (required)" SecCore
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayIcon" "$INSTDIR\app\src\wled_controller\static\icons\icon.ico"
"DisplayIcon" "$INSTDIR\app\src\ledgrab\static\icons\icon.ico"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
@@ -152,13 +152,13 @@ SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\wled_controller\static\icons\icon.ico" 0
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
; ── Section Descriptions ────────────────────────────────────
-109
View File
@@ -1,109 +0,0 @@
# math_wave Color Strip Source — Implementation Plan
## Overview
A new CSS type that generates LED colors from configurable mathematical wave functions. Each LED position gets a wave value based on spatial position and time, mapped to a color via a gradient palette. Supports multiple superimposed wave layers, sync clocks, and bindable parameters.
## Requirements
- Waveform types: sine, triangle, sawtooth, square
- Parameters per wave layer: waveform, frequency, amplitude, phase, offset
- Global parameters: speed (bindable), gradient_id (color mapping)
- Wave superposition: list of wave layers combined additively
- Spatial dimension: wave value depends on LED position (0.0-1.0) + time
- Sync clock integration for time parameter
- Color mapping: combined wave output (0.0-1.0) mapped through a gradient
## Phase 1: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MathWaveColorStripSource` dataclass after `GameEventColorStripSource`
- Fields:
- `waves: list` — default `[{"waveform": "sine", "frequency": 1.0, "amplitude": 1.0, "phase": 0.0, "offset": 0.0}]`
- `speed: BindableFloat` — default 1.0
- `gradient_id: Optional[str]` — references Gradient entity
- Implement `to_dict`, `from_dict`, `create_from_kwargs`, `apply_update` (follow `CandlelightColorStripSource` pattern)
- Valid waveforms: `{"sine", "triangle", "sawtooth", "square"}`
- Add `"math_wave": MathWaveColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 2: Stream Implementation
**File: `server/src/wled_controller/core/processing/math_wave_stream.py`** (new)
- Class `MathWaveColorStripStream(ColorStripStream)` following `CandlelightColorStripStream` pattern
- Key methods: `__init__`, `_update_from_source`, `configure`, `start`, `stop`, `get_latest_colors`, `update_source`, `set_clock`, `set_gradient_store`
- Animation loop (`_animate_loop`):
- Get `t` from clock (if set) or wall clock
- For each LED at normalized position `p = i / (N-1)`:
- Sum all wave layers: `sum += amplitude * waveform(2*pi*frequency*(p + speed*t) + phase) + offset`
- Clamp result to [0.0, 1.0]
- Map per-LED values to RGB via gradient LUT
- Waveform functions (vectorized with numpy):
- `sine`: `0.5 + 0.5 * np.sin(x)`
- `triangle`: `2.0 * np.abs(np.mod(x / (2*pi), 1.0) - 0.5)`
- `sawtooth`: `np.mod(x / (2*pi), 1.0)`
- `square`: `(np.sin(x) >= 0).astype(float)`
- Double-buffering pattern (same as candlelight)
- Use `self.resolve("speed", self._speed)` for bindable speed
- Gradient resolution via `set_gradient_store` pattern
## Phase 3: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MathWaveCSSResponse`, `MathWaveCSSCreate`, `MathWaveCSSUpdate`
- Add to `ColorStripSourceResponse`, `ColorStripSourceCreate`, `ColorStripSourceUpdate` unions
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Import `MathWaveColorStripStream`
- Add `"math_wave": MathWaveColorStripStream` to `_SIMPLE_STREAM_MAP`
- Existing `set_gradient_store` and `_inject_clock` injection handles it automatically
## Phase 4: Frontend
**File: `server/src/wled_controller/static/js/types.ts`**
- Add `'math_wave'` to `CSSSourceType` union
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `math_wave: _svg(P.activity)` to `_colorStripTypeIcons`
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<option value="math_wave">` to type select
- Add `<div id="css-editor-math-wave-section">` with:
- Gradient picker (EntitySelect)
- Speed (BindableScalarWidget)
- Wave layers list (dynamic rows: waveform IconSelect, frequency/amplitude/phase/offset inputs, add/remove buttons)
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Add to `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Add to `clockTypes` array in `saveCSSEditor`
- Add type handler: `load(css)`, `reset()`, `getPayload(name)`
- Add card renderer showing wave count, gradient swatch, speed, clock badge
- Add wave layer management: `_renderMathWaveRow`, `addMathWaveLayer`, `removeMathWaveLayer`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type name, description, all field labels, waveform names
## Phase 5: Testing
**File: `server/tests/core/test_math_wave_stream.py`** (new)
- Test wave functions produce expected values at known inputs
- Test single wave spatial pattern
- Test wave superposition
- Test gradient color mapping
- Test clock integration
- Test `update_source` hot-update
- Test `configure` auto-sizing
**File: `server/tests/e2e/test_color_strip_flow.py`**
- Add `test_math_wave_crud` to lifecycle tests
**Storage model tests:**
- Test `from_dict` roundtrip
- Test `create_from_kwargs` with valid/invalid waveforms
- Test `apply_update`
- Test `_SOURCE_TYPE_MAP` dispatch
## Risks & Mitigations
- **Wave layer UI complexity** — Follow existing composite layers / game event mappings patterns
- **Performance with many layers** — Vectorize with numpy; cap max wave layers to 8
- **Gradient resolution** — Stream manager already injects `set_gradient_store` automatically
-151
View File
@@ -1,151 +0,0 @@
# music_sync Color Strip Source — Implementation Plan
## Overview
A higher-level music-reactive CSS that provides semantic audio analysis (BPM detection, beat tracking, energy envelope, drop detection, frequency band energy) and multiple visualization modes. Builds on existing `AudioCaptureManager` and `AudioAnalysis` infrastructure. No new external dependencies — uses numpy-only analysis.
## Requirements
- BPM estimation from real-time audio
- Beat onset detection with configurable threshold
- Smoothed RMS energy envelope with attack/release
- Drop detection: energy drops/buildups
- Frequency band energy: bass (20-250 Hz), mid (250-4k Hz), treble (4k-20k Hz)
- Four visualization modes: `pulse_on_beat`, `energy_gradient`, `spectrum_bands`, `strobe_on_drop`
- Uses existing audio engine infrastructure (no new audio capture code)
- No external dependencies beyond numpy
## Phase 1: Music Analysis Engine
**File: `server/src/wled_controller/core/audio/music_analyzer.py`** (new)
### 1.1 `MusicFeatures` dataclass (frozen=True)
- `bpm: float` — estimated BPM (0 if unknown)
- `beat: bool` — beat detected this frame
- `beat_intensity: float` — 0.0-1.0
- `beat_phase: float` — 0.0-1.0 position in beat cycle
- `energy: float` — smoothed RMS 0.0-1.0
- `energy_delta: float` — rate of change
- `bass_energy, mid_energy, treble_energy: float` — 0.0-1.0
- `drop_state: str` — "idle"|"buildup"|"drop"|"recovery"
- `drop_intensity: float` — 0.0-1.0
### 1.2 `MusicAnalyzer` class
- **State**: rolling energy buffer (~4s at ~43 Hz = ~172 samples), beat history timestamps (last 30), smoothed band energies, BPM estimate, drop state machine
- **`update(analysis: AudioAnalysis) -> MusicFeatures`**: main entry point
- **BPM estimation**: Track beat timestamps, compute median inter-beat interval, exponential smoothing, clamp 40-220 BPM
- **Beat tracking**: Pass through `AudioAnalysis.beat` + compute `beat_phase` (position in current beat cycle)
- **Energy envelope**: Smoothed RMS with configurable attack/release
- **Drop detection**: State machine: `idle -> buildup` (energy rising steadily 1-2s), `buildup -> drop` (energy drops >50% within 100ms), `drop -> recovery` (after 500ms), `recovery -> idle`
- **Frequency bands**: Sum spectrum bins into 3 bands from 64-band spectrum
## Phase 2: Storage Model
**File: `server/src/wled_controller/storage/color_strip_source.py`**
- Add `MusicSyncColorStripSource` dataclass after `AudioColorStripSource`
- Fields:
- `visualization_mode: str` — default `"pulse_on_beat"`
- `audio_source_id: str` — references AudioSource
- `sensitivity: BindableFloat` — default 1.0
- `smoothing: BindableFloat` — default 0.3
- `palette: str` — default `"rainbow"`
- `gradient_id: Optional[str]`
- `color: BindableColor` — primary color
- `color_secondary: BindableColor` — for two-color modes
- `beat_decay: BindableFloat` — default 0.15
- `led_count: int` — 0 = auto-size
- `mirror: bool`
- Add `"music_sync": MusicSyncColorStripSource` to `_SOURCE_TYPE_MAP`
## Phase 3: Stream Implementation
**File: `server/src/wled_controller/core/processing/music_sync_stream.py`** (new)
- Class `MusicSyncColorStripStream(ColorStripStream)` following `AudioColorStripStream` pattern
- Constructor: accept source + audio_capture_manager + stores. Create `MusicAnalyzer` instance
- `start()`: Acquire audio stream, start background thread
- `stop()`: Release audio stream, stop thread
- `_animate_loop()`:
1. Get `AudioAnalysis` from audio stream
2. Apply audio filter pipeline (if any)
3. Feed to `MusicAnalyzer.update()``MusicFeatures`
4. Dispatch to visualization renderer
5. Double-buffer output
### Visualization Renderers
- **`pulse_on_beat`**: Full-strip flash on beat with exponential decay. Between beats: sine-wave pulsing synced to BPM. Color from palette indexed by beat_intensity.
- **`energy_gradient`**: Maps bass→warm, treble→cool. Overall brightness from energy. Gradient scrolls with beat_phase.
- **`spectrum_bands`**: 3 zones (bass/mid/treble), each fills proportionally to band energy. Mirror mode: bass center, treble edges.
- **`strobe_on_drop`**: Idle=gentle breathing. Buildup=increasing pulse. Drop=rapid strobe at 10 Hz. Recovery=fade back.
## Phase 4: API Integration
**File: `server/src/wled_controller/api/schemas/color_strip_sources.py`**
- Add `MusicSyncCSSResponse`, `MusicSyncCSSCreate`, `MusicSyncCSSUpdate`
- Add to all three union types
**File: `server/src/wled_controller/api/routes/color_strip_sources.py`**
- Import + add to `_RESPONSE_MAP`
**File: `server/src/wled_controller/core/processing/color_strip_stream_manager.py`**
- Add `music_sync` branch in `acquire()` (same pattern as `audio` branch)
- Update `refresh_audio_filter_pipelines()` to include `MusicSyncColorStripStream`
## Phase 5: Frontend
**File: `server/src/wled_controller/static/js/core/icons.ts`**
- Add `music_sync: _svg(P.radio)` icon
**File: `server/src/wled_controller/templates/modals/css-editor.html`**
- Add `<div id="css-editor-music-sync-section">` with:
- Visualization mode selector (IconSelect)
- Audio source dropdown
- Sensitivity, Smoothing, Beat Decay (BindableScalarWidget containers)
- Palette/gradient selector (EntitySelect)
- Primary + Secondary color (BindableColorWidget)
- Mirror checkbox
**File: `server/src/wled_controller/static/js/features/color-strips-music-sync.ts`** (new, extracted)
- Editor logic, widget factories, card renderer
**File: `server/src/wled_controller/static/js/features/color-strips.ts`**
- Register type in `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, `NON_PICTURE_TYPES`
- Import and register from `color-strips-music-sync.ts`
**Files: `en.json`, `ru.json`, `zh.json`**
- Add i18n keys for type, visualization modes, all field labels
## Phase 6: Testing
**File: `server/tests/core/audio/test_music_analyzer.py`** (new)
- BPM estimation from regular beats (within 5 BPM accuracy)
- BPM handles no beats gracefully
- Beat phase progression
- Energy envelope attack/release
- Frequency band splitting
- Drop detection state machine transitions
- No false drops on steady signal
**File: `server/tests/core/processing/test_music_sync_stream.py`** (new)
- Stream lifecycle (start/stop)
- Produces valid colors
- Hot-update parameters
- Auto-size from device
- All 4 visualization modes produce valid (n,3) uint8 arrays
- Mirror mode symmetry
**Storage + API tests:**
- `from_dict` roundtrip
- CRUD via test client
## Risks & Mitigations
- **BPM accuracy** — Median-of-recent-IBIs is robust for dance/electronic. For ambient, BPM noisy but `energy_gradient` and `spectrum_bands` don't depend on BPM
- **Drop detection false positives** — Require minimum energy threshold + sustained increase before buildup state
- **Frontend file size** — Extract to `color-strips-music-sync.ts` (following composite/notification pattern)
- **Strobe photosensitivity** — Cap at 10 Hz (below 15-25 Hz danger zone), add UI warning
- **Thread safety** — `MusicAnalyzer` owned exclusively by one stream thread, no shared access
## Dependency Order
`math_wave` should be implemented first (simpler, no audio dependency), then `music_sync`.
+22 -22
View File
@@ -1,41 +1,41 @@
# WLED Screen Controller — Environment Variables
# LedGrab — 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.
# All variables use the LEDGRAB_ 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
# LEDGRAB_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
# LEDGRAB_SERVER__PORT=8080 # Listen port (default: 8080)
# LEDGRAB_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
# LEDGRAB_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"}
# LEDGRAB_AUTH__API_KEYS={"dev": "development-key-change-in-production"}
# ── Storage ────────────────────────────────────────────
# All data is stored in a single SQLite database.
# WLED_STORAGE__DATABASE_FILE=data/ledgrab.db
# LEDGRAB_STORAGE__DATABASE_FILE=data/ledgrab.db
# ── 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
# LEDGRAB_MQTT__ENABLED=false
# LEDGRAB_MQTT__BROKER_HOST=localhost
# LEDGRAB_MQTT__BROKER_PORT=1883
# LEDGRAB_MQTT__USERNAME=
# LEDGRAB_MQTT__PASSWORD=
# LEDGRAB_MQTT__CLIENT_ID=ledgrab
# LEDGRAB_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
# LEDGRAB_LOGGING__FORMAT=json # json or text (default: json)
# LEDGRAB_LOGGING__FILE=logs/wled_controller.log
# LEDGRAB_LOGGING__MAX_SIZE_MB=100
# LEDGRAB_LOGGING__BACKUP_COUNT=5
# ── Demo mode ───────────────────────────────────────────
# WLED_DEMO=false # Enable demo mode (uses data/demo/ directory)
# LEDGRAB_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)
# LEDGRAB_CONFIG_PATH= # Absolute path to a YAML config file (overrides all above)
# ── Docker Compose extras (not part of WLED_ prefix) ───
# ── Docker Compose extras (not part of LEDGRAB_ prefix) ───
# DISPLAY=:0 # X11 display for Linux screen capture
+10 -10
View File
@@ -1,15 +1,15 @@
# Claude Instructions for WLED Screen Controller Server
# Claude Instructions for LedGrab Server
## Project Structure
- `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, SSRF validation, sound playback)
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
- `src/wled_controller/templates/` — Jinja2 HTML templates
- `src/ledgrab/main.py` — FastAPI application entry point
- `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
- `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/ledgrab/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/ledgrab/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/ledgrab/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/ledgrab/static/` — Frontend files (TypeScript, CSS, locales)
- `src/ledgrab/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML)
- `data/` — Runtime data (JSON stores, persisted state)
@@ -22,7 +22,7 @@ Each entity follows: dataclass model (`storage/`) + JSON store (`storage/*_store
Server uses API key authentication via Bearer token in `Authorization` header.
- Config: `config/default_config.yaml` under `auth.api_keys`
- Env var: `WLED_AUTH__API_KEYS`
- Env var: `LEDGRAB_AUTH__API_KEYS`
- When `api_keys` is empty (default), auth is disabled — all endpoints are open
- To enable auth, add key entries (e.g. `dev: "your-secret-key"`)
+7 -7
View File
@@ -4,7 +4,7 @@ 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/
COPY src/ledgrab/static/ ./src/ledgrab/static/
RUN npm run build
## Stage 2: Python application
@@ -16,8 +16,8 @@ LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
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="${APP_VERSION}"
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.url="https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
@@ -37,16 +37,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# The real source is copied afterward, keeping the dep layer cached.
COPY pyproject.toml .
RUN sed -i "s/^version = .*/version = \"${APP_VERSION}\"/" pyproject.toml \
&& mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
&& mkdir -p src/ledgrab && touch src/ledgrab/__init__.py \
&& pip install --no-cache-dir ".[notifications]" \
&& rm -rf src/wled_controller
&& rm -rf src/ledgrab
# Copy source code and config (invalidates cache only when source changes)
COPY src/ ./src/
COPY config/ ./config/
# Copy built frontend bundle from stage 1
COPY --from=frontend /build/src/wled_controller/static/dist/ ./src/wled_controller/static/dist/
COPY --from=frontend /build/src/ledgrab/static/dist/ ./src/ledgrab/static/dist/
# Create non-root user for security
RUN groupadd --gid 1000 ledgrab \
@@ -67,4 +67,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
ENV PYTHONPATH=/app/src
# Run the application
CMD ["uvicorn", "wled_controller.main:app", "--host", "0.0.0.0", "--port", "8080"]
CMD ["uvicorn", "ledgrab.main:app", "--host", "0.0.0.0", "--port", "8080"]
+11 -11
View File
@@ -1,4 +1,4 @@
# WLED Screen Controller - Server
# LedGrab - Server
High-performance FastAPI server that captures screen content and controls WLED devices for ambient lighting.
@@ -47,7 +47,7 @@ export PYTHONPATH=$(pwd)/src # Linux/Mac
set PYTHONPATH=%CD%\src # Windows
# Run server
uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
uvicorn ledgrab.main:app --host 0.0.0.0 --port 8080
```
## Installation
@@ -85,20 +85,20 @@ storage:
logging:
format: "json"
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
```
### Environment Variables
```bash
# Server configuration
export WLED_SERVER__HOST="0.0.0.0"
export WLED_SERVER__PORT=8080
export WLED_SERVER__LOG_LEVEL="INFO"
export LEDGRAB_SERVER__HOST="0.0.0.0"
export LEDGRAB_SERVER__PORT=8080
export LEDGRAB_SERVER__LOG_LEVEL="INFO"
# Processing configuration
export WLED_PROCESSING__DEFAULT_FPS=30
export WLED_PROCESSING__BORDER_WIDTH=10
export LEDGRAB_PROCESSING__DEFAULT_FPS=30
export LEDGRAB_PROCESSING__BORDER_WIDTH=10
# WLED configuration
export WLED_WLED__TIMEOUT=5
@@ -147,7 +147,7 @@ curl http://localhost:8080/api/v1/devices/{device_id}/state
pytest
# Run with coverage
pytest --cov=wled_controller --cov-report=html
pytest --cov=ledgrab --cov-report=html
# Run specific test
pytest tests/test_screen_capture.py -v
@@ -158,7 +158,7 @@ pytest tests/test_screen_capture.py -v
### Project Structure
```
src/wled_controller/
src/ledgrab/
├── main.py # FastAPI application
├── config.py # Configuration
├── api/ # API routes
@@ -188,4 +188,4 @@ MIT - see [../LICENSE](../LICENSE)
## Support
- 📖 [Full Documentation](../docs/)
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues)
- 🐛 [Issues](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues)
+1 -1
View File
@@ -28,6 +28,6 @@ mqtt:
logging:
format: "json" # json or text
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
max_size_mb: 100
backup_count: 5
+2 -2
View File
@@ -1,5 +1,5 @@
# Demo mode configuration
# Loaded automatically when WLED_DEMO=true is set.
# Loaded automatically when LEDGRAB_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.
@@ -26,6 +26,6 @@ mqtt:
logging:
format: "text"
file: "logs/wled_controller.log"
file: "logs/ledgrab.log"
max_size_mb: 100
backup_count: 5
+1 -1
View File
@@ -15,6 +15,6 @@ storage:
logging:
format: "text"
file: "logs/wled_test.log"
file: "logs/ledgrab_test.log"
max_size_mb: 10
backup_count: 2
+17 -17
View File
@@ -1,14 +1,14 @@
services:
wled-controller:
ledgrab:
build:
context: .
dockerfile: Dockerfile
image: ledgrab:latest
container_name: wled-screen-controller
container_name: ledgrab
restart: unless-stopped
ports:
- "${WLED_PORT:-8080}:8080"
- "${LEDGRAB_PORT:-8080}:8080"
volumes:
# Persist device data and configuration across restarts
@@ -22,37 +22,37 @@ services:
environment:
## 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
- LEDGRAB_SERVER__HOST=0.0.0.0
- LEDGRAB_SERVER__PORT=8080
- LEDGRAB_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"]
# LEDGRAB_SERVER__CORS_ORIGINS=["http://localhost:8080","http://192.168.1.100:8080"]
## Auth
# Override the default API key (STRONGLY recommended for production):
# WLED_AUTH__API_KEYS__main=your-secure-key-here
# LEDGRAB_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
#- LEDGRAB_PROCESSING__DEFAULT_FPS=30
#- LEDGRAB_PROCESSING__BORDER_WIDTH=10
## 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=
#- LEDGRAB_MQTT__ENABLED=true
#- LEDGRAB_MQTT__BROKER_HOST=192.168.1.2
#- LEDGRAB_MQTT__BROKER_PORT=1883
#- LEDGRAB_MQTT__USERNAME=
#- LEDGRAB_MQTT__PASSWORD=
# Uncomment for Linux screen capture (requires host network for X11 access)
# network_mode: host
networks:
- wled-network
- ledgrab-network
networks:
wled-network:
ledgrab-network:
driver: bridge
+4 -4
View File
@@ -1,6 +1,6 @@
# API Authentication Guide
WLED Screen Controller **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
LedGrab **requires** API key authentication for all API endpoints. This ensures your server is secure and all access is properly authenticated and audited.
## Configuration
@@ -66,7 +66,7 @@ curl -H "Authorization: Bearer your-api-key-here" \
The integration will prompt for an API key during setup if authentication is enabled. You can also configure it in `configuration.yaml`:
```yaml
wled_screen_controller:
ledgrab:
server_url: "http://192.168.1.100:8080"
api_key: "your-api-key-here" # Optional, only if auth is enabled
```
@@ -168,8 +168,8 @@ export WLED_API_KEY_2="$(openssl rand -hex 32)"
services:
wled-controller:
environment:
- WLED_AUTH__ENABLED=true
- WLED_AUTH__API_KEYS__0=your-key-here
- LEDGRAB_AUTH__ENABLED=true
- LEDGRAB_AUTH__API_KEYS__0=your-key-here
```
Or use Docker secrets for better security.
+1 -1
View File
@@ -1,6 +1,6 @@
import * as esbuild from 'esbuild';
const srcDir = 'src/wled_controller/static';
const srcDir = 'src/ledgrab/static';
const outDir = `${srcDir}/dist`;
const watch = process.argv.includes('--watch');
+6 -6
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "wled-screen-controller"
name = "ledgrab"
version = "0.3.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [
@@ -83,10 +83,10 @@ 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"
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/src/branch/master/INSTALLATION.md"
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues"
[tool.setuptools]
package-dir = {"" = "src"}
@@ -97,7 +97,7 @@ where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --cov=wled_controller --cov-report=html --cov-report=term"
addopts = "-v --cov=ledgrab --cov-report=html --cov-report=term"
[tool.black]
line-length = 100
+8 -8
View File
@@ -1,8 +1,8 @@
# Restart the WLED Screen Controller server
# Restart the LedGrab server
# Uses graceful shutdown first (lets the server persist data to disk),
# then force-kills as a fallback.
$serverRoot = 'c:\Users\Alexei\Documents\wled-screen-controller\server'
$serverRoot = $PSScriptRoot
# Read API key from config for authenticated shutdown request
$configPath = Join-Path $serverRoot 'config\default_config.yaml'
@@ -20,7 +20,7 @@ if (Test-Path $configPath) {
# Find running server processes
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if ($procs) {
# Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save)
@@ -46,7 +46,7 @@ if ($procs) {
Start-Sleep -Seconds 1
$waited++
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if (-not $still) {
Write-Host " Server exited cleanly after ${waited}s"
break
@@ -54,7 +54,7 @@ if ($procs) {
}
# Step 3: Force-kill stragglers
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if ($still) {
Write-Host " Force-killing remaining processes..."
foreach ($p in $still) {
@@ -85,13 +85,13 @@ if ($regUser) {
# Start server detached (set WLED_RESTART=1 to skip browser open)
Write-Host "Starting server..."
$env:WLED_RESTART = "1"
$env:LEDGRAB_RESTART = "1"
$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source
if (-not $pythonExe) {
# Fallback to known install location
$pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe"
}
Start-Process -FilePath $pythonExe -ArgumentList '-m', 'wled_controller' `
Start-Process -FilePath $pythonExe -ArgumentList '-m', 'ledgrab' `
-WorkingDirectory $serverRoot `
-WindowStyle Hidden
@@ -99,7 +99,7 @@ Start-Sleep -Seconds 3
# Verify it's running
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if ($check) {
Write-Host "Server started (PID $($check[0].ProcessId))"
} else {
+5 -5
View File
@@ -1,25 +1,25 @@
#!/usr/bin/env bash
# Restart the WLED Screen Controller server (Linux/macOS)
# Restart the LedGrab server (Linux/macOS)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Stop any running instance
PIDS=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
PIDS=$(pgrep -f 'ledgrab\.main' 2>/dev/null || true)
if [ -n "$PIDS" ]; then
echo "Stopping server (PID $PIDS)..."
pkill -f 'wled_controller\.main' 2>/dev/null || true
pkill -f 'ledgrab\.main' 2>/dev/null || true
sleep 2
fi
# Start server detached
echo "Starting server..."
cd "$SCRIPT_DIR"
nohup python -m wled_controller.main > /dev/null 2>&1 &
nohup python -m ledgrab.main > /dev/null 2>&1 &
sleep 3
# Verify it's running
NEW_PID=$(pgrep -f 'wled_controller\.main' 2>/dev/null || true)
NEW_PID=$(pgrep -f 'ledgrab\.main' 2>/dev/null || true)
if [ -n "$NEW_PID" ]; then
echo "Server started (PID $NEW_PID)"
else
+3 -3
View File
@@ -1,8 +1,8 @@
@echo off
REM WLED Screen Controller Restart Script
REM LedGrab Restart Script
REM This script restarts the WLED screen controller server
echo Restarting WLED Screen Controller...
echo Restarting LedGrab...
echo.
REM Stop the server first
@@ -18,7 +18,7 @@ cd /d "%~dp0\.."
REM Start the server
echo.
echo [2/2] Starting server...
python -m wled_controller
python -m ledgrab
REM If the server exits, pause to show any error messages
pause
+3 -3
View File
@@ -8,13 +8,13 @@ WshShell.CurrentDirectory = appRoot
' Set env vars for the child process (inherited via WshShell.Run)
Set procEnv = WshShell.Environment("Process")
procEnv("PYTHONPATH") = appRoot & "\app\src"
procEnv("WLED_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml"
procEnv("LEDGRAB_CONFIG_PATH") = appRoot & "\app\config\default_config.yaml"
' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0.
' Same pattern as the Media Server sibling app.
embeddedPython = appRoot & "\python\python.exe"
If fso.FileExists(embeddedPython) Then
WshShell.Run """" & embeddedPython & """ -m wled_controller", 0, False
WshShell.Run """" & embeddedPython & """ -m ledgrab", 0, False
Else
WshShell.Run "python -m wled_controller", 0, False
WshShell.Run "python -m ledgrab", 0, False
End If
+1 -1
View File
@@ -2,6 +2,6 @@ Set WshShell = CreateObject("WScript.Shell")
Set FSO = CreateObject("Scripting.FileSystemObject")
' Get parent folder of scripts folder (server root)
WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName))
WshShell.Run "python -m wled_controller", 0, False
WshShell.Run "python -m ledgrab", 0, False
Set FSO = Nothing
Set WshShell = Nothing
+3 -3
View File
@@ -1,15 +1,15 @@
@echo off
REM WLED Screen Controller Startup Script
REM LedGrab Startup Script
REM This script starts the WLED screen controller server
echo Starting WLED Screen Controller...
echo Starting LedGrab...
echo.
REM Change to the server directory (parent of scripts folder)
cd /d "%~dp0\.."
REM Start the server
python -m wled_controller
python -m ledgrab
REM If the server exits, pause to show any error messages
pause
+5 -5
View File
@@ -1,13 +1,13 @@
@echo off
REM WLED Screen Controller Stop Script
REM LedGrab Stop Script
REM This script stops the running WLED screen controller server
echo Stopping WLED Screen Controller...
echo Stopping LedGrab...
echo.
REM Find and kill Python processes running wled_controller.main
REM Find and kill Python processes running ledgrab.main
for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr /B "PID:"') do (
wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"wled_controller.main" >nul
wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"ledgrab.main" >nul
if not errorlevel 1 (
taskkill /PID %%i /F
echo WLED controller process (PID %%i) terminated.
@@ -15,5 +15,5 @@ for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| fi
)
echo.
echo Done! WLED Screen Controller stopped.
echo Done! LedGrab stopped.
pause
@@ -3,7 +3,7 @@
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("wled-screen-controller")
__version__ = version("ledgrab")
except PackageNotFoundError:
# Running from source without pip install (e.g. dev, embedded Python)
__version__ = "0.0.0-dev"
@@ -13,6 +13,6 @@ __email__ = "dolgolyov.alexei@gmail.com"
# ─── Project links ───────────────────────────────────────────
GITEA_BASE_URL = "https://git.dolgolyov-family.by"
GITEA_REPO = "alexei.dolgolyov/wled-screen-controller-mixed"
GITEA_REPO = "alexei.dolgolyov/ledgrab"
REPO_URL = f"{GITEA_BASE_URL}/{GITEA_REPO}"
DONATE_URL = "" # TODO: set once donation platform is chosen
@@ -1,4 +1,4 @@
"""Entry point for ``python -m wled_controller``.
"""Entry point for ``python -m ledgrab``.
Starts the uvicorn server and, on Windows when *pystray* is installed,
shows a system-tray icon with **Show UI** / **Exit** actions.
@@ -36,10 +36,10 @@ _fix_embedded_tcl_paths()
import uvicorn # noqa: E402
from wled_controller.config import get_config # noqa: E402
from wled_controller.server_ref import set_server, set_tray # noqa: E402
from wled_controller.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from wled_controller.utils import setup_logging, get_logger # noqa: E402
from ledgrab.config import get_config # noqa: E402
from ledgrab.server_ref import set_server, set_tray # noqa: E402
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402
setup_logging()
logger = get_logger(__name__)
@@ -62,7 +62,7 @@ def _open_browser(port: int, delay: float = 2.0) -> None:
def _is_restart() -> bool:
"""Detect if this is a restart (vs first launch)."""
return os.environ.get("WLED_RESTART", "") == "1"
return os.environ.get("LEDGRAB_RESTART", "") == "1"
def _check_port(host: str, port: int) -> None:
@@ -81,7 +81,7 @@ def main() -> None:
_check_port(config.server.host, config.server.port)
uv_config = uvicorn.Config(
"wled_controller.main:app",
"ledgrab.main:app",
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
@@ -133,10 +133,10 @@ def _request_shutdown(server: uvicorn.Server) -> None:
def _force_tray() -> bool:
"""Allow forcing tray on non-Windows via WLED_TRAY=1."""
"""Allow forcing tray on non-Windows via LEDGRAB_TRAY=1."""
import os
return os.environ.get("WLED_TRAY", "").strip() in ("1", "true", "yes")
return os.environ.get("LEDGRAB_TRAY", "").strip() in ("1", "true", "yes")
if __name__ == "__main__":
@@ -6,8 +6,8 @@ from typing import Annotated
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from wled_controller.config import get_config
from wled_controller.utils import get_logger
from ledgrab.config import get_config
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -6,39 +6,39 @@ All getter function signatures remain unchanged for FastAPI Depends() compatibil
from typing import Any, Dict, TypeVar
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.database import Database
from wled_controller.storage import DeviceStore
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.output_target_store import OutputTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
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 (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.database import Database
from ledgrab.storage import DeviceStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
)
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.update.update_service import UpdateService
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.storage.asset_store import AssetStore
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.core.update.update_service import UpdateService
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore
T = TypeVar("T")
@@ -8,9 +8,9 @@ from typing import Callable, Optional
import numpy as np
from starlette.websockets import WebSocket
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.utils import get_logger
from wled_controller.utils.image_codec import (
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.utils import get_logger
from ledgrab.utils.image_codec import (
encode_jpeg,
encode_jpeg_data_uri,
resize_down,
@@ -31,7 +31,8 @@ def authenticate_ws_token(token: str) -> bool:
Delegates to the canonical implementation in auth module.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
return verify_ws_token(token)
@@ -160,14 +161,16 @@ async def stream_capture_test(
thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY)
fps = fc / elapsed if elapsed > 0 else 0
avg_ms = (tc / fc * 1000) if fc > 0 else 0
await websocket.send_json({
"type": "frame",
"thumbnail": thumb_uri,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
})
await websocket.send_json(
{
"type": "frame",
"thumbnail": thumb_uri,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
}
)
# Wait for capture thread to fully finish
await capture_future
@@ -199,17 +202,19 @@ async def stream_capture_test(
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)
thumb_uri = _encode_jpeg(thumb, 85)
await websocket.send_json({
"type": "result",
"full_image": full_uri,
"thumbnail": thumb_uri,
"width": w,
"height": h,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
})
await websocket.send_json(
{
"type": "result",
"full_image": full_uri,
"thumbnail": thumb_uri,
"width": w,
"height": h,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
}
)
except Exception as e:
# WebSocket disconnect or send error — signal capture thread to stop
@@ -5,17 +5,17 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_asset_store
from wled_controller.api.schemas.assets import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import fire_entity_event, get_asset_store
from ledgrab.api.schemas.assets import (
AssetListResponse,
AssetResponse,
AssetUpdate,
)
from wled_controller.config import get_config
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
from ledgrab.config import get_config
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -103,7 +103,9 @@ async def upload_asset(
if not data:
raise HTTPException(status_code=400, detail="Empty file")
display_name = name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
display_name = (
name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
)
try:
asset = store.create_asset(
@@ -4,8 +4,8 @@ import asyncio
from fastapi import APIRouter
from wled_controller.api.auth import AuthRequired
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from ledgrab.api.auth import AuthRequired
from ledgrab.core.audio.audio_capture import AudioCaptureManager
router = APIRouter()
@@ -2,15 +2,15 @@
from fastapi import APIRouter, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_audio_processing_template_store
from wled_controller.api.schemas.filters import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_audio_processing_template_store
from ledgrab.api.schemas.filters import (
FilterOptionDefSchema,
FilterTypeListResponse,
FilterTypeResponse,
)
from wled_controller.core.audio.filters import AudioFilterRegistry
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.core.audio.filters import AudioFilterRegistry
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
router = APIRouter()
@@ -2,24 +2,24 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_audio_source_store,
get_processor_manager,
)
from wled_controller.api.schemas.audio_processing import (
from ledgrab.api.schemas.audio_processing import (
AudioProcessingTemplateCreate,
AudioProcessingTemplateListResponse,
AudioProcessingTemplateResponse,
AudioProcessingTemplateUpdate,
)
from wled_controller.api.schemas.filters import FilterInstanceSchema
from wled_controller.core.filters.filter_instance import FilterInstance
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
from ledgrab.api.schemas.filters import FilterInstanceSchema
from ledgrab.core.filters.filter_instance import FilterInstance
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -6,8 +6,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_audio_source_store,
@@ -15,7 +15,7 @@ from wled_controller.api.dependencies import (
get_color_strip_store,
get_processor_manager,
)
from wled_controller.api.schemas.audio_sources import (
from ledgrab.api.schemas.audio_sources import (
AudioSourceCreate,
AudioSourceListResponse,
AudioSourceResponse,
@@ -23,15 +23,15 @@ from wled_controller.api.schemas.audio_sources import (
CaptureAudioSourceResponse,
ProcessedAudioSourceResponse,
)
from wled_controller.storage.audio_source import (
from ledgrab.storage.audio_source import (
AudioSource,
CaptureAudioSource,
ProcessedAudioSource,
)
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
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -178,7 +178,7 @@ async def delete_audio_source(
"""Delete an audio source."""
try:
# Check if any CSS entities reference this audio source
from wled_controller.storage.color_strip_source import AudioColorStripSource
from ledgrab.storage.color_strip_source import AudioColorStripSource
for css in css_store.get_all_sources():
if (
@@ -215,8 +215,8 @@ async def test_audio_source_ws(
analysis before sending, so the WebSocket output matches what running
streams see.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids
from ledgrab.api.auth import verify_ws_token
from ledgrab.core.audio.filters.pipeline import build_pipeline_from_template_ids
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -4,9 +4,14 @@ import asyncio
from fastapi import APIRouter, HTTPException, Depends, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_audio_template_store, get_audio_source_store, get_processor_manager
from wled_controller.api.schemas.audio_templates import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_audio_template_store,
get_audio_source_store,
get_processor_manager,
)
from ledgrab.api.schemas.audio_templates import (
AudioEngineInfo,
AudioEngineListResponse,
AudioTemplateCreate,
@@ -14,11 +19,11 @@ from wled_controller.api.schemas.audio_templates import (
AudioTemplateResponse,
AudioTemplateUpdate,
)
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
from ledgrab.core.audio.factory import AudioEngineRegistry
from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -27,7 +32,10 @@ router = APIRouter()
# ===== AUDIO TEMPLATE ENDPOINTS =====
@router.get("/api/v1/audio-templates", response_model=AudioTemplateListResponse, tags=["Audio Templates"])
@router.get(
"/api/v1/audio-templates", response_model=AudioTemplateListResponse, tags=["Audio Templates"]
)
async def list_audio_templates(
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
@@ -37,10 +45,14 @@ async def list_audio_templates(
templates = store.get_all_templates()
responses = [
AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=t.tags,
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
updated_at=t.updated_at,
description=t.description,
)
for t in templates
]
@@ -50,7 +62,12 @@ async def list_audio_templates(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
@router.post(
"/api/v1/audio-templates",
response_model=AudioTemplateResponse,
tags=["Audio Templates"],
status_code=201,
)
async def create_audio_template(
data: AudioTemplateCreate,
_auth: AuthRequired,
@@ -59,16 +76,22 @@ async def create_audio_template(
"""Create a new audio capture template."""
try:
template = store.create_template(
name=data.name, engine_type=data.engine_type,
engine_config=data.engine_config, description=data.description,
name=data.name,
engine_type=data.engine_type,
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
)
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=template.tags,
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at, description=template.description,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -80,7 +103,11 @@ async def create_audio_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
@router.get(
"/api/v1/audio-templates/{template_id}",
response_model=AudioTemplateResponse,
tags=["Audio Templates"],
)
async def get_audio_template(
template_id: str,
_auth: AuthRequired,
@@ -92,14 +119,22 @@ async def get_audio_template(
except ValueError:
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=t.tags,
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
updated_at=t.updated_at,
description=t.description,
)
@router.put("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
@router.put(
"/api/v1/audio-templates/{template_id}",
response_model=AudioTemplateResponse,
tags=["Audio Templates"],
)
async def update_audio_template(
template_id: str,
data: AudioTemplateUpdate,
@@ -109,16 +144,23 @@ async def update_audio_template(
"""Update an audio template."""
try:
t = store.update_template(
template_id=template_id, name=data.name,
engine_type=data.engine_type, engine_config=data.engine_config,
description=data.description, tags=data.tags,
template_id=template_id,
name=data.name,
engine_type=data.engine_type,
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
)
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=t.tags,
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
updated_at=t.updated_at,
description=t.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -155,7 +197,10 @@ async def delete_audio_template(
# ===== AUDIO ENGINE ENDPOINTS =====
@router.get("/api/v1/audio-engines", response_model=AudioEngineListResponse, tags=["Audio Templates"])
@router.get(
"/api/v1/audio-engines", response_model=AudioEngineListResponse, tags=["Audio Templates"]
)
async def list_audio_engines(_auth: AuthRequired):
"""List all registered audio capture engines."""
try:
@@ -195,7 +240,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.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -214,13 +260,17 @@ async def test_audio_template_ws(
loopback = is_loopback != 0
try:
stream = audio_mgr.acquire(device_index, loopback, template.engine_type, template.engine_config)
stream = audio_mgr.acquire(
device_index, loopback, template.engine_type, template.engine_config
)
except RuntimeError as e:
await websocket.close(code=4003, reason=str(e))
return
await websocket.accept()
logger.info(f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}")
logger.info(
f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}"
)
last_ts = 0.0
try:
@@ -228,13 +278,15 @@ async def test_audio_template_ws(
analysis = stream.get_latest_analysis()
if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp
await websocket.send_json({
"spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
})
await websocket.send_json(
{
"spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
}
)
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Audio template test WebSocket disconnected")
@@ -4,22 +4,22 @@ import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_automation_engine,
get_automation_store,
get_scene_preset_store,
)
from wled_controller.api.schemas.automations import (
from ledgrab.api.schemas.automations import (
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationUpdate,
RuleSchema,
)
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import (
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
@@ -30,10 +30,10 @@ from wled_controller.storage.automation import (
TimeOfDayRule,
WebhookRule,
)
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
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -97,7 +97,7 @@ def _automation_to_response(
for r in automation.rules:
if isinstance(r, WebhookRule) and r.token:
# Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
from ledgrab.api.routes.system import load_external_url
ext = load_external_url()
if ext:
@@ -15,20 +15,20 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
from wled_controller.api.schemas.system import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
from ledgrab.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
BackupListResponse,
RestoreResponse,
)
from wled_controller.config import get_config
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.database import Database, freeze_writes
from wled_controller.utils import get_logger
from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -42,11 +42,17 @@ def _schedule_restart() -> None:
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
[
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
str(_SERVER_DIR / "restart.ps1"),
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
@@ -71,6 +77,7 @@ def backup_config(
):
"""Download a full backup as a .zip containing the database and asset files."""
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = Path(tmp.name)
@@ -95,6 +102,7 @@ def backup_config(
zip_buffer.seek(0)
from datetime import datetime, timezone
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.zip"
@@ -129,7 +137,9 @@ async def restore_config(
is_sqlite = raw[:16].startswith(b"SQLite format 3")
if not is_zip and not is_sqlite:
raise HTTPException(status_code=400, detail="Not a valid backup file (expected .zip or .db)")
raise HTTPException(
status_code=400, detail="Not a valid backup file (expected .zip or .db)"
)
if is_zip:
# Extract DB and assets from ZIP
@@ -160,6 +170,7 @@ async def restore_config(
tmp_path = Path(tmp.name)
try:
def _restore():
db.restore_from(tmp_path)
@@ -181,7 +192,8 @@ async def restore_config(
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
from wled_controller.server_ref import _broadcast_restarting
from ledgrab.server_ref import _broadcast_restarting
_broadcast_restarting()
_schedule_restart()
return {"status": "restarting"}
@@ -190,7 +202,8 @@ def restart_server(_: AuthRequired):
@router.post("/api/v1/system/shutdown", tags=["System"])
def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server."""
from wled_controller.server_ref import request_shutdown
from ledgrab.server_ref import request_shutdown
request_shutdown()
return {"status": "shutting_down"}
@@ -6,27 +6,27 @@ 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 (
from ledgrab.api.auth import AuthRequired
from ledgrab.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 (
from ledgrab.api.schemas.filters import FilterInstanceSchema
from ledgrab.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
from ledgrab.core.filters import FilterInstance
from ledgrab.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage import DeviceStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -46,7 +46,11 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
)
@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"])
@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),
@@ -61,7 +65,12 @@ async def list_cspt(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
@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,
@@ -88,7 +97,11 @@ async def create_cspt(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
@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,
@@ -99,10 +112,16 @@ async def get_cspt(
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")
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"])
@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,
@@ -111,7 +130,11 @@ async def update_cspt(
):
"""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
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,
@@ -131,7 +154,11 @@ async def update_cspt(
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
@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,
@@ -147,7 +174,7 @@ async def delete_cspt(
raise HTTPException(
status_code=409,
detail=f"Cannot delete: template is referenced by: {names}. "
"Please reassign before deleting.",
"Please reassign before deleting.",
)
store.delete_template(template_id)
fire_entity_event("cspt", "deleted", template_id)
@@ -165,6 +192,7 @@ async def delete_cspt(
# ── Test / Preview WebSocket ──────────────────────────────────────────
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
async def test_cspt_ws(
websocket: WebSocket,
@@ -179,9 +207,9 @@ async def test_cspt_ws(
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
from ledgrab.api.auth import verify_ws_token
from ledgrab.core.filters import FilterRegistry
from ledgrab.core.processing.processor_manager import ProcessorManager
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -9,8 +9,8 @@ from typing import Annotated
import numpy as np
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
@@ -20,7 +20,7 @@ from wled_controller.api.dependencies import (
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.color_strip_sources import (
from ledgrab.api.schemas.color_strip_sources import (
ApiInputCSSResponse,
AudioCSSResponse,
CandlelightCSSResponse,
@@ -47,17 +47,17 @@ from wled_controller.api.schemas.color_strip_sources import (
StaticCSSResponse,
WeatherCSSResponse,
)
from wled_controller.api.schemas.devices import (
from ledgrab.api.schemas.devices import (
Calibration as CalibrationSchema,
CalibrationTestModeResponse,
)
from wled_controller.core.capture.calibration import (
from ledgrab.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 (
from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
AudioColorStripSource,
@@ -76,17 +76,17 @@ from wled_controller.storage.color_strip_source import (
StaticColorStripSource,
WeatherColorStripSource,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source import (
from ledgrab.storage import DeviceStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.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.storage.base_store import EntityNotFoundError
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -371,7 +371,7 @@ async def create_color_strip_source(
if data.source_type == "composite" and kwargs.get("layers"):
child_ids = [ly.get("source_id", "") for ly in kwargs["layers"] if ly.get("source_id")]
# No parent_id yet (new source), just check depth
from wled_controller.storage.color_strip_store import MAX_COMPOSITE_DEPTH
from ledgrab.storage.color_strip_store import MAX_COMPOSITE_DEPTH
for cid in child_ids:
depth = store.get_nesting_depth(cid)
@@ -524,19 +524,19 @@ async def test_key_colors_source(
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key_colors source: capture a frame, extract colors from each rectangle."""
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import (
from ledgrab.storage.color_strip_source import KeyColorsColorStripSource
from ledgrab.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.picture_source import (
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.storage.picture_source import (
ScreenCapturePictureSource,
StaticImagePictureSource,
)
from wled_controller.utils.image_codec import encode_jpeg_data_uri
from ledgrab.utils.image_codec import encode_jpeg_data_uri
stream = None
try:
@@ -553,10 +553,10 @@ async def test_key_colors_source(
chain = source_store.resolve_stream_chain(source.picture_source_id)
raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import load_image_file
from ledgrab.utils.image_codec import load_image_file
if isinstance(raw_stream, StaticImagePictureSource):
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from ledgrab.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = (
@@ -681,15 +681,15 @@ async def test_key_colors_ws(
"""WebSocket for real-time key_colors test preview with frame + rectangle overlay."""
import json as ws_json
import time as ws_time
from wled_controller.api.auth import verify_ws_token
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import (
from ledgrab.api.auth import verify_ws_token
from ledgrab.storage.color_strip_source import KeyColorsColorStripSource
from ledgrab.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
from ledgrab.storage.picture_source import ScreenCapturePictureSource
from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1095,7 +1095,7 @@ 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 (
from ledgrab.core.processing.os_notification_listener import (
get_os_notification_listener,
)
@@ -1139,7 +1139,7 @@ async def preview_color_strip_ws(
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
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1168,7 +1168,7 @@ async def preview_color_strip_ws(
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
from ledgrab.storage.color_strip_source import ColorStripSource
config.setdefault("id", "__preview__")
config.setdefault("name", "__preview__")
@@ -1176,7 +1176,7 @@ async def preview_color_strip_ws(
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
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
@@ -1185,7 +1185,7 @@ async def preview_color_strip_ws(
# Inject gradient store for palette resolution
if hasattr(s, "set_gradient_store"):
try:
from wled_controller.api.dependencies import get_gradient_store
from ledgrab.api.dependencies import get_gradient_store
s.set_gradient_store(get_gradient_store())
except Exception:
@@ -1283,7 +1283,7 @@ async def preview_color_strip_ws(
# Handle "fire" command for notification streams
if new_config.get("action") == "fire":
from wled_controller.core.processing.notification_stream import (
from ledgrab.core.processing.notification_stream import (
NotificationColorStripStream,
)
@@ -1349,7 +1349,7 @@ 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).
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1391,7 +1391,7 @@ async def css_api_input_ws(
if "segments" in data:
# Segment-based path — validate and push
try:
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
from ledgrab.api.schemas.color_strip_sources import SegmentPayload
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e:
@@ -1460,7 +1460,7 @@ async def test_color_strip_ws(
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
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1506,9 +1506,9 @@ async def test_color_strip_ws(
logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})")
try:
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
from ledgrab.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
@@ -1643,7 +1643,7 @@ async def test_color_strip_ws(
try:
frame = _frame_live.get_latest_frame()
if frame is not None and frame.image is not None:
from wled_controller.utils.image_codec import encode_jpeg
from ledgrab.utils.image_codec import encode_jpeg
import cv2 as _cv2
img = frame.image
@@ -3,19 +3,19 @@
import httpx
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.core.devices.led_client import (
from ledgrab.api.auth import AuthRequired
from ledgrab.core.devices.led_client import (
get_all_providers,
get_device_capabilities,
get_provider,
)
from wled_controller.api.dependencies import (
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_output_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.devices import (
from ledgrab.api.schemas.devices import (
BrightnessRequest,
DeviceCreate,
DeviceListResponse,
@@ -28,10 +28,10 @@ from wled_controller.api.schemas.devices import (
OpenRGBZonesResponse,
PowerRequest,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -300,14 +300,14 @@ async def get_openrgb_zones(
"""List available zones on an OpenRGB device."""
import asyncio
from wled_controller.core.devices.openrgb_client import parse_openrgb_url
from ledgrab.core.devices.openrgb_client import parse_openrgb_url
host, port, device_index, _zones = parse_openrgb_url(url)
def _fetch_zones():
from openrgb import OpenRGBClient
client = OpenRGBClient(host, port, name="WLED Controller (zones)")
client = OpenRGBClient(host, port, name="LedGrab (zones)")
try:
devices = client.devices
if device_index >= len(devices):
@@ -742,7 +742,7 @@ async def device_ws_stream(
Wire format: [brightness_byte][R G B R G B ...]
Auth via ?token=<api_key>.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -760,7 +760,7 @@ async def device_ws_stream(
await websocket.accept()
from wled_controller.core.devices.ws_client import get_ws_broadcaster
from ledgrab.core.devices.ws_client import get_ws_broadcaster
broadcaster = get_ws_broadcaster()
broadcaster.add_client(device_id, websocket)
@@ -10,14 +10,14 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_database,
get_game_integration_store,
get_game_event_bus,
)
from wled_controller.api.schemas.game_integration import (
from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse,
AdapterListResponse,
ApplyPresetRequest,
@@ -34,13 +34,13 @@ from wled_controller.api.schemas.game_integration import (
PresetListResponse,
RecentEventsResponse,
)
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.game_integration import EventMapping
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.utils import get_logger
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -135,7 +135,7 @@ def _cleanup_state(integration_id: str) -> None:
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
from wled_controller.api.schemas.game_integration import EventMappingSchema
from ledgrab.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
id=config.id,
@@ -171,7 +171,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
)
async def list_presets(_auth: AuthRequired):
"""List all available built-in effect presets."""
from wled_controller.core.game_integration.presets import get_all_presets
from ledgrab.core.game_integration.presets import get_all_presets
presets = get_all_presets()
responses = [
@@ -554,7 +554,7 @@ async def apply_preset(
If replace=true, replaces all existing mappings.
If replace=false (default), appends preset mappings to existing ones.
"""
from wled_controller.core.game_integration.presets import get_preset
from ledgrab.core.game_integration.presets import get_preset
try:
config = store.get_integration(integration_id)
@@ -619,7 +619,7 @@ async def auto_setup_integration(
)
# Determine server URL
from wled_controller.api.routes.system_settings import load_external_url
from ledgrab.api.routes.system_settings import load_external_url
db = get_database()
server_url = load_external_url(db)
@@ -2,23 +2,23 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_gradient_store,
)
from wled_controller.api.schemas.gradients import (
from ledgrab.api.schemas.gradients import (
GradientCreate,
GradientListResponse,
GradientResponse,
GradientUpdate,
)
from wled_controller.storage.gradient import Gradient
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
from ledgrab.storage.gradient import Gradient
from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -51,7 +51,9 @@ async def list_gradients(
)
@router.post("/api/v1/gradients", response_model=GradientResponse, status_code=201, tags=["Gradients"])
@router.post(
"/api/v1/gradients", response_model=GradientResponse, status_code=201, tags=["Gradients"]
)
async def create_gradient(
data: GradientCreate,
_auth: AuthRequired,
@@ -109,7 +111,12 @@ async def update_gradient(
raise HTTPException(status_code=status, detail=str(e))
@router.post("/api/v1/gradients/{gradient_id}/clone", response_model=GradientResponse, status_code=201, tags=["Gradients"])
@router.post(
"/api/v1/gradients/{gradient_id}/clone",
response_model=GradientResponse,
status_code=201,
tags=["Gradients"],
)
async def clone_gradient(
gradient_id: str,
_auth: AuthRequired,
@@ -143,9 +150,7 @@ async def delete_gradient(
# Check references
for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{source.name}'"
)
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id)
except (ValueError, EntityNotFoundError) as e:
@@ -5,13 +5,13 @@ import json
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_ha_manager,
get_ha_store,
)
from wled_controller.api.schemas.home_assistant import (
from ledgrab.api.schemas.home_assistant import (
HomeAssistantConnectionStatus,
HomeAssistantEntityListResponse,
HomeAssistantEntityResponse,
@@ -22,12 +22,12 @@ from wled_controller.api.schemas.home_assistant import (
HomeAssistantStatusResponse,
HomeAssistantTestResponse,
)
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.home_assistant.ha_runtime import HARuntime
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.home_assistant_source import HomeAssistantSource
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.utils import get_logger
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.home_assistant.ha_runtime import HARuntime
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.home_assistant_source import HomeAssistantSource
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -5,13 +5,13 @@ import asyncio
import aiomqtt
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_mqtt_manager,
get_mqtt_store,
)
from wled_controller.api.schemas.mqtt import (
from ledgrab.api.schemas.mqtt import (
MQTTConnectionStatus,
MQTTSourceCreate,
MQTTSourceListResponse,
@@ -20,11 +20,11 @@ from wled_controller.api.schemas.mqtt import (
MQTTStatusResponse,
MQTTTestResponse,
)
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.mqtt_source import MQTTSource
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.utils import get_logger
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.mqtt_source import MQTTSource
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -5,14 +5,14 @@ from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_output_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
from ledgrab.api.schemas.output_targets import (
HALightMappingSchema,
HALightOutputTargetResponse,
LedOutputTargetResponse,
@@ -21,17 +21,17 @@ from wled_controller.api.schemas.output_targets import (
OutputTargetResponse,
OutputTargetUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.ha_light_output_target import (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
from ledgrab.storage.bindable import BindableFloat
from ledgrab.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -5,30 +5,30 @@ 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 (
from ledgrab.api.auth import AuthRequired
from ledgrab.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 (
from ledgrab.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 (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.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
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -208,7 +208,7 @@ async def events_ws(
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -272,7 +272,7 @@ async def start_target_overlay(
):
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import (
from ledgrab.api.routes.color_strip_sources import (
_resolve_display_index,
)
@@ -348,7 +348,7 @@ async def ha_light_colors_ws(
Streams: {"type": "colors_update", "colors": {entity_id: {r,g,b,hex}, ...}}
at the target's update_rate.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -390,7 +390,7 @@ async def led_preview_ws(
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
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -2,24 +2,24 @@
from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_pattern_template_store,
get_output_target_store,
)
from wled_controller.api.schemas.pattern_templates import (
from ledgrab.api.schemas.pattern_templates import (
PatternTemplateCreate,
PatternTemplateListResponse,
PatternTemplateResponse,
PatternTemplateUpdate,
)
from wled_controller.api.schemas.output_targets import KeyColorRectangleSchema
from wled_controller.storage.pattern_template 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
from ledgrab.api.schemas.output_targets import KeyColorRectangleSchema
from ledgrab.storage.pattern_template import KeyColorRectangle
from ledgrab.storage.pattern_template_store import PatternTemplateStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -9,20 +9,20 @@ import numpy as np
from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
from ledgrab.api.schemas.common import (
CaptureImage,
PerformanceMetrics,
TemplateTestResponse,
)
from wled_controller.api.schemas.picture_sources import (
from ledgrab.api.schemas.picture_sources import (
ImageValidateRequest,
ImageValidateResponse,
PictureSourceCreate,
@@ -35,20 +35,20 @@ from wled_controller.api.schemas.picture_sources import (
StaticImagePictureSourceResponse,
VideoPictureSourceResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
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 (
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.picture_source import (
ProcessedPictureSource,
ScreenCapturePictureSource,
StaticImagePictureSource,
VideoCaptureSource,
)
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -142,7 +142,7 @@ async def validate_image(
"""Validate an image source (URL or file path) and return a preview thumbnail."""
try:
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
from ledgrab.utils.safe_source import validate_image_path, validate_image_url
source = data.image_source.strip()
if not source:
@@ -161,7 +161,7 @@ async def validate_image(
img_bytes = path
def _process_image(src):
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
load_image_bytes,
load_image_file,
@@ -198,7 +198,7 @@ async def get_full_image(
):
"""Serve the full-resolution image for lightbox preview."""
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
from ledgrab.utils.safe_source import validate_image_path, validate_image_url
try:
if source.startswith(("http://", "https://")):
@@ -214,7 +214,7 @@ async def get_full_image(
img_bytes = path
def _encode_full(src):
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg,
load_image_bytes,
load_image_file,
@@ -375,9 +375,9 @@ async def get_video_thumbnail(
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Get a thumbnail for a video picture source (first frame)."""
from wled_controller.core.processing.video_stream import extract_thumbnail
from wled_controller.storage.picture_source import VideoCaptureSource
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
from ledgrab.core.processing.video_stream import extract_thumbnail
from ledgrab.storage.picture_source import VideoCaptureSource
from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down
try:
source = store.get_stream(stream_id)
@@ -385,7 +385,7 @@ async def get_video_thumbnail(
raise HTTPException(status_code=400, detail="Not a video source")
# Resolve video asset to file path
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from ledgrab.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
video_path = (
@@ -449,8 +449,8 @@ async def test_picture_source(
if isinstance(raw_stream, StaticImagePictureSource):
# Static image stream: load image from asset
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from wled_controller.utils.image_codec import load_image_file
from ledgrab.api.dependencies import get_asset_store as _get_asset_store
from ledgrab.utils.image_codec import load_image_file
asset_store = _get_asset_store()
image_path = (
@@ -531,7 +531,7 @@ async def test_picture_source(
image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread)
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
@@ -628,11 +628,11 @@ 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._preview_helpers import (
from ledgrab.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)
from wled_controller.api.dependencies import (
from ledgrab.api.dependencies import (
get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store,
get_pp_template_store as _get_pp_store,
@@ -662,8 +662,8 @@ async def test_picture_source_ws(
# Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
from ledgrab.core.processing.video_stream import VideoCaptureLiveStream
from ledgrab.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2()
video_path = (
@@ -690,7 +690,7 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down
if pw:
image = resize_down(image, pw)
@@ -5,34 +5,34 @@ import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
from ledgrab.api.schemas.common import (
CaptureImage,
PerformanceMetrics,
TemplateTestResponse,
)
from wled_controller.api.schemas.filters import FilterInstanceSchema
from wled_controller.api.schemas.postprocessing import (
from ledgrab.api.schemas.filters import FilterInstanceSchema
from ledgrab.api.schemas.postprocessing import (
PostprocessingTemplateCreate,
PostprocessingTemplateListResponse,
PostprocessingTemplateResponse,
PostprocessingTemplateUpdate,
PPTemplateTestRequest,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
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.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, FilterInstance, ImagePool
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -52,7 +52,11 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
)
@router.get("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateListResponse, tags=["Postprocessing Templates"])
@router.get(
"/api/v1/postprocessing-templates",
response_model=PostprocessingTemplateListResponse,
tags=["Postprocessing Templates"],
)
async def list_pp_templates(
_auth: AuthRequired,
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
@@ -63,7 +67,12 @@ async def list_pp_templates(
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
@router.post(
"/api/v1/postprocessing-templates",
response_model=PostprocessingTemplateResponse,
tags=["Postprocessing Templates"],
status_code=201,
)
async def create_pp_template(
data: PostprocessingTemplateCreate,
_auth: AuthRequired,
@@ -90,7 +99,11 @@ async def create_pp_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
@router.get(
"/api/v1/postprocessing-templates/{template_id}",
response_model=PostprocessingTemplateResponse,
tags=["Postprocessing Templates"],
)
async def get_pp_template(
template_id: str,
_auth: AuthRequired,
@@ -101,10 +114,16 @@ async def get_pp_template(
template = store.get_template(template_id)
return _pp_template_to_response(template)
except ValueError:
raise HTTPException(status_code=404, detail=f"Postprocessing template {template_id} not found")
raise HTTPException(
status_code=404, detail=f"Postprocessing template {template_id} not found"
)
@router.put("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
@router.put(
"/api/v1/postprocessing-templates/{template_id}",
response_model=PostprocessingTemplateResponse,
tags=["Postprocessing Templates"],
)
async def update_pp_template(
template_id: str,
data: PostprocessingTemplateUpdate,
@@ -113,7 +132,11 @@ async def update_pp_template(
):
"""Update a postprocessing template."""
try:
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
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,
@@ -133,7 +156,11 @@ async def update_pp_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"])
@router.delete(
"/api/v1/postprocessing-templates/{template_id}",
status_code=204,
tags=["Postprocessing Templates"],
)
async def delete_pp_template(
template_id: str,
_auth: AuthRequired,
@@ -149,7 +176,7 @@ async def delete_pp_template(
raise HTTPException(
status_code=409,
detail=f"Cannot delete postprocessing template: it is referenced by picture source(s): {names}. "
"Please reassign those streams before deleting.",
"Please reassign those streams before deleting.",
)
store.delete_template(template_id)
fire_entity_event("pp_template", "deleted", template_id)
@@ -165,7 +192,11 @@ async def delete_pp_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
@router.post(
"/api/v1/postprocessing-templates/{template_id}/test",
response_model=TemplateTestResponse,
tags=["Postprocessing Templates"],
)
async def test_pp_template(
template_id: str,
test_request: PPTemplateTestRequest,
@@ -194,7 +225,7 @@ async def test_pp_template(
raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
load_image_file,
thumbnail as make_thumbnail,
@@ -202,10 +233,14 @@ async def test_pp_template(
if isinstance(raw_stream, StaticImagePictureSource):
# Static image: load from asset
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from ledgrab.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
image_path = (
asset_store.get_file_path(raw_stream.image_asset_id)
if raw_stream.image_asset_id
else None
)
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
@@ -238,7 +273,9 @@ async def test_pp_template(
)
stream.initialize()
logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}")
logger.info(
f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}"
)
frame_count = 0
total_capture_time = 0.0
@@ -346,11 +383,11 @@ 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._preview_helpers import (
from ledgrab.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)
from wled_controller.api.dependencies import (
from ledgrab.api.dependencies import (
get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store,
get_pp_template_store as _get_pp_store,
@@ -400,7 +437,9 @@ async def test_pp_template_ws(
return
if capture_template.engine_type not in EngineRegistry.get_available_engines():
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
await websocket.close(
code=4003, reason=f"Engine '{capture_template.engine_type}' not available"
)
return
# Resolve PP filters
@@ -422,7 +461,9 @@ async def test_pp_template_ws(
try:
await stream_capture_test(
websocket, engine_factory, duration,
websocket,
engine_factory,
duration,
pp_filters=pp_filters,
preview_width=preview_width or None,
)
@@ -5,30 +5,30 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_scene_preset_store,
)
from wled_controller.api.schemas.scene_presets import (
from ledgrab.api.schemas.scene_presets import (
ActivateResponse,
ScenePresetCreate,
ScenePresetListResponse,
ScenePresetResponse,
ScenePresetUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.scenes.scene_activator import (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.core.scenes.scene_activator import (
apply_scene_state,
capture_current_snapshot,
)
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
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -39,13 +39,16 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
id=preset.id,
name=preset.name,
description=preset.description,
targets=[{
"target_id": t.target_id,
"running": t.running,
"color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps,
} for t in preset.targets],
targets=[
{
"target_id": t.target_id,
"running": t.running,
"color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps,
}
for t in preset.targets
],
order=preset.order,
tags=preset.tags,
created_at=preset.created_at,
@@ -55,6 +58,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
# ===== CRUD =====
@router.post(
"/api/v1/scene-presets",
response_model=ScenePresetResponse,
@@ -180,7 +184,9 @@ async def update_scene_preset(
tags=data.tags,
)
except ValueError as e:
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
raise HTTPException(
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
)
fire_entity_event("scene_preset", "updated", preset_id)
return _preset_to_response(preset)
@@ -206,6 +212,7 @@ async def delete_scene_preset(
# ===== Recapture =====
@router.post(
"/api/v1/scene-presets/{preset_id}/recapture",
response_model=ScenePresetResponse,
@@ -244,6 +251,7 @@ async def recapture_scene_preset(
# ===== Activate =====
@router.post(
"/api/v1/scene-presets/{preset_id}/activate",
response_model=ActivateResponse,
@@ -2,25 +2,25 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_sync_clock_manager,
get_sync_clock_store,
)
from wled_controller.api.schemas.sync_clocks import (
from ledgrab.api.schemas.sync_clocks import (
SyncClockCreate,
SyncClockListResponse,
SyncClockResponse,
SyncClockUpdate,
)
from wled_controller.storage.sync_clock import SyncClock
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
from ledgrab.storage.sync_clock import SyncClock
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -57,7 +57,9 @@ async def list_sync_clocks(
)
@router.post("/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"]
)
async def create_sync_clock(
data: SyncClockCreate,
_auth: AuthRequired,
@@ -81,7 +83,9 @@ async def create_sync_clock(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.get(
"/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def get_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -96,7 +100,9 @@ async def get_sync_clock(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.put(
"/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def update_sync_clock(
clock_id: str,
data: SyncClockUpdate,
@@ -138,9 +144,7 @@ async def delete_sync_clock(
# Check references
for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{source.name}'"
)
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
@@ -153,7 +157,10 @@ async def delete_sync_clock(
# ── Runtime control ──────────────────────────────────────────────────
@router.post("/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def pause_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -170,7 +177,9 @@ async def pause_sync_clock(
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def resume_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -187,7 +196,9 @@ async def resume_sync_clock(
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def reset_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -15,9 +15,9 @@ import os
import psutil
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__, REPO_URL, DONATE_URL
from wled_controller.api.auth import AuthRequired, is_auth_enabled
from wled_controller.api.dependencies import (
from ledgrab import __version__, REPO_URL, DONATE_URL
from ledgrab.api.auth import AuthRequired, is_auth_enabled
from ledgrab.api.dependencies import (
get_audio_source_store,
get_audio_template_store,
get_automation_store,
@@ -34,7 +34,7 @@ from wled_controller.api.dependencies import (
get_template_store,
get_value_source_store,
)
from wled_controller.api.schemas.system import (
from ledgrab.api.schemas.system import (
DisplayInfo,
DisplayListResponse,
GpuInfo,
@@ -43,13 +43,13 @@ from wled_controller.api.schemas.system import (
ProcessListResponse,
VersionResponse,
)
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 get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.config import get_config, is_demo_mode
from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
# Re-export load_external_url so existing callers still work
from wled_controller.api.routes.system_settings import load_external_url # noqa: F401
from ledgrab.api.routes.system_settings import load_external_url # noqa: F401
logger = get_logger(__name__)
@@ -59,7 +59,7 @@ _process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import ( # noqa: E402
from ledgrab.utils.gpu import ( # noqa: E402
nvml_available as _nvml_available,
nvml as _nvml,
nvml_handle as _nvml_handle,
@@ -139,7 +139,7 @@ async def get_version():
async def list_all_tags(_: AuthRequired):
"""Get all tags used across all entities."""
all_tags: set[str] = set()
from wled_controller.api.dependencies import get_asset_store
from ledgrab.api.dependencies import get_asset_store
store_getters = [
get_device_store,
@@ -185,7 +185,7 @@ async def get_displays(
logger.info(f"Listing available displays (engine_type={engine_type})")
try:
from wled_controller.core.capture_engines import EngineRegistry
from ledgrab.core.capture_engines import EngineRegistry
if engine_type:
engine_cls = EngineRegistry.get_engine(engine_type)
@@ -240,7 +240,7 @@ async def get_running_processes(_: AuthRequired):
Returns a sorted list of unique process names for use in automation conditions.
"""
from wled_controller.core.automations.platform_detector import PlatformDetector
from ledgrab.core.automations.platform_detector import PlatformDetector
try:
detector = PlatformDetector()
@@ -348,7 +348,7 @@ async def get_integrations_status(
Used by the dashboard to show connectivity indicators.
"""
from wled_controller.core.devices.mqtt_client import get_mqtt_service
from ledgrab.core.devices.mqtt_client import get_mqtt_service
# MQTT status
mqtt_service = get_mqtt_service()
@@ -10,9 +10,9 @@ import re
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_database
from wled_controller.api.schemas.system import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_database
from ledgrab.api.schemas.system import (
ExternalUrlRequest,
ExternalUrlResponse,
LogLevelRequest,
@@ -20,9 +20,9 @@ from wled_controller.api.schemas.system import (
MQTTSettingsRequest,
MQTTSettingsResponse,
)
from wled_controller.config import get_config
from wled_controller.storage.database import Database
from wled_controller.utils import get_logger
from ledgrab.config import get_config
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -76,7 +76,9 @@ async def get_mqtt_settings(_: AuthRequired, db: Database = Depends(get_database
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest, db: Database = Depends(get_database)):
async def update_mqtt_settings(
_: AuthRequired, body: MQTTSettingsRequest, db: Database = Depends(get_database)
):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings(db)
@@ -110,10 +112,12 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest, db: D
# External URL setting
# ---------------------------------------------------------------------------
def load_external_url(db: Database | None = None) -> str:
"""Load the external URL setting. Returns empty string if not set."""
if db is None:
from wled_controller.api.dependencies import get_database
from ledgrab.api.dependencies import get_database
db = get_database()
data = db.get_setting("external_url")
if data:
@@ -136,7 +140,9 @@ async def get_external_url(_: AuthRequired, db: Database = Depends(get_database)
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest, db: Database = Depends(get_database)):
async def update_external_url(
_: AuthRequired, body: ExternalUrlRequest, db: Database = Depends(get_database)
):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
db.set_setting("external_url", {"external_url": url})
@@ -159,8 +165,8 @@ async def logs_ws(
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
from ledgrab.api.auth import verify_ws_token
from ledgrab.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -205,9 +211,7 @@ async def logs_ws(
# ---------------------------------------------------------------------------
# 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})?$"
)
_ADB_ADDRESS_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$")
class AdbConnectRequest(BaseModel):
@@ -244,7 +248,8 @@ def _validate_adb_address(address: str) -> None:
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
from ledgrab.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@@ -265,7 +270,9 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
logger.info(f"Connecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
adb,
"connect",
address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -295,7 +302,9 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
logger.info(f"Disconnecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
adb,
"disconnect",
address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -5,20 +5,20 @@ import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_cspt_store,
get_picture_source_store,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
from ledgrab.api.schemas.common import (
CaptureImage,
PerformanceMetrics,
TemplateTestResponse,
)
from wled_controller.api.schemas.templates import (
from ledgrab.api.schemas.templates import (
EngineInfo,
EngineListResponse,
TemplateCreate,
@@ -27,18 +27,18 @@ from wled_controller.api.schemas.templates import (
TemplateTestRequest,
TemplateUpdate,
)
from wled_controller.api.schemas.filters import (
from ledgrab.api.schemas.filters import (
FilterOptionDefSchema,
FilterTypeListResponse,
FilterTypeResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry
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
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.picture_source import ScreenCapturePictureSource
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -47,6 +47,7 @@ router = APIRouter()
# ===== CAPTURE TEMPLATE ENDPOINTS =====
@router.get("/api/v1/capture-templates", response_model=TemplateListResponse, tags=["Templates"])
async def list_templates(
_auth: AuthRequired,
@@ -80,7 +81,12 @@ async def list_templates(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
@router.post(
"/api/v1/capture-templates",
response_model=TemplateResponse,
tags=["Templates"],
status_code=201,
)
async def create_template(
template_data: TemplateCreate,
_auth: AuthRequired,
@@ -111,7 +117,6 @@ async def create_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:
@@ -119,7 +124,9 @@ async def create_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
@router.get(
"/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"]
)
async def get_template(
template_id: str,
_auth: AuthRequired,
@@ -143,7 +150,9 @@ async def get_template(
)
@router.put("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
@router.put(
"/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"]
)
async def update_template(
template_id: str,
update_data: TemplateUpdate,
@@ -176,7 +185,6 @@ async def update_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:
@@ -199,7 +207,10 @@ async def delete_template(
# Check if any streams are using this template
streams_using_template = []
for stream in stream_store.get_all_streams():
if isinstance(stream, ScreenCapturePictureSource) and stream.capture_template_id == template_id:
if (
isinstance(stream, ScreenCapturePictureSource)
and stream.capture_template_id == template_id
):
streams_using_template.append(stream.name)
if streams_using_template:
@@ -207,7 +218,7 @@ async def delete_template(
raise HTTPException(
status_code=409,
detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. "
f"Please reassign these streams to a different template before deleting."
f"Please reassign these streams to a different template before deleting.",
)
# Proceed with deletion
@@ -245,7 +256,7 @@ async def list_engines(_auth: AuthRequired):
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
available=(engine_type in available_set),
has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False),
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
)
)
@@ -256,7 +267,9 @@ async def list_engines(_auth: AuthRequired):
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
@router.post(
"/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"]
)
def test_template(
test_request: TemplateTestRequest,
_auth: AuthRequired,
@@ -276,7 +289,7 @@ def test_template(
if test_request.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{test_request.engine_type}' is not available on this system"
detail=f"Engine '{test_request.engine_type}' is not available on this system",
)
# Create and initialize capture stream
@@ -286,7 +299,9 @@ def test_template(
stream.initialize()
# Run sustained capture test
logger.info(f"Starting {test_request.capture_duration}s capture test with {test_request.engine_type}")
logger.info(
f"Starting {test_request.capture_duration}s capture test with {test_request.engine_type}"
)
frame_count = 0
total_capture_time = 0.0
@@ -321,7 +336,7 @@ def test_template(
raise ValueError("Unexpected image format from engine")
image = last_frame.image
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
@@ -361,7 +376,6 @@ 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:
@@ -391,7 +405,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._preview_helpers import (
from ledgrab.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)
@@ -417,7 +431,9 @@ async def test_template_ws(
pw = int(config.get("preview_width", 0)) or None
if engine_type not in EngineRegistry.get_available_engines():
await websocket.send_json({"type": "error", "detail": f"Engine '{engine_type}' not available"})
await websocket.send_json(
{"type": "error", "detail": f"Engine '{engine_type}' not available"}
)
await websocket.close(code=4003)
return
@@ -428,7 +444,9 @@ async def test_template_ws(
s.initialize()
return s
logger.info(f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)")
logger.info(
f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)"
)
try:
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
@@ -443,6 +461,7 @@ async def test_template_ws(
# ===== FILTER TYPE ENDPOINTS =====
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_filter_types(
_auth: AuthRequired,
@@ -467,23 +486,31 @@ async def list_filter_types(
for opt in schema:
choices = opt.choices
# Enrich filter_template choices with current template list
if filter_id == "filter_template" and opt.key == "template_id" and template_choices is not None:
if (
filter_id == "filter_template"
and opt.key == "template_id"
and template_choices is not None
):
choices = template_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,
))
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))
@@ -512,21 +539,29 @@ async def list_strip_filter_types(
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:
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,
))
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))
@@ -3,16 +3,16 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_update_service
from wled_controller.api.schemas.update import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_update_service
from ledgrab.api.schemas.update import (
DismissRequest,
UpdateSettingsRequest,
UpdateSettingsResponse,
UpdateStatusResponse,
)
from wled_controller.core.update.update_service import UpdateService
from wled_controller.utils import get_logger
from ledgrab.core.update.update_service import UpdateService
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -57,7 +57,9 @@ async def apply_update(
if not status["can_auto_update"]:
return JSONResponse(
status_code=400,
content={"detail": f"Auto-update not supported for install type: {status['install_type']}"},
content={
"detail": f"Auto-update not supported for install type: {status['install_type']}"
},
)
try:
await service.apply_update()
@@ -5,14 +5,14 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_value_source_store,
)
from wled_controller.api.schemas.value_sources import (
from ledgrab.api.schemas.value_sources import (
AdaptiveSceneValueSourceResponse,
AdaptiveTimeColorValueSourceResponse,
AdaptiveTimeValueSourceResponse,
@@ -31,7 +31,7 @@ from wled_controller.api.schemas.value_sources import (
ValueSourceResponse,
ValueSourceUpdate,
)
from wled_controller.storage.value_source import (
from ledgrab.storage.value_source import (
AdaptiveTimeColorValueSource,
AdaptiveValueSource,
AnimatedColorValueSource,
@@ -46,12 +46,12 @@ from wled_controller.storage.value_source import (
SystemMetricsValueSource,
ValueSource,
)
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.core.processing.value_stream import ValueStream
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.core.processing.value_stream import ValueStream
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -340,7 +340,7 @@ async def delete_value_source(
"""Delete a value source."""
try:
# Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget):
@@ -370,7 +370,7 @@ 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.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -2,25 +2,25 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_weather_manager,
get_weather_source_store,
)
from wled_controller.api.schemas.weather_sources import (
from ledgrab.api.schemas.weather_sources import (
WeatherSourceCreate,
WeatherSourceListResponse,
WeatherSourceResponse,
WeatherSourceUpdate,
WeatherTestResponse,
)
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.weather.weather_provider import WMO_CONDITION_NAMES
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.weather_source import WeatherSource
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.utils import get_logger
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.weather.weather_provider import WMO_CONDITION_NAMES
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.weather_source import WeatherSource
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -44,7 +44,9 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse:
)
@router.get("/api/v1/weather-sources", response_model=WeatherSourceListResponse, tags=["Weather Sources"])
@router.get(
"/api/v1/weather-sources", response_model=WeatherSourceListResponse, tags=["Weather Sources"]
)
async def list_weather_sources(
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
@@ -56,7 +58,12 @@ async def list_weather_sources(
)
@router.post("/api/v1/weather-sources", response_model=WeatherSourceResponse, status_code=201, tags=["Weather Sources"])
@router.post(
"/api/v1/weather-sources",
response_model=WeatherSourceResponse,
status_code=201,
tags=["Weather Sources"],
)
async def create_weather_source(
data: WeatherSourceCreate,
_auth: AuthRequired,
@@ -79,7 +86,11 @@ async def create_weather_source(
return _to_response(source)
@router.get("/api/v1/weather-sources/{source_id}", response_model=WeatherSourceResponse, tags=["Weather Sources"])
@router.get(
"/api/v1/weather-sources/{source_id}",
response_model=WeatherSourceResponse,
tags=["Weather Sources"],
)
async def get_weather_source(
source_id: str,
_auth: AuthRequired,
@@ -91,7 +102,11 @@ async def get_weather_source(
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
@router.put("/api/v1/weather-sources/{source_id}", response_model=WeatherSourceResponse, tags=["Weather Sources"])
@router.put(
"/api/v1/weather-sources/{source_id}",
response_model=WeatherSourceResponse,
tags=["Weather Sources"],
)
async def update_weather_source(
source_id: str,
data: WeatherSourceUpdate,
@@ -133,7 +148,11 @@ async def delete_weather_source(
fire_entity_event("weather_source", "deleted", source_id)
@router.post("/api/v1/weather-sources/{source_id}/test", response_model=WeatherTestResponse, tags=["Weather Sources"])
@router.post(
"/api/v1/weather-sources/{source_id}/test",
response_model=WeatherTestResponse,
tags=["Weather Sources"],
)
async def test_weather_source(
source_id: str,
_auth: AuthRequired,
@@ -13,11 +13,11 @@ 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
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import WebhookCondition
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.utils import get_logger
from ledgrab.api.dependencies import get_automation_engine, get_automation_store
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import WebhookCondition
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
@@ -75,12 +75,17 @@ async def handle_webhook(
# Find the automation that owns this token
for automation in store.get_all_automations():
for condition in automation.conditions:
if isinstance(condition, WebhookCondition) and secrets.compare_digest(condition.token, token):
if isinstance(condition, WebhookCondition) and secrets.compare_digest(
condition.token, token
):
active = body.action == "activate"
await engine.set_webhook_state(token, active)
logger.info(
"Webhook %s: automation '%s' (%s) → %s",
token[:8], automation.name, automation.id, body.action,
token[:8],
automation.name,
automation.id,
body.action,
)
return {
"ok": True,
@@ -10,7 +10,9 @@ class AudioTemplateCreate(BaseModel):
"""Request to create an audio capture template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1)
engine_type: str = Field(
description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1
)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -12,7 +12,9 @@ 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")
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")
@@ -21,7 +23,9 @@ 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")
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
@@ -5,7 +5,7 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from wled_controller.api.schemas.devices import Calibration
from ledgrab.api.schemas.devices import Calibration
# =====================================================================
@@ -48,5 +48,7 @@ class TemplateTestResponse(BaseModel):
"""Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)")
border_extraction: Optional[BorderExtraction] = Field(
None, description="Extracted border images (deprecated)"
)
performance: PerformanceMetrics = Field(description="Performance metrics")
@@ -22,8 +22,12 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
max_length: Optional[int] = Field(default=None, description="Maximum string length for string type")
choices: Optional[List[Dict[str, str]]] = Field(
default=None, description="Available choices for select type"
)
max_length: Optional[int] = Field(
default=None, description="Maximum string length for string type"
)
class FilterTypeResponse(BaseModel):

Some files were not shown because too many files have changed in this diff Show More