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 ## Code Search
+4 -4
View File
@@ -9,8 +9,8 @@
## Development Setup ## Development Setup
```bash ```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd wled-screen-controller-mixed/server cd ledgrab/server
# Python environment # Python environment
python -m venv venv python -m venv venv
@@ -29,7 +29,7 @@ npm run build
cd server cd server
export PYTHONPATH=$(pwd)/src # Linux/Mac export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows # set PYTHONPATH=%CD%\src # Windows
python -m wled_controller.main python -m ledgrab.main
``` ```
Open http://localhost:8080 to access the dashboard. Open http://localhost:8080 to access the dashboard.
@@ -55,7 +55,7 @@ ruff check src/ tests/
## Frontend Changes ## 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 ```bash
cd server cd server
+29 -79
View File
@@ -1,15 +1,17 @@
# Installation Guide # 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 ## Table of Contents
1. [Docker Installation (recommended)](#docker-installation) 1. [Docker Installation (recommended)](#docker-installation)
2. [Manual Installation](#manual-installation) 2. [Manual Installation](#manual-installation)
3. [First-Time Setup](#first-time-setup) 3. [First-Time Setup](#first-time-setup)
4. [Home Assistant Integration](#home-assistant-integration) 4. [Configuration Reference](#configuration-reference)
5. [Configuration Reference](#configuration-reference) 5. [Troubleshooting](#troubleshooting)
6. [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:** 1. **Clone and start:**
```bash ```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd wled-screen-controller/server cd ledgrab/server
docker compose up -d docker compose up -d
``` ```
@@ -54,7 +56,7 @@ cd server
docker build -t ledgrab . docker build -t ledgrab .
docker run -d \ docker run -d \
--name wled-screen-controller \ --name ledgrab \
-p 8080:8080 \ -p 8080:8080 \
-v $(pwd)/data:/app/data \ -v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \ -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:** 1. **Clone the repository:**
```bash ```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd wled-screen-controller/server cd ledgrab/server
``` ```
2. **Build the frontend bundle:** 2. **Build the frontend bundle:**
@@ -95,7 +97,7 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
npm run build 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:** 3. **Create a virtual environment:**
@@ -131,11 +133,11 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m
```bash ```bash
# Linux / macOS # Linux / macOS
export PYTHONPATH=$(pwd)/src 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) # Windows (cmd)
set PYTHONPATH=%CD%\src 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. 6. **Verify:** open <http://localhost:8080> in your browser.
@@ -160,7 +162,7 @@ auth:
Option B -- set an environment variable: Option B -- set an environment variable:
```bash ```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: Generate a random key:
@@ -184,7 +186,7 @@ server:
Or via environment variable: Or via environment variable:
```bash ```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 ### 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 ## Configuration Reference
The server reads configuration from three sources (in order of priority): 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`) 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 `WLED_CONFIG_PATH` to override) 2. **YAML config file** -- `server/config/default_config.yaml` (or set `LEDGRAB_CONFIG_PATH` to override)
3. **Built-in defaults** 3. **Built-in defaults**
See [`server/.env.example`](server/.env.example) for every available variable with descriptions. 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 | | Variable | Default | Description |
| -------- | ------- | ----------- | | -------- | ------- | ----------- |
| `WLED_SERVER__PORT` | `8080` | HTTP listen port | | `LEDGRAB_SERVER__PORT` | `8080` | HTTP listen port |
| `WLED_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | | `LEDGRAB_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `WLED_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) | | `LEDGRAB_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) |
| `WLED_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) | | `LEDGRAB_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) |
| `WLED_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path | | `LEDGRAB_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path |
| `WLED_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery | | `LEDGRAB_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery |
| `WLED_MQTT__BROKER_HOST` | `localhost` | MQTT broker address | | `LEDGRAB_MQTT__BROKER_HOST` | `localhost` | MQTT broker address |
| `WLED_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) | | `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:** **Check the frontend bundle exists:**
```bash ```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`. 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 docker compose logs -f
# Manual install # Manual install
tail -f logs/wled_controller.log tail -f logs/ledgrab.log
``` ```
### Cannot access the dashboard from another machine ### 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. 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). 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 ### WLED device not responding
1. Confirm the device is powered on and connected to Wi-Fi. 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) - [API Documentation](docs/API.md)
- [Calibration Guide](docs/CALIBRATION.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) ### Docker (recommended)
```bash ```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd wled-screen-controller/server cd ledgrab/server
docker compose up -d docker compose up -d
``` ```
@@ -97,8 +97,8 @@ docker compose up -d
Requires Python 3.11+ and Node.js 18+. Requires Python 3.11+ and Node.js 18+.
```bash ```bash
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed.git git clone https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
cd wled-screen-controller/server cd ledgrab/server
# Build the frontend bundle # Build the frontend bundle
npm ci && npm run build npm ci && npm run build
@@ -112,7 +112,7 @@ pip install .
# Start the server # Start the server
export PYTHONPATH=$(pwd)/src # Linux/Mac export PYTHONPATH=$(pwd)/src # Linux/Mac
# set PYTHONPATH=%CD%\src # Windows # 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. 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. 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 ```bash
# Docker # Docker
docker compose run -e WLED_DEMO=true server docker compose run -e LEDGRAB_DEMO=true server
# Python # 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) # Windows (installed app)
set WLED_DEMO=true set LEDGRAB_DEMO=true
LedGrab.bat LedGrab.bat
``` ```
@@ -144,9 +144,9 @@ Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores
## Architecture ## Architecture
```text ```text
wled-screen-controller/ ledgrab/
├── server/ # Python FastAPI backend ├── server/ # Python FastAPI backend
│ ├── src/wled_controller/ │ ├── src/ledgrab/
│ │ ├── main.py # Application entry point │ │ ├── main.py # Application entry point
│ │ ├── config.py # YAML + env var configuration │ │ ├── config.py # YAML + env var configuration
│ │ ├── api/ │ │ ├── api/
@@ -171,8 +171,6 @@ wled-screen-controller/
│ ├── tests/ # pytest suite │ ├── tests/ # pytest suite
│ ├── Dockerfile │ ├── Dockerfile
│ └── docker-compose.yml │ └── docker-compose.yml
├── custom_components/ # Home Assistant integration (HACS)
│ └── wled_screen_controller/
├── docs/ ├── docs/
│ ├── API.md # REST API reference │ ├── API.md # REST API reference
│ └── CALIBRATION.md # LED calibration guide │ └── CALIBRATION.md # LED calibration guide
@@ -182,7 +180,7 @@ wled-screen-controller/
## Configuration ## 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 ```yaml
server: server:
@@ -200,11 +198,11 @@ storage:
logging: logging:
format: "json" format: "json"
file: "logs/wled_controller.log" file: "logs/ledgrab.log"
max_size_mb: 100 max_size_mb: 100
``` ```
Environment variable override example: `WLED_SERVER__PORT=9090`. Environment variable override example: `LEDGRAB_SERVER__PORT=9090`.
## API ## API
@@ -234,9 +232,7 @@ See [docs/CALIBRATION.md](docs/CALIBRATION.md) for a step-by-step guide.
## Home Assistant ## 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. For Home Assistant integration, see the separate [ledgrab-haos-integration](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration) repository.
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
## Development ## Development
+125 -125
View File
@@ -5,96 +5,96 @@ This release brings a major expansion of integrations and source types: Home Ass
### Features ### Features
#### Home Assistant Integration #### 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)) - 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/wled-screen-controller-mixed/commit/cb9289f)) - 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/wled-screen-controller-mixed/commit/324a308)) - 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/wled-screen-controller-mixed/commit/40751fe)) - 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/wled-screen-controller-mixed/commit/e7c9a56)) - HA source cards use health-dot indicators ([e7c9a56](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/e7c9a56))
#### Integrations & Tabs #### Integrations & Tabs
- New **Integrations** tab and responsive icon-only tabs ([b7da4ab](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/b7da4ab)) - 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/wled-screen-controller-mixed/commit/c59107c)) - 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/wled-screen-controller-mixed/commit/492bdb9)) - Game integration system ([492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/492bdb9))
#### Audio #### Audio
- Processed audio sources — audio filter framework ([86a9d34](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/86a9d34)) - 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/wled-screen-controller-mixed/commit/eb94066)) - 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/wled-screen-controller-mixed/commit/353c090), [ab43578](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/ab43578)) - 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/wled-screen-controller-mixed/commit/5534639), [1ce0dc6](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/1ce0dc6)) - 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/wled-screen-controller-mixed/commit/b04978a)) - Music sync viz modes and `auto_gain` audio filter ([b04978a](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b04978a))
#### Value Sources #### 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)) - **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/wled-screen-controller-mixed/commit/384362c)) - 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/wled-screen-controller-mixed/commit/b6713be)) - `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/wled-screen-controller-mixed/commit/f6c25cd)) - 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/wled-screen-controller-mixed/commit/0a87371)) - 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/wled-screen-controller-mixed/commit/4b7a8d7)) - Value source card crosslinks + gradient_map test shows input value ([4b7a8d7](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/4b7a8d7))
#### Sources & Assets #### 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)) - 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/wled-screen-controller-mixed/commit/ace2471)) - `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/wled-screen-controller-mixed/commit/3e0bf85)) - `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/wled-screen-controller-mixed/commit/f61a020)) - Custom file drop zone for asset upload modal ([f61a020](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/f61a020))
#### UI & UX #### 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)) - 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/wled-screen-controller-mixed/commit/db5008a)) - 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/wled-screen-controller-mixed/commit/f3d07fc)) - 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/wled-screen-controller-mixed/commit/5f70302)) - Custom app icon for shortcuts and installer ([5f70302](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/5f70302))
#### Runtime #### 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 ### Bug Fixes
- Tray: replace tkinter messagebox with Win32 `MessageBoxW` ([d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e)) - 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/wled-screen-controller-mixed/commit/fc8ee34)) - 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 `WLED_CONFIG_PATH` in `start-hidden.vbs` ([e262a8b](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/e262a8b)) - 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/wled-screen-controller-mixed/commit/6e8b159)) - 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/wled-screen-controller-mixed/commit/edc6d27)) - 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/wled-screen-controller-mixed/commit/99460a8)) - `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/wled-screen-controller-mixed/commit/af2c89c)) - 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/wled-screen-controller-mixed/commit/d04192f)) - 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/wled-screen-controller-mixed/commit/11d5d6b)) - 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/wled-screen-controller-mixed/commit/a9e6e8c)) - 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/wled-screen-controller-mixed/commit/78ce6c8)) - 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/wled-screen-controller-mixed/commit/381ee75)) - 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/wled-screen-controller-mixed/commit/89d1b13)) - 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/wled-screen-controller-mixed/commit/c0853ce)) - 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/wled-screen-controller-mixed/commit/be4c98b)) - 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/wled-screen-controller-mixed/commit/dca2d21)) - 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/wled-screen-controller-mixed/commit/53986f8)) - 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/wled-screen-controller-mixed/commit/a4a9f6f)) - Send `gradient_id` instead of palette in effect transient preview ([a4a9f6f](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/a4a9f6f))
--- ---
### Development / Internal ### Development / Internal
#### Build #### Build
- Drop `packaging` dependency, inline version parsing ([d4ffe2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d4ffe2e)) - 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/wled-screen-controller-mixed/commit/feb91ad)) - 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/wled-screen-controller-mixed/commit/17c5c02)) - 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/wled-screen-controller-mixed/commit/fd6776a)) - 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/wled-screen-controller-mixed/commit/9f34ffb)) - 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/wled-screen-controller-mixed/commit/b5842e6)) - Normalize non-PEP440 versions, fix `.py`/`compileall` ordering, wipe NSIS payload dirs ([b5842e6](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/b5842e6))
#### CI #### CI
- Add manual build workflow for testing artifacts ([fb98e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/fb98e6e)) - 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/wled-screen-controller-mixed/commit/9fcfdb8)) - Use sparse checkout for release notes in release workflow ([9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9fcfdb8))
#### Refactoring #### 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)) - 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/wled-screen-controller-mixed/commit/3e6760f)) - 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/wled-screen-controller-mixed/commit/3c2efd5)) - Move Weather and Home Assistant sources to Integrations tree group ([3c2efd5](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/3c2efd5))
#### Tests #### Tests
- Isolate tests from production database ([992495e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/992495e)) - 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/wled-screen-controller-mixed/commit/ce1f484), [6b0e4e5](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/6b0e4e5)) - 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 #### Chores
- Remove processed-audio-sources plan files ([89990f8](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/89990f8)) - 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/wled-screen-controller-mixed/commit/f345687)) - 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 | | Hash | Message |
|------|---------| |------|---------|
| [d037a2e](https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/commit/d037a2e) | fix(tray): replace tkinter messagebox with Win32 MessageBoxW | | [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/wled-screen-controller-mixed/commit/fc8ee34) | fix(launcher): start-hidden.vbs must be ASCII + CRLF, use python.exe | | [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/wled-screen-controller-mixed/commit/e262a8b) | fix(launcher): set PYTHONPATH and WLED_CONFIG_PATH in start-hidden.vbs | | [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/wled-screen-controller-mixed/commit/d4ffe2e) | refactor: drop packaging dependency, inline version parsing | | [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/wled-screen-controller-mixed/commit/feb91ad) | fix(build): fix shell syntax error in smoke_test_imports heredoc | | [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/wled-screen-controller-mixed/commit/17c5c02) | fix(build): keep .py sources + make smoke test skip uninstalled modules | | [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/wled-screen-controller-mixed/commit/fd6776a) | fix(build): stop stripping zeroconf/_services + add import smoke test | | [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/wled-screen-controller-mixed/commit/9f34ffb) | fix(build): stop stripping numpy.lib/linalg from site-packages | | [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/wled-screen-controller-mixed/commit/b5842e6) | fix(build): normalize non-PEP440 versions, fix .py/compileall ordering, wipe NSIS payload dirs | | [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/wled-screen-controller-mixed/commit/7a9c368) | refactor: split color-strips.ts into focused modules under color-strips/ folder | | [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/wled-screen-controller-mixed/commit/ce53ca6) | feat: add card glare effect to dashboard and perf chart cards | | [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/wled-screen-controller-mixed/commit/b04978a) | feat: add music sync viz modes and auto_gain audio filter | | [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/wled-screen-controller-mixed/commit/6e8b159) | fix: weather CSS card shows empty source name after hard refresh | | [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/wled-screen-controller-mixed/commit/ace2471) | feat: add math_wave color strip source type | | [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/wled-screen-controller-mixed/commit/edc6d27) | fix: replace HA test icon with refresh, make automation rules collapsible | | [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/wled-screen-controller-mixed/commit/b7da4ab) | feat: add Integrations tab and responsive icon-only tabs | | [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/wled-screen-controller-mixed/commit/99460a8) | fix: make pystray a core dependency on Windows instead of optional extra | | [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/wled-screen-controller-mixed/commit/89990f8) | chore: remove processed-audio-sources plan files | | [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/wled-screen-controller-mixed/commit/af2c89c) | fix: audio tree structure, filter i18n, and IconSelect for filter options | | [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/wled-screen-controller-mixed/commit/d04192f) | fix: add reference check before deleting audio processing template | | [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/wled-screen-controller-mixed/commit/992495e) | fix: isolate tests from production database | | [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/wled-screen-controller-mixed/commit/6b0e4e5) | feat(processed-audio-sources): phase 8 - frontend design consistency review | | [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/wled-screen-controller-mixed/commit/ce1f484) | feat(processed-audio-sources): phase 7 - testing and polish | | [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/wled-screen-controller-mixed/commit/1ce0dc6) | feat(processed-audio-sources): phase 6 - frontend source type cleanup | | [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/wled-screen-controller-mixed/commit/5534639) | feat(processed-audio-sources): phase 5 - frontend audio processing templates | | [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/wled-screen-controller-mixed/commit/ab43578) | feat(processed-audio-sources): phase 4 - runtime filter integration | | [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/wled-screen-controller-mixed/commit/353c090) | feat(processed-audio-sources): phase 3 - processed audio source model | | [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/wled-screen-controller-mixed/commit/eb94066) | feat(processed-audio-sources): phase 2 - implement 11 audio filters | | [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/wled-screen-controller-mixed/commit/86a9d34) | feat(processed-audio-sources): phase 1 - audio filter framework | | [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/wled-screen-controller-mixed/commit/c59107c) | feat: refactor MQTT from global config to multi-instance entity model | | [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/wled-screen-controller-mixed/commit/e7c9a56) | feat: HA source cards use health-dot indicators | | [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/wled-screen-controller-mixed/commit/492bdb9) | feat: game integration system | | [492bdb9](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/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 | | [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/wled-screen-controller-mixed/commit/db5008a) | feat: system theme option + fix toast timer overlap | | [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/wled-screen-controller-mixed/commit/4b7a8d7) | feat: value source card crosslinks + gradient_map test shows input value | | [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/wled-screen-controller-mixed/commit/f6c25cd) | feat: color value source test visualization | | [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/wled-screen-controller-mixed/commit/0a87371) | feat: HA value source test — raw value axis + behavior IconSelect | | [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/wled-screen-controller-mixed/commit/11d5d6b) | fix: device card header layout — URL badge overflow and hide button gap | | [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/wled-screen-controller-mixed/commit/384362c) | feat: new value source types (HA entity, gradient map, strip extract) + UI fixes | | [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/wled-screen-controller-mixed/commit/ea812bb) | feat: check if port is busy before starting the server | | [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/wled-screen-controller-mixed/commit/a9e6e8c) | fix: KC color strip test preview — use LiveStreamManager instead of raw engine | | [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/wled-screen-controller-mixed/commit/78ce6c8) | fix: composite layer opacity/brightness widgets + CSS layout | | [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/wled-screen-controller-mixed/commit/8a17bb5) | feat: BindableFloat — universal value source binding for all scalar properties | | [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/wled-screen-controller-mixed/commit/5f70302) | feat: use custom app icon for shortcuts and installer | | [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/wled-screen-controller-mixed/commit/40751fe) | feat: HA light target live color preview — per-entity swatches via WebSocket | | [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/wled-screen-controller-mixed/commit/381ee75) | fix: HA light target — brightness source, transition=0, dashboard type label | | [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/wled-screen-controller-mixed/commit/3e6760f) | refactor: key colors targets → CSS source type, HA target improvements | | [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/wled-screen-controller-mixed/commit/89d1b13) | fix: rename HA Lights → Home Assistant, HA Light Targets → Light Targets | | [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/wled-screen-controller-mixed/commit/324a308) | feat: entity picker for HA light mapping — searchable EntitySelect for light entities | | [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/wled-screen-controller-mixed/commit/cb9289f) | feat: HA light output targets — cast LED colors to Home Assistant lights | | [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/wled-screen-controller-mixed/commit/fb98e6e) | ci: add manual build workflow for testing artifacts | | [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/wled-screen-controller-mixed/commit/3c2efd5) | refactor: move Weather and Home Assistant sources to Integrations tree group | | [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/wled-screen-controller-mixed/commit/2153dde) | feat: Home Assistant integration — WebSocket connection, automation conditions, UI | | [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/wled-screen-controller-mixed/commit/f3d07fc) | feat: donation banner, About tab, settings UI improvements | | [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/wled-screen-controller-mixed/commit/f61a020) | feat: custom file drop zone for asset upload modal; fix review issues | | [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/wled-screen-controller-mixed/commit/f345687) | chore: remove python3.11 version pin from pre-commit config | | [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/wled-screen-controller-mixed/commit/e2e1107) | feat: asset-based image/video sources, notification sounds, UI improvements | | [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/wled-screen-controller-mixed/commit/c0853ce) | fix: improve command palette actions and automation condition button | | [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/wled-screen-controller-mixed/commit/3e0bf85) | feat: add api_input LED interpolation; fix LED preview, FPS charts, dashboard layout | | [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/wled-screen-controller-mixed/commit/be4c98b) | fix: show template name instead of ID in filter list and card badges | | [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/wled-screen-controller-mixed/commit/dca2d21) | fix: clip graph node title and subtitle to prevent overflow | | [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/wled-screen-controller-mixed/commit/53986f8) | fix: replace emoji with SVG icons on weather and daylight cards | | [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/wled-screen-controller-mixed/commit/a4a9f6f) | fix: send gradient_id instead of palette in effect transient preview | | [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/wled-screen-controller-mixed/commit/9fcfdb8) | ci: use sparse checkout for release notes in release workflow | | [9fcfdb8](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/commit/9fcfdb8) | ci: use sparse checkout for release notes in release workflow |
</details> </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 - [ ] 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 - [ ] 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 - [ ] 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 - [ ] 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) - [ ] **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 find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
fi fi
# ── Remove wled_controller if pip-installed ─────────────── # ── Remove ledgrab if pip-installed ───────────────
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true rm -rf "$sp_dir"/ledgrab* "$sp_dir"/ledgrab*.dist-info 2>/dev/null || true
local cleaned_size local cleaned_size
cleaned_size=$(du -sh "$sp_dir" | cut -f1) cleaned_size=$(du -sh "$sp_dir" | cut -f1)
@@ -191,7 +191,7 @@ compile_and_strip_sources() {
# ── Import smoke test ──────────────────────────────────────── # ── 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 # uses can be imported from the stripped site-packages. Catches regressions
# where cleanup_site_packages removes a submodule that turns out to be # where cleanup_site_packages removes a submodule that turns out to be
# imported internally by the package (e.g. numpy.linalg, zeroconf._services). # imported internally by the package (e.g. numpy.linalg, zeroconf._services).
@@ -200,7 +200,7 @@ compile_and_strip_sources() {
# Args: # Args:
# $1 — path to site-packages to test against # $1 — path to site-packages to test against
# $2 — python executable # $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() { smoke_test_imports() {
local sp_dir="$1" 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" echo 'Lib\site-packages' >> "$PTH_FILE"
fi fi
# Embedded Python ._pth overrides PYTHONPATH, so we must add the app # 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 if ! grep -q '\.\./app/src' "$PTH_FILE"; then
echo '../app/src' >> "$PTH_FILE" echo '../app/src' >> "$PTH_FILE"
fi fi
@@ -325,14 +325,14 @@ cd /d "%~dp0"
:: Set paths :: Set paths
set PYTHONPATH=%~dp0app\src 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 :: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data" if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs" if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit) :: Start the server (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m wled_controller "%~dp0python\pythonw.exe" -m ledgrab
LAUNCHER LAUNCHER
# Convert launcher to Windows line endings # Convert launcher to Windows line endings
+7 -7
View File
@@ -58,7 +58,7 @@ if (-not $Version) {
} }
if (-not $Version) { if (-not $Version) {
# Parse from __init__.py # 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*"([^"]+)"' $match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
if ($match) { $Version = $match.Matches[0].Groups[1].Value } 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" $pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
} }
# Embedded Python ._pth overrides PYTHONPATH, so add the app source path # 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') { if ($pthContent -notmatch '\.\.[/\\]app[/\\]src') {
$pthContent = $pthContent.TrimEnd() + "`n..\app\src`n" $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 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" $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 "ledgrab*" -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*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Clean up caches and test files to reduce size # Clean up caches and test files to reduce size
Write-Host " Cleaning up caches..." Write-Host " Cleaning up caches..."
@@ -206,14 +206,14 @@ cd /d "%~dp0"
:: Set paths :: Set paths
set PYTHONPATH=%~dp0app\src 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 :: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data" if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs" if not exist "%~dp0logs" mkdir "%~dp0logs"
:: Start the server (tray icon handles UI and exit) :: 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 $launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
+2 -2
View File
@@ -83,12 +83,12 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app/src" 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" mkdir -p "$SCRIPT_DIR/data" "$SCRIPT_DIR/logs"
source "$SCRIPT_DIR/venv/bin/activate" source "$SCRIPT_DIR/venv/bin/activate"
exec python -m wled_controller.main exec python -m ledgrab.main
LAUNCHER LAUNCHER
sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh" sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh"
+1 -1
View File
@@ -4,7 +4,7 @@
## CSS Custom Properties (Variables) ## 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. **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 | | Mode | Command | Config | Port | API Key | Data |
| ---- | ------- | ------ | ---- | ------- | ---- | | ---- | ------- | ------ | ---- | ------- | ---- |
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` | | **Real** | `python -m ledgrab` | `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/` | | **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. 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: Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
```bash ```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 ### Demo server
@@ -35,7 +35,7 @@ powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
# Kill it # Kill it
powershell -Command "Stop-Process -Id <PID> -Force" powershell -Command "Stop-Process -Id <PID> -Force"
# Restart # 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.). **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()`. 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()`. 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()`. 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'`. 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. 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 ### 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 engines: `core/capture_engines/demo_engine.py`, `core/audio/demo_engine.py`
- Demo devices: `core/devices/demo_provider.py` - Demo devices: `core/devices/demo_provider.py`
- Seed data: `core/demo_seed.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` **Base URL:** `http://localhost:8080`
**API Version:** v1 **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 ; Modern UI Configuration
!define MUI_ICON "server\src\wled_controller\static\icons\icon.ico" !define MUI_ICON "server\src\ledgrab\static\icons\icon.ico"
!define MUI_UNICON "server\src\wled_controller\static\icons\icon.ico" !define MUI_UNICON "server\src\ledgrab\static\icons\icon.ico"
!define MUI_ABORTWARNING !define MUI_ABORTWARNING
; Pages ; Pages
@@ -116,7 +116,7 @@ Section "!${APPNAME} (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}" CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \ CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ "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" CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
; Registry: install location + Add/Remove Programs entry ; Registry: install location + Add/Remove Programs entry
@@ -132,11 +132,11 @@ Section "!${APPNAME} (required)" SecCore
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ 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}" \ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov" "Publisher" "Alexei Dolgolyov"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ 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}" \ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoModify" 1 "NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
@@ -152,13 +152,13 @@ SectionEnd
Section "Desktop shortcut" SecDesktop Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \ CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ "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 SectionEnd
Section "Start with Windows" SecAutostart Section "Start with Windows" SecAutostart
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \ CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ "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 SectionEnd
; Section Descriptions ; 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. # 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 ────────────────────────────────────────────── # ── Server ──────────────────────────────────────────────
# WLED_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0) # LEDGRAB_SERVER__HOST=0.0.0.0 # Listen address (default: 0.0.0.0)
# WLED_SERVER__PORT=8080 # Listen port (default: 8080) # LEDGRAB_SERVER__PORT=8080 # Listen port (default: 8080)
# WLED_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO) # LEDGRAB_SERVER__LOG_LEVEL=INFO # Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
# WLED_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins # LEDGRAB_SERVER__CORS_ORIGINS=["*"] # JSON array of allowed CORS origins
# ── Authentication ────────────────────────────────────── # ── Authentication ──────────────────────────────────────
# API keys are required. Format: JSON object {"label": "key"}. # 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 ──────────────────────────────────────────── # ── Storage ────────────────────────────────────────────
# All data is stored in a single SQLite database. # 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) ──────────────────────────────────── # ── MQTT (optional) ────────────────────────────────────
# WLED_MQTT__ENABLED=false # LEDGRAB_MQTT__ENABLED=false
# WLED_MQTT__BROKER_HOST=localhost # LEDGRAB_MQTT__BROKER_HOST=localhost
# WLED_MQTT__BROKER_PORT=1883 # LEDGRAB_MQTT__BROKER_PORT=1883
# WLED_MQTT__USERNAME= # LEDGRAB_MQTT__USERNAME=
# WLED_MQTT__PASSWORD= # LEDGRAB_MQTT__PASSWORD=
# WLED_MQTT__CLIENT_ID=ledgrab # LEDGRAB_MQTT__CLIENT_ID=ledgrab
# WLED_MQTT__BASE_TOPIC=ledgrab # LEDGRAB_MQTT__BASE_TOPIC=ledgrab
# ── Logging ───────────────────────────────────────────── # ── Logging ─────────────────────────────────────────────
# WLED_LOGGING__FORMAT=json # json or text (default: json) # LEDGRAB_LOGGING__FORMAT=json # json or text (default: json)
# WLED_LOGGING__FILE=logs/wled_controller.log # LEDGRAB_LOGGING__FILE=logs/wled_controller.log
# WLED_LOGGING__MAX_SIZE_MB=100 # LEDGRAB_LOGGING__MAX_SIZE_MB=100
# WLED_LOGGING__BACKUP_COUNT=5 # LEDGRAB_LOGGING__BACKUP_COUNT=5
# ── Demo mode ─────────────────────────────────────────── # ── 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 ─────────────────────────────── # ── 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 # 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 ## Project Structure
- `src/wled_controller/main.py` — FastAPI application entry point - `src/ledgrab/main.py` — FastAPI application entry point
- `src/wled_controller/api/routes/` — REST API endpoints (one file per entity) - `src/ledgrab/api/routes/` — REST API endpoints (one file per entity)
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity) - `src/ledgrab/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations) - `src/ledgrab/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores - `src/ledgrab/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback) - `src/ledgrab/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales) - `src/ledgrab/static/` — Frontend files (TypeScript, CSS, locales)
- `src/wled_controller/templates/` — Jinja2 HTML templates - `src/ledgrab/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML) - `config/` — Configuration files (YAML)
- `data/` — Runtime data (JSON stores, persisted state) - `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. Server uses API key authentication via Bearer token in `Authorization` header.
- Config: `config/default_config.yaml` under `auth.api_keys` - 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 - 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"`) - 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* ./ COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts RUN npm ci --ignore-scripts
COPY esbuild.mjs tsconfig.json ./ 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 RUN npm run build
## Stage 2: Python application ## 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.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.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.version="${APP_VERSION}"
LABEL org.opencontainers.image.url="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/wled-screen-controller-mixed" LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app 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. # The real source is copied afterward, keeping the dep layer cached.
COPY pyproject.toml . COPY pyproject.toml .
RUN sed -i "s/^version = .*/version = \"${APP_VERSION}\"/" 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]" \ && 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 source code and config (invalidates cache only when source changes)
COPY src/ ./src/ COPY src/ ./src/
COPY config/ ./config/ COPY config/ ./config/
# Copy built frontend bundle from stage 1 # 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 # Create non-root user for security
RUN groupadd --gid 1000 ledgrab \ RUN groupadd --gid 1000 ledgrab \
@@ -67,4 +67,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
# Run the application # 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. 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 set PYTHONPATH=%CD%\src # Windows
# Run server # 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 ## Installation
@@ -85,20 +85,20 @@ storage:
logging: logging:
format: "json" format: "json"
file: "logs/wled_controller.log" file: "logs/ledgrab.log"
``` ```
### Environment Variables ### Environment Variables
```bash ```bash
# Server configuration # Server configuration
export WLED_SERVER__HOST="0.0.0.0" export LEDGRAB_SERVER__HOST="0.0.0.0"
export WLED_SERVER__PORT=8080 export LEDGRAB_SERVER__PORT=8080
export WLED_SERVER__LOG_LEVEL="INFO" export LEDGRAB_SERVER__LOG_LEVEL="INFO"
# Processing configuration # Processing configuration
export WLED_PROCESSING__DEFAULT_FPS=30 export LEDGRAB_PROCESSING__DEFAULT_FPS=30
export WLED_PROCESSING__BORDER_WIDTH=10 export LEDGRAB_PROCESSING__BORDER_WIDTH=10
# WLED configuration # WLED configuration
export WLED_WLED__TIMEOUT=5 export WLED_WLED__TIMEOUT=5
@@ -147,7 +147,7 @@ curl http://localhost:8080/api/v1/devices/{device_id}/state
pytest pytest
# Run with coverage # Run with coverage
pytest --cov=wled_controller --cov-report=html pytest --cov=ledgrab --cov-report=html
# Run specific test # Run specific test
pytest tests/test_screen_capture.py -v pytest tests/test_screen_capture.py -v
@@ -158,7 +158,7 @@ pytest tests/test_screen_capture.py -v
### Project Structure ### Project Structure
``` ```
src/wled_controller/ src/ledgrab/
├── main.py # FastAPI application ├── main.py # FastAPI application
├── config.py # Configuration ├── config.py # Configuration
├── api/ # API routes ├── api/ # API routes
@@ -188,4 +188,4 @@ MIT - see [../LICENSE](../LICENSE)
## Support ## Support
- 📖 [Full Documentation](../docs/) - 📖 [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: logging:
format: "json" # json or text format: "json" # json or text
file: "logs/wled_controller.log" file: "logs/ledgrab.log"
max_size_mb: 100 max_size_mb: 100
backup_count: 5 backup_count: 5
+2 -2
View File
@@ -1,5 +1,5 @@
# Demo mode configuration # 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 # Uses isolated data directory (data/demo/) and a pre-configured API key
# so the demo works out of the box with zero setup. # so the demo works out of the box with zero setup.
@@ -26,6 +26,6 @@ mqtt:
logging: logging:
format: "text" format: "text"
file: "logs/wled_controller.log" file: "logs/ledgrab.log"
max_size_mb: 100 max_size_mb: 100
backup_count: 5 backup_count: 5
+1 -1
View File
@@ -15,6 +15,6 @@ storage:
logging: logging:
format: "text" format: "text"
file: "logs/wled_test.log" file: "logs/ledgrab_test.log"
max_size_mb: 10 max_size_mb: 10
backup_count: 2 backup_count: 2
+17 -17
View File
@@ -1,14 +1,14 @@
services: services:
wled-controller: ledgrab:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: ledgrab:latest image: ledgrab:latest
container_name: wled-screen-controller container_name: ledgrab
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${WLED_PORT:-8080}:8080" - "${LEDGRAB_PORT:-8080}:8080"
volumes: volumes:
# Persist device data and configuration across restarts # Persist device data and configuration across restarts
@@ -22,37 +22,37 @@ services:
environment: environment:
## Server ## Server
# Bind address and port (usually no need to change) # Bind address and port (usually no need to change)
- WLED_SERVER__HOST=0.0.0.0 - LEDGRAB_SERVER__HOST=0.0.0.0
- WLED_SERVER__PORT=8080 - LEDGRAB_SERVER__PORT=8080
- WLED_SERVER__LOG_LEVEL=INFO - LEDGRAB_SERVER__LOG_LEVEL=INFO
# CORS origins — add your LAN IP for remote access, e.g.: # 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 ## Auth
# Override the default API key (STRONGLY recommended for production): # 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 # Generate a key: openssl rand -hex 32
## Display (Linux X11 only) ## Display (Linux X11 only)
- DISPLAY=${DISPLAY:-:0} - DISPLAY=${DISPLAY:-:0}
## Processing defaults ## Processing defaults
#- WLED_PROCESSING__DEFAULT_FPS=30 #- LEDGRAB_PROCESSING__DEFAULT_FPS=30
#- WLED_PROCESSING__BORDER_WIDTH=10 #- LEDGRAB_PROCESSING__BORDER_WIDTH=10
## MQTT (optional — for Home Assistant auto-discovery) ## MQTT (optional — for Home Assistant auto-discovery)
#- WLED_MQTT__ENABLED=true #- LEDGRAB_MQTT__ENABLED=true
#- WLED_MQTT__BROKER_HOST=192.168.1.2 #- LEDGRAB_MQTT__BROKER_HOST=192.168.1.2
#- WLED_MQTT__BROKER_PORT=1883 #- LEDGRAB_MQTT__BROKER_PORT=1883
#- WLED_MQTT__USERNAME= #- LEDGRAB_MQTT__USERNAME=
#- WLED_MQTT__PASSWORD= #- LEDGRAB_MQTT__PASSWORD=
# Uncomment for Linux screen capture (requires host network for X11 access) # Uncomment for Linux screen capture (requires host network for X11 access)
# network_mode: host # network_mode: host
networks: networks:
- wled-network - ledgrab-network
networks: networks:
wled-network: ledgrab-network:
driver: bridge driver: bridge
+4 -4
View File
@@ -1,6 +1,6 @@
# API Authentication Guide # 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 ## 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`: The integration will prompt for an API key during setup if authentication is enabled. You can also configure it in `configuration.yaml`:
```yaml ```yaml
wled_screen_controller: ledgrab:
server_url: "http://192.168.1.100:8080" server_url: "http://192.168.1.100:8080"
api_key: "your-api-key-here" # Optional, only if auth is enabled 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: services:
wled-controller: wled-controller:
environment: environment:
- WLED_AUTH__ENABLED=true - LEDGRAB_AUTH__ENABLED=true
- WLED_AUTH__API_KEYS__0=your-key-here - LEDGRAB_AUTH__API_KEYS__0=your-key-here
``` ```
Or use Docker secrets for better security. Or use Docker secrets for better security.
+1 -1
View File
@@ -1,6 +1,6 @@
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
const srcDir = 'src/wled_controller/static'; const srcDir = 'src/ledgrab/static';
const outDir = `${srcDir}/dist`; const outDir = `${srcDir}/dist`;
const watch = process.argv.includes('--watch'); const watch = process.argv.includes('--watch');
+6 -6
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "wled-screen-controller" name = "ledgrab"
version = "0.3.0" version = "0.3.0"
description = "Ambient lighting system that captures screen content and drives LED strips in real time" description = "Ambient lighting system that captures screen content and drives LED strips in real time"
authors = [ authors = [
@@ -83,10 +83,10 @@ perf = [
] ]
[project.urls] [project.urls]
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" Repository = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/src/branch/master/INSTALLATION.md" Documentation = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/src/branch/master/INSTALLATION.md"
Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed/issues" Issues = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab/issues"
[tool.setuptools] [tool.setuptools]
package-dir = {"" = "src"} package-dir = {"" = "src"}
@@ -97,7 +97,7 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" 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] [tool.black]
line-length = 100 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), # Uses graceful shutdown first (lets the server persist data to disk),
# then force-kills as a fallback. # 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 # Read API key from config for authenticated shutdown request
$configPath = Join-Path $serverRoot 'config\default_config.yaml' $configPath = Join-Path $serverRoot 'config\default_config.yaml'
@@ -20,7 +20,7 @@ if (Test-Path $configPath) {
# Find running server processes # Find running server processes
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | $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) { if ($procs) {
# Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save) # Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save)
@@ -46,7 +46,7 @@ if ($procs) {
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1
$waited++ $waited++
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | $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) { if (-not $still) {
Write-Host " Server exited cleanly after ${waited}s" Write-Host " Server exited cleanly after ${waited}s"
break break
@@ -54,7 +54,7 @@ if ($procs) {
} }
# Step 3: Force-kill stragglers # Step 3: Force-kill stragglers
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | $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) { if ($still) {
Write-Host " Force-killing remaining processes..." Write-Host " Force-killing remaining processes..."
foreach ($p in $still) { foreach ($p in $still) {
@@ -85,13 +85,13 @@ if ($regUser) {
# Start server detached (set WLED_RESTART=1 to skip browser open) # Start server detached (set WLED_RESTART=1 to skip browser open)
Write-Host "Starting server..." Write-Host "Starting server..."
$env:WLED_RESTART = "1" $env:LEDGRAB_RESTART = "1"
$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source $pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source
if (-not $pythonExe) { if (-not $pythonExe) {
# Fallback to known install location # Fallback to known install location
$pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe" $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 ` -WorkingDirectory $serverRoot `
-WindowStyle Hidden -WindowStyle Hidden
@@ -99,7 +99,7 @@ Start-Sleep -Seconds 3
# Verify it's running # Verify it's running
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | $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) { if ($check) {
Write-Host "Server started (PID $($check[0].ProcessId))" Write-Host "Server started (PID $($check[0].ProcessId))"
} else { } else {
+5 -5
View File
@@ -1,25 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Restart the WLED Screen Controller server (Linux/macOS) # Restart the LedGrab server (Linux/macOS)
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Stop any running instance # 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 if [ -n "$PIDS" ]; then
echo "Stopping server (PID $PIDS)..." echo "Stopping server (PID $PIDS)..."
pkill -f 'wled_controller\.main' 2>/dev/null || true pkill -f 'ledgrab\.main' 2>/dev/null || true
sleep 2 sleep 2
fi fi
# Start server detached # Start server detached
echo "Starting server..." echo "Starting server..."
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
nohup python -m wled_controller.main > /dev/null 2>&1 & nohup python -m ledgrab.main > /dev/null 2>&1 &
sleep 3 sleep 3
# Verify it's running # 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 if [ -n "$NEW_PID" ]; then
echo "Server started (PID $NEW_PID)" echo "Server started (PID $NEW_PID)"
else else
+3 -3
View File
@@ -1,8 +1,8 @@
@echo off @echo off
REM WLED Screen Controller Restart Script REM LedGrab Restart Script
REM This script restarts the WLED screen controller server REM This script restarts the WLED screen controller server
echo Restarting WLED Screen Controller... echo Restarting LedGrab...
echo. echo.
REM Stop the server first REM Stop the server first
@@ -18,7 +18,7 @@ cd /d "%~dp0\.."
REM Start the server REM Start the server
echo. echo.
echo [2/2] Starting server... echo [2/2] Starting server...
python -m wled_controller python -m ledgrab
REM If the server exits, pause to show any error messages REM If the server exits, pause to show any error messages
pause pause
+3 -3
View File
@@ -8,13 +8,13 @@ WshShell.CurrentDirectory = appRoot
' Set env vars for the child process (inherited via WshShell.Run) ' Set env vars for the child process (inherited via WshShell.Run)
Set procEnv = WshShell.Environment("Process") Set procEnv = WshShell.Environment("Process")
procEnv("PYTHONPATH") = appRoot & "\app\src" 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. ' Use embedded python.exe (NOT pythonw.exe) with WindowStyle=0.
' Same pattern as the Media Server sibling app. ' Same pattern as the Media Server sibling app.
embeddedPython = appRoot & "\python\python.exe" embeddedPython = appRoot & "\python\python.exe"
If fso.FileExists(embeddedPython) Then If fso.FileExists(embeddedPython) Then
WshShell.Run """" & embeddedPython & """ -m wled_controller", 0, False WshShell.Run """" & embeddedPython & """ -m ledgrab", 0, False
Else Else
WshShell.Run "python -m wled_controller", 0, False WshShell.Run "python -m ledgrab", 0, False
End If End If
+1 -1
View File
@@ -2,6 +2,6 @@ Set WshShell = CreateObject("WScript.Shell")
Set FSO = CreateObject("Scripting.FileSystemObject") Set FSO = CreateObject("Scripting.FileSystemObject")
' Get parent folder of scripts folder (server root) ' Get parent folder of scripts folder (server root)
WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName)) 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 FSO = Nothing
Set WshShell = Nothing Set WshShell = Nothing
+3 -3
View File
@@ -1,15 +1,15 @@
@echo off @echo off
REM WLED Screen Controller Startup Script REM LedGrab Startup Script
REM This script starts the WLED screen controller server REM This script starts the WLED screen controller server
echo Starting WLED Screen Controller... echo Starting LedGrab...
echo. echo.
REM Change to the server directory (parent of scripts folder) REM Change to the server directory (parent of scripts folder)
cd /d "%~dp0\.." cd /d "%~dp0\.."
REM Start the server REM Start the server
python -m wled_controller python -m ledgrab
REM If the server exits, pause to show any error messages REM If the server exits, pause to show any error messages
pause pause
+5 -5
View File
@@ -1,13 +1,13 @@
@echo off @echo off
REM WLED Screen Controller Stop Script REM LedGrab Stop Script
REM This script stops the running WLED screen controller server REM This script stops the running WLED screen controller server
echo Stopping WLED Screen Controller... echo Stopping LedGrab...
echo. 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 ( 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 ( if not errorlevel 1 (
taskkill /PID %%i /F taskkill /PID %%i /F
echo WLED controller process (PID %%i) terminated. 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.
echo Done! WLED Screen Controller stopped. echo Done! LedGrab stopped.
pause pause
@@ -3,7 +3,7 @@
from importlib.metadata import version, PackageNotFoundError from importlib.metadata import version, PackageNotFoundError
try: try:
__version__ = version("wled-screen-controller") __version__ = version("ledgrab")
except PackageNotFoundError: except PackageNotFoundError:
# Running from source without pip install (e.g. dev, embedded Python) # Running from source without pip install (e.g. dev, embedded Python)
__version__ = "0.0.0-dev" __version__ = "0.0.0-dev"
@@ -13,6 +13,6 @@ __email__ = "dolgolyov.alexei@gmail.com"
# ─── Project links ─────────────────────────────────────────── # ─── Project links ───────────────────────────────────────────
GITEA_BASE_URL = "https://git.dolgolyov-family.by" 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}" REPO_URL = f"{GITEA_BASE_URL}/{GITEA_REPO}"
DONATE_URL = "" # TODO: set once donation platform is chosen 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, Starts the uvicorn server and, on Windows when *pystray* is installed,
shows a system-tray icon with **Show UI** / **Exit** actions. shows a system-tray icon with **Show UI** / **Exit** actions.
@@ -36,10 +36,10 @@ _fix_embedded_tcl_paths()
import uvicorn # noqa: E402 import uvicorn # noqa: E402
from wled_controller.config import get_config # noqa: E402 from ledgrab.config import get_config # noqa: E402
from wled_controller.server_ref import set_server, set_tray # noqa: E402 from ledgrab.server_ref import set_server, set_tray # noqa: E402
from wled_controller.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402 from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from wled_controller.utils import setup_logging, get_logger # noqa: E402 from ledgrab.utils import setup_logging, get_logger # noqa: E402
setup_logging() setup_logging()
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -62,7 +62,7 @@ def _open_browser(port: int, delay: float = 2.0) -> None:
def _is_restart() -> bool: def _is_restart() -> bool:
"""Detect if this is a restart (vs first launch).""" """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: def _check_port(host: str, port: int) -> None:
@@ -81,7 +81,7 @@ def main() -> None:
_check_port(config.server.host, config.server.port) _check_port(config.server.host, config.server.port)
uv_config = uvicorn.Config( uv_config = uvicorn.Config(
"wled_controller.main:app", "ledgrab.main:app",
host=config.server.host, host=config.server.host,
port=config.server.port, port=config.server.port,
log_level=config.server.log_level.lower(), log_level=config.server.log_level.lower(),
@@ -133,10 +133,10 @@ def _request_shutdown(server: uvicorn.Server) -> None:
def _force_tray() -> bool: 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 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__": if __name__ == "__main__":
@@ -6,8 +6,8 @@ from typing import Annotated
from fastapi import Depends, HTTPException, Security, status from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from wled_controller.config import get_config from ledgrab.config import get_config
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) 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 typing import Any, Dict, TypeVar
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.database import Database from ledgrab.storage.database import Database
from wled_controller.storage import DeviceStore from ledgrab.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore from ledgrab.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore from ledgrab.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore from ledgrab.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore from ledgrab.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore from ledgrab.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore from ledgrab.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore from ledgrab.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ( from ledgrab.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore, ColorStripProcessingTemplateStore,
) )
from wled_controller.storage.gradient_store import GradientStore from ledgrab.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore from ledgrab.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore from ledgrab.storage.asset_store import AssetStore
from wled_controller.core.automations.automation_engine import AutomationEngine from ledgrab.core.automations.automation_engine import AutomationEngine
from wled_controller.core.weather.weather_manager import WeatherManager from ledgrab.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.update.update_service import UpdateService from ledgrab.core.update.update_service import UpdateService
from wled_controller.storage.home_assistant_store import HomeAssistantStore from ledgrab.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.storage.game_integration_store import GameIntegrationStore from ledgrab.storage.game_integration_store import GameIntegrationStore
from wled_controller.core.game_integration.event_bus import GameEventBus from ledgrab.core.game_integration.event_bus import GameEventBus
from wled_controller.storage.mqtt_source_store import MQTTSourceStore from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.core.mqtt.mqtt_manager import MQTTManager from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore from ledgrab.storage.pattern_template_store import PatternTemplateStore
T = TypeVar("T") T = TypeVar("T")
@@ -8,9 +8,9 @@ from typing import Callable, Optional
import numpy as np import numpy as np
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
from wled_controller.core.filters import FilterRegistry, ImagePool from ledgrab.core.filters import FilterRegistry, ImagePool
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.utils.image_codec import ( from ledgrab.utils.image_codec import (
encode_jpeg, encode_jpeg,
encode_jpeg_data_uri, encode_jpeg_data_uri,
resize_down, resize_down,
@@ -31,7 +31,8 @@ def authenticate_ws_token(token: str) -> bool:
Delegates to the canonical implementation in auth module. 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) return verify_ws_token(token)
@@ -160,14 +161,16 @@ async def stream_capture_test(
thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY) thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY)
fps = fc / elapsed if elapsed > 0 else 0 fps = fc / elapsed if elapsed > 0 else 0
avg_ms = (tc / fc * 1000) if fc > 0 else 0 avg_ms = (tc / fc * 1000) if fc > 0 else 0
await websocket.send_json({ await websocket.send_json(
{
"type": "frame", "type": "frame",
"thumbnail": thumb_uri, "thumbnail": thumb_uri,
"frame_count": fc, "frame_count": fc,
"elapsed_s": round(elapsed, 2), "elapsed_s": round(elapsed, 2),
"fps": round(fps, 1), "fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1), "avg_capture_ms": round(avg_ms, 1),
}) }
)
# Wait for capture thread to fully finish # Wait for capture thread to fully finish
await capture_future await capture_future
@@ -199,7 +202,8 @@ async def stream_capture_test(
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH) thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)
thumb_uri = _encode_jpeg(thumb, 85) thumb_uri = _encode_jpeg(thumb, 85)
await websocket.send_json({ await websocket.send_json(
{
"type": "result", "type": "result",
"full_image": full_uri, "full_image": full_uri,
"thumbnail": thumb_uri, "thumbnail": thumb_uri,
@@ -209,7 +213,8 @@ async def stream_capture_test(
"elapsed_s": round(elapsed, 2), "elapsed_s": round(elapsed, 2),
"fps": round(fps, 1), "fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1), "avg_capture_ms": round(avg_ms, 1),
}) }
)
except Exception as e: except Exception as e:
# WebSocket disconnect or send error — signal capture thread to stop # 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 import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_asset_store from ledgrab.api.dependencies import fire_entity_event, get_asset_store
from wled_controller.api.schemas.assets import ( from ledgrab.api.schemas.assets import (
AssetListResponse, AssetListResponse,
AssetResponse, AssetResponse,
AssetUpdate, AssetUpdate,
) )
from wled_controller.config import get_config from ledgrab.config import get_config
from wled_controller.storage.asset_store import AssetStore from ledgrab.storage.asset_store import AssetStore
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -103,7 +103,9 @@ async def upload_asset(
if not data: if not data:
raise HTTPException(status_code=400, detail="Empty file") 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: try:
asset = store.create_asset( asset = store.create_asset(
@@ -4,8 +4,8 @@ import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.core.audio.audio_capture import AudioCaptureManager from ledgrab.core.audio.audio_capture import AudioCaptureManager
router = APIRouter() router = APIRouter()
@@ -2,15 +2,15 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import get_audio_processing_template_store from ledgrab.api.dependencies import get_audio_processing_template_store
from wled_controller.api.schemas.filters import ( from ledgrab.api.schemas.filters import (
FilterOptionDefSchema, FilterOptionDefSchema,
FilterTypeListResponse, FilterTypeListResponse,
FilterTypeResponse, FilterTypeResponse,
) )
from wled_controller.core.audio.filters import AudioFilterRegistry from ledgrab.core.audio.filters import AudioFilterRegistry
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
router = APIRouter() router = APIRouter()
@@ -2,24 +2,24 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_audio_processing_template_store, get_audio_processing_template_store,
get_audio_source_store, get_audio_source_store,
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.audio_processing import ( from ledgrab.api.schemas.audio_processing import (
AudioProcessingTemplateCreate, AudioProcessingTemplateCreate,
AudioProcessingTemplateListResponse, AudioProcessingTemplateListResponse,
AudioProcessingTemplateResponse, AudioProcessingTemplateResponse,
AudioProcessingTemplateUpdate, AudioProcessingTemplateUpdate,
) )
from wled_controller.api.schemas.filters import FilterInstanceSchema from ledgrab.api.schemas.filters import FilterInstanceSchema
from wled_controller.core.filters.filter_instance import FilterInstance from ledgrab.core.filters.filter_instance import FilterInstance
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -6,8 +6,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_audio_processing_template_store, get_audio_processing_template_store,
get_audio_source_store, get_audio_source_store,
@@ -15,7 +15,7 @@ from wled_controller.api.dependencies import (
get_color_strip_store, get_color_strip_store,
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.audio_sources import ( from ledgrab.api.schemas.audio_sources import (
AudioSourceCreate, AudioSourceCreate,
AudioSourceListResponse, AudioSourceListResponse,
AudioSourceResponse, AudioSourceResponse,
@@ -23,15 +23,15 @@ from wled_controller.api.schemas.audio_sources import (
CaptureAudioSourceResponse, CaptureAudioSourceResponse,
ProcessedAudioSourceResponse, ProcessedAudioSourceResponse,
) )
from wled_controller.storage.audio_source import ( from ledgrab.storage.audio_source import (
AudioSource, AudioSource,
CaptureAudioSource, CaptureAudioSource,
ProcessedAudioSource, ProcessedAudioSource,
) )
from wled_controller.storage.audio_source_store import AudioSourceStore from ledgrab.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -178,7 +178,7 @@ async def delete_audio_source(
"""Delete an audio source.""" """Delete an audio source."""
try: try:
# Check if any CSS entities reference this audio source # 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(): for css in css_store.get_all_sources():
if ( if (
@@ -215,8 +215,8 @@ async def test_audio_source_ws(
analysis before sending, so the WebSocket output matches what running analysis before sending, so the WebSocket output matches what running
streams see. streams see.
""" """
from wled_controller.api.auth import verify_ws_token from ledgrab.api.auth import verify_ws_token
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids from ledgrab.core.audio.filters.pipeline import build_pipeline_from_template_ids
if not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -4,9 +4,14 @@ import asyncio
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from starlette.websockets import WebSocket, WebSocketDisconnect from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.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 ledgrab.api.dependencies import (
from wled_controller.api.schemas.audio_templates import ( fire_entity_event,
get_audio_template_store,
get_audio_source_store,
get_processor_manager,
)
from ledgrab.api.schemas.audio_templates import (
AudioEngineInfo, AudioEngineInfo,
AudioEngineListResponse, AudioEngineListResponse,
AudioTemplateCreate, AudioTemplateCreate,
@@ -14,11 +19,11 @@ from wled_controller.api.schemas.audio_templates import (
AudioTemplateResponse, AudioTemplateResponse,
AudioTemplateUpdate, AudioTemplateUpdate,
) )
from wled_controller.core.audio.factory import AudioEngineRegistry from ledgrab.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template_store import AudioTemplateStore from ledgrab.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore from ledgrab.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -27,7 +32,10 @@ router = APIRouter()
# ===== AUDIO TEMPLATE ENDPOINTS ===== # ===== 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( async def list_audio_templates(
_auth: AuthRequired, _auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store), store: AudioTemplateStore = Depends(get_audio_template_store),
@@ -37,10 +45,14 @@ async def list_audio_templates(
templates = store.get_all_templates() templates = store.get_all_templates()
responses = [ responses = [
AudioTemplateResponse( AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, id=t.id,
engine_config=t.engine_config, tags=t.tags, name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at, 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 for t in templates
] ]
@@ -50,7 +62,12 @@ async def list_audio_templates(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def create_audio_template(
data: AudioTemplateCreate, data: AudioTemplateCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -59,16 +76,22 @@ async def create_audio_template(
"""Create a new audio capture template.""" """Create a new audio capture template."""
try: try:
template = store.create_template( template = store.create_template(
name=data.name, engine_type=data.engine_type, name=data.name,
engine_config=data.engine_config, description=data.description, engine_type=data.engine_type,
engine_config=data.engine_config,
description=data.description,
tags=data.tags, tags=data.tags,
) )
fire_entity_event("audio_template", "created", template.id) fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type, id=template.id,
engine_config=template.engine_config, tags=template.tags, name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at, 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: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(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") 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( async def get_audio_template(
template_id: str, template_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -92,14 +119,22 @@ async def get_audio_template(
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found") raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse( return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, id=t.id,
engine_config=t.engine_config, tags=t.tags, name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at, 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( async def update_audio_template(
template_id: str, template_id: str,
data: AudioTemplateUpdate, data: AudioTemplateUpdate,
@@ -109,16 +144,23 @@ async def update_audio_template(
"""Update an audio template.""" """Update an audio template."""
try: try:
t = store.update_template( t = store.update_template(
template_id=template_id, name=data.name, template_id=template_id,
engine_type=data.engine_type, engine_config=data.engine_config, name=data.name,
description=data.description, tags=data.tags, engine_type=data.engine_type,
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
) )
fire_entity_event("audio_template", "updated", template_id) fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse( return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type, id=t.id,
engine_config=t.engine_config, tags=t.tags, name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at, 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: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -155,7 +197,10 @@ async def delete_audio_template(
# ===== AUDIO ENGINE ENDPOINTS ===== # ===== 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): async def list_audio_engines(_auth: AuthRequired):
"""List all registered audio capture engines.""" """List all registered audio capture engines."""
try: 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. Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
Streams AudioAnalysis snapshots as JSON at ~20 Hz. 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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
return return
@@ -214,13 +260,17 @@ async def test_audio_template_ws(
loopback = is_loopback != 0 loopback = is_loopback != 0
try: 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: except RuntimeError as e:
await websocket.close(code=4003, reason=str(e)) await websocket.close(code=4003, reason=str(e))
return return
await websocket.accept() 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 last_ts = 0.0
try: try:
@@ -228,13 +278,15 @@ async def test_audio_template_ws(
analysis = stream.get_latest_analysis() analysis = stream.get_latest_analysis()
if analysis is not None and analysis.timestamp != last_ts: if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp last_ts = analysis.timestamp
await websocket.send_json({ await websocket.send_json(
{
"spectrum": analysis.spectrum.tolist(), "spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4), "rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4), "peak": round(analysis.peak, 4),
"beat": analysis.beat, "beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4), "beat_intensity": round(analysis.beat_intensity, 4),
}) }
)
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Audio template test WebSocket disconnected") logger.debug("Audio template test WebSocket disconnected")
@@ -4,22 +4,22 @@ import secrets
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_automation_engine, get_automation_engine,
get_automation_store, get_automation_store,
get_scene_preset_store, get_scene_preset_store,
) )
from wled_controller.api.schemas.automations import ( from ledgrab.api.schemas.automations import (
AutomationCreate, AutomationCreate,
AutomationListResponse, AutomationListResponse,
AutomationResponse, AutomationResponse,
AutomationUpdate, AutomationUpdate,
RuleSchema, RuleSchema,
) )
from wled_controller.core.automations.automation_engine import AutomationEngine from ledgrab.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import ( from ledgrab.storage.automation import (
ApplicationRule, ApplicationRule,
DisplayStateRule, DisplayStateRule,
HomeAssistantRule, HomeAssistantRule,
@@ -30,10 +30,10 @@ from wled_controller.storage.automation import (
TimeOfDayRule, TimeOfDayRule,
WebhookRule, WebhookRule,
) )
from wled_controller.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore from ledgrab.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -97,7 +97,7 @@ def _automation_to_response(
for r in automation.rules: for r in automation.rules:
if isinstance(r, WebhookRule) and r.token: if isinstance(r, WebhookRule) and r.token:
# Prefer configured external URL, fall back to request base URL # 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() ext = load_external_url()
if ext: if ext:
@@ -15,20 +15,20 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import get_asset_store, get_auto_backup_engine, get_database from ledgrab.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
from wled_controller.api.schemas.system import ( from ledgrab.api.schemas.system import (
AutoBackupSettings, AutoBackupSettings,
AutoBackupStatusResponse, AutoBackupStatusResponse,
BackupFileInfo, BackupFileInfo,
BackupListResponse, BackupListResponse,
RestoreResponse, RestoreResponse,
) )
from wled_controller.config import get_config from ledgrab.config import get_config
from wled_controller.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.backup.auto_backup import AutoBackupEngine
from wled_controller.storage.asset_store import AssetStore from ledgrab.storage.asset_store import AssetStore
from wled_controller.storage.database import Database, freeze_writes from ledgrab.storage.database import Database, freeze_writes
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -42,11 +42,17 @@ def _schedule_restart() -> None:
def _restart(): def _restart():
import time import time
time.sleep(1) time.sleep(1)
if sys.platform == "win32": if sys.platform == "win32":
subprocess.Popen( 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, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
) )
else: else:
@@ -71,6 +77,7 @@ def backup_config(
): ):
"""Download a full backup as a .zip containing the database and asset files.""" """Download a full backup as a .zip containing the database and asset files."""
import tempfile import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
@@ -95,6 +102,7 @@ def backup_config(
zip_buffer.seek(0) zip_buffer.seek(0)
from datetime import datetime, timezone from datetime import datetime, timezone
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S") timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.zip" filename = f"ledgrab-backup-{timestamp}.zip"
@@ -129,7 +137,9 @@ async def restore_config(
is_sqlite = raw[:16].startswith(b"SQLite format 3") is_sqlite = raw[:16].startswith(b"SQLite format 3")
if not is_zip and not is_sqlite: 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: if is_zip:
# Extract DB and assets from ZIP # Extract DB and assets from ZIP
@@ -160,6 +170,7 @@ async def restore_config(
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:
def _restore(): def _restore():
db.restore_from(tmp_path) db.restore_from(tmp_path)
@@ -181,7 +192,8 @@ async def restore_config(
@router.post("/api/v1/system/restart", tags=["System"]) @router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired): def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately.""" """Schedule a server restart and return immediately."""
from wled_controller.server_ref import _broadcast_restarting from ledgrab.server_ref import _broadcast_restarting
_broadcast_restarting() _broadcast_restarting()
_schedule_restart() _schedule_restart()
return {"status": "restarting"} return {"status": "restarting"}
@@ -190,7 +202,8 @@ def restart_server(_: AuthRequired):
@router.post("/api/v1/system/shutdown", tags=["System"]) @router.post("/api/v1/system/shutdown", tags=["System"])
def shutdown_server(_: AuthRequired): def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server.""" """Gracefully shut down the server."""
from wled_controller.server_ref import request_shutdown from ledgrab.server_ref import request_shutdown
request_shutdown() request_shutdown()
return {"status": "shutting_down"} return {"status": "shutting_down"}
@@ -6,27 +6,27 @@ import uuid as _uuid
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_color_strip_store, get_color_strip_store,
get_cspt_store, get_cspt_store,
get_device_store, get_device_store,
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.filters import FilterInstanceSchema from ledgrab.api.schemas.filters import FilterInstanceSchema
from wled_controller.api.schemas.color_strip_processing import ( from ledgrab.api.schemas.color_strip_processing import (
ColorStripProcessingTemplateCreate, ColorStripProcessingTemplateCreate,
ColorStripProcessingTemplateListResponse, ColorStripProcessingTemplateListResponse,
ColorStripProcessingTemplateResponse, ColorStripProcessingTemplateResponse,
ColorStripProcessingTemplateUpdate, ColorStripProcessingTemplateUpdate,
) )
from wled_controller.core.filters import FilterInstance from ledgrab.core.filters import FilterInstance
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore from ledgrab.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from wled_controller.storage import DeviceStore from ledgrab.storage import DeviceStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) 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( async def list_cspt(
_auth: AuthRequired, _auth: AuthRequired,
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store), store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
@@ -61,7 +65,12 @@ async def list_cspt(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def create_cspt(
data: ColorStripProcessingTemplateCreate, data: ColorStripProcessingTemplateCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -88,7 +97,11 @@ async def create_cspt(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def get_cspt(
template_id: str, template_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -99,10 +112,16 @@ async def get_cspt(
template = store.get_template(template_id) template = store.get_template(template_id)
return _cspt_to_response(template) return _cspt_to_response(template)
except ValueError: 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( async def update_cspt(
template_id: str, template_id: str,
data: ColorStripProcessingTemplateUpdate, data: ColorStripProcessingTemplateUpdate,
@@ -111,7 +130,11 @@ async def update_cspt(
): ):
"""Update a color strip processing template.""" """Update a color strip processing template."""
try: 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 = store.update_template(
template_id=template_id, template_id=template_id,
name=data.name, name=data.name,
@@ -131,7 +154,11 @@ async def update_cspt(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def delete_cspt(
template_id: str, template_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -165,6 +192,7 @@ async def delete_cspt(
# ── Test / Preview WebSocket ────────────────────────────────────────── # ── Test / Preview WebSocket ──────────────────────────────────────────
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws") @router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
async def test_cspt_ws( async def test_cspt_ws(
websocket: WebSocket, websocket: WebSocket,
@@ -179,9 +207,9 @@ async def test_cspt_ws(
Takes an input CSS source, applies the CSPT filter chain, and streams Takes an input CSS source, applies the CSPT filter chain, and streams
the processed RGB frames. Auth via ``?token=<api_key>``. the processed RGB frames. Auth via ``?token=<api_key>``.
""" """
from wled_controller.api.auth import verify_ws_token from ledgrab.api.auth import verify_ws_token
from wled_controller.core.filters import FilterRegistry from ledgrab.core.filters import FilterRegistry
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
if not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -9,8 +9,8 @@ from typing import Annotated
import numpy as np import numpy as np
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_color_strip_store, get_color_strip_store,
get_device_store, get_device_store,
@@ -20,7 +20,7 @@ from wled_controller.api.dependencies import (
get_processor_manager, get_processor_manager,
get_template_store, get_template_store,
) )
from wled_controller.api.schemas.color_strip_sources import ( from ledgrab.api.schemas.color_strip_sources import (
ApiInputCSSResponse, ApiInputCSSResponse,
AudioCSSResponse, AudioCSSResponse,
CandlelightCSSResponse, CandlelightCSSResponse,
@@ -47,17 +47,17 @@ from wled_controller.api.schemas.color_strip_sources import (
StaticCSSResponse, StaticCSSResponse,
WeatherCSSResponse, WeatherCSSResponse,
) )
from wled_controller.api.schemas.devices import ( from ledgrab.api.schemas.devices import (
Calibration as CalibrationSchema, Calibration as CalibrationSchema,
CalibrationTestModeResponse, CalibrationTestModeResponse,
) )
from wled_controller.core.capture.calibration import ( from ledgrab.core.capture.calibration import (
calibration_from_dict, calibration_from_dict,
calibration_to_dict, calibration_to_dict,
) )
from wled_controller.core.capture.screen_capture import get_available_displays from ledgrab.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import ( from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource, AdvancedPictureColorStripSource,
ApiInputColorStripSource, ApiInputColorStripSource,
AudioColorStripSource, AudioColorStripSource,
@@ -76,17 +76,17 @@ from wled_controller.storage.color_strip_source import (
StaticColorStripSource, StaticColorStripSource,
WeatherColorStripSource, WeatherColorStripSource,
) )
from wled_controller.storage import DeviceStore from ledgrab.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from wled_controller.storage.template_store import TemplateStore from ledgrab.storage.template_store import TemplateStore
from wled_controller.storage.picture_source import ( from ledgrab.storage.picture_source import (
ProcessedPictureSource, ProcessedPictureSource,
ScreenCapturePictureSource, ScreenCapturePictureSource,
) )
from wled_controller.storage.picture_source_store import PictureSourceStore from ledgrab.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -371,7 +371,7 @@ async def create_color_strip_source(
if data.source_type == "composite" and kwargs.get("layers"): 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")] 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 # 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: for cid in child_ids:
depth = store.get_nesting_depth(cid) depth = store.get_nesting_depth(cid)
@@ -524,19 +524,19 @@ async def test_key_colors_source(
pp_template_store=Depends(get_pp_template_store), pp_template_store=Depends(get_pp_template_store),
): ):
"""Test a key_colors source: capture a frame, extract colors from each rectangle.""" """Test a key_colors source: capture a frame, extract colors from each rectangle."""
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource from ledgrab.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import ( from ledgrab.core.capture.screen_capture import (
calculate_average_color, calculate_average_color,
calculate_dominant_color, calculate_dominant_color,
calculate_median_color, calculate_median_color,
) )
from wled_controller.core.capture_engines import EngineRegistry from ledgrab.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool from ledgrab.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.picture_source import ( from ledgrab.storage.picture_source import (
ScreenCapturePictureSource, ScreenCapturePictureSource,
StaticImagePictureSource, StaticImagePictureSource,
) )
from wled_controller.utils.image_codec import encode_jpeg_data_uri from ledgrab.utils.image_codec import encode_jpeg_data_uri
stream = None stream = None
try: try:
@@ -553,10 +553,10 @@ async def test_key_colors_source(
chain = source_store.resolve_stream_chain(source.picture_source_id) chain = source_store.resolve_stream_chain(source.picture_source_id)
raw_stream = chain["raw_stream"] 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): 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() asset_store = _get_asset_store()
image_path = ( image_path = (
@@ -681,15 +681,15 @@ async def test_key_colors_ws(
"""WebSocket for real-time key_colors test preview with frame + rectangle overlay.""" """WebSocket for real-time key_colors test preview with frame + rectangle overlay."""
import json as ws_json import json as ws_json
import time as ws_time import time as ws_time
from wled_controller.api.auth import verify_ws_token from ledgrab.api.auth import verify_ws_token
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource from ledgrab.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import ( from ledgrab.core.capture.screen_capture import (
calculate_average_color, calculate_average_color,
calculate_dominant_color, calculate_dominant_color,
calculate_median_color, calculate_median_color,
) )
from wled_controller.storage.picture_source import ScreenCapturePictureSource from ledgrab.storage.picture_source import ScreenCapturePictureSource
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 not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") 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"]) @router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"])
async def os_notification_history(_auth: AuthRequired): async def os_notification_history(_auth: AuthRequired):
"""Return recent OS notification capture history (newest first).""" """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, 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 Subsequent text messages are treated as config updates: if the source_type
changed the old stream is replaced; otherwise ``update_source()`` is used. 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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -1168,7 +1168,7 @@ async def preview_color_strip_ws(
def _build_source(config: dict): def _build_source(config: dict):
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name.""" """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("id", "__preview__")
config.setdefault("name", "__preview__") config.setdefault("name", "__preview__")
@@ -1176,7 +1176,7 @@ async def preview_color_strip_ws(
def _create_stream(source): def _create_stream(source):
"""Instantiate and start the appropriate stream class for *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) stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls: if not stream_cls:
@@ -1185,7 +1185,7 @@ async def preview_color_strip_ws(
# Inject gradient store for palette resolution # Inject gradient store for palette resolution
if hasattr(s, "set_gradient_store"): if hasattr(s, "set_gradient_store"):
try: 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()) s.set_gradient_store(get_gradient_store())
except Exception: except Exception:
@@ -1283,7 +1283,7 @@ async def preview_color_strip_ws(
# Handle "fire" command for notification streams # Handle "fire" command for notification streams
if new_config.get("action") == "fire": if new_config.get("action") == "fire":
from wled_controller.core.processing.notification_stream import ( from ledgrab.core.processing.notification_stream import (
NotificationColorStripStream, NotificationColorStripStream,
) )
@@ -1349,7 +1349,7 @@ async def css_api_input_ws(
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]}) Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
or binary frames (raw RGBRGB... bytes, 3 bytes per LED). 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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -1391,7 +1391,7 @@ async def css_api_input_ws(
if "segments" in data: if "segments" in data:
# Segment-based path — validate and push # Segment-based path — validate and push
try: 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"]] seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e: 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). First message is JSON metadata (source_type, led_count, calibration segments).
Subsequent messages are binary RGB frames (``led_count * 3`` bytes). 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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") 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})") logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})")
try: 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) is_api_input = isinstance(stream, ApiInputColorStripStream)
_last_push_gen = 0 # track api_input push generation to skip unchanged frames _last_push_gen = 0 # track api_input push generation to skip unchanged frames
@@ -1643,7 +1643,7 @@ async def test_color_strip_ws(
try: try:
frame = _frame_live.get_latest_frame() frame = _frame_live.get_latest_frame()
if frame is not None and frame.image is not None: 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 import cv2 as _cv2
img = frame.image img = frame.image
@@ -3,19 +3,19 @@
import httpx import httpx
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.core.devices.led_client import ( from ledgrab.core.devices.led_client import (
get_all_providers, get_all_providers,
get_device_capabilities, get_device_capabilities,
get_provider, get_provider,
) )
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_device_store, get_device_store,
get_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.devices import ( from ledgrab.api.schemas.devices import (
BrightnessRequest, BrightnessRequest,
DeviceCreate, DeviceCreate,
DeviceListResponse, DeviceListResponse,
@@ -28,10 +28,10 @@ from wled_controller.api.schemas.devices import (
OpenRGBZonesResponse, OpenRGBZonesResponse,
PowerRequest, PowerRequest,
) )
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore from ledgrab.storage import DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -300,14 +300,14 @@ async def get_openrgb_zones(
"""List available zones on an OpenRGB device.""" """List available zones on an OpenRGB device."""
import asyncio 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) host, port, device_index, _zones = parse_openrgb_url(url)
def _fetch_zones(): def _fetch_zones():
from openrgb import OpenRGBClient from openrgb import OpenRGBClient
client = OpenRGBClient(host, port, name="WLED Controller (zones)") client = OpenRGBClient(host, port, name="LedGrab (zones)")
try: try:
devices = client.devices devices = client.devices
if device_index >= len(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 ...] Wire format: [brightness_byte][R G B R G B ...]
Auth via ?token=<api_key>. 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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -760,7 +760,7 @@ async def device_ws_stream(
await websocket.accept() 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 = get_ws_broadcaster()
broadcaster.add_client(device_id, websocket) broadcaster.add_client(device_id, websocket)
@@ -10,14 +10,14 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_database, get_database,
get_game_integration_store, get_game_integration_store,
get_game_event_bus, get_game_event_bus,
) )
from wled_controller.api.schemas.game_integration import ( from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse, AdapterInfoResponse,
AdapterListResponse, AdapterListResponse,
ApplyPresetRequest, ApplyPresetRequest,
@@ -34,13 +34,13 @@ from wled_controller.api.schemas.game_integration import (
PresetListResponse, PresetListResponse,
RecentEventsResponse, RecentEventsResponse,
) )
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.event_bus import GameEventBus from ledgrab.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent from ledgrab.core.game_integration.events import GameEvent
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from wled_controller.storage.game_integration import EventMapping from ledgrab.storage.game_integration import EventMapping
from wled_controller.storage.game_integration_store import GameIntegrationStore from ledgrab.storage.game_integration_store import GameIntegrationStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -135,7 +135,7 @@ def _cleanup_state(integration_id: str) -> None:
def _config_to_response(config: Any) -> GameIntegrationResponse: def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response.""" """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( return GameIntegrationResponse(
id=config.id, id=config.id,
@@ -171,7 +171,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
) )
async def list_presets(_auth: AuthRequired): async def list_presets(_auth: AuthRequired):
"""List all available built-in effect presets.""" """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() presets = get_all_presets()
responses = [ responses = [
@@ -554,7 +554,7 @@ async def apply_preset(
If replace=true, replaces all existing mappings. If replace=true, replaces all existing mappings.
If replace=false (default), appends preset mappings to existing ones. 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: try:
config = store.get_integration(integration_id) config = store.get_integration(integration_id)
@@ -619,7 +619,7 @@ async def auto_setup_integration(
) )
# Determine server URL # 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() db = get_database()
server_url = load_external_url(db) server_url = load_external_url(db)
@@ -2,23 +2,23 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_color_strip_store, get_color_strip_store,
get_gradient_store, get_gradient_store,
) )
from wled_controller.api.schemas.gradients import ( from ledgrab.api.schemas.gradients import (
GradientCreate, GradientCreate,
GradientListResponse, GradientListResponse,
GradientResponse, GradientResponse,
GradientUpdate, GradientUpdate,
) )
from wled_controller.storage.gradient import Gradient from ledgrab.storage.gradient import Gradient
from wled_controller.storage.gradient_store import GradientStore from ledgrab.storage.gradient_store import GradientStore
from wled_controller.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) 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( async def create_gradient(
data: GradientCreate, data: GradientCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -109,7 +111,12 @@ async def update_gradient(
raise HTTPException(status_code=status, detail=str(e)) 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( async def clone_gradient(
gradient_id: str, gradient_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -143,9 +150,7 @@ async def delete_gradient(
# Check references # Check references
for source in css_store.get_all_sources(): for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id: if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError( raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
f"Cannot delete: referenced by color strip source '{source.name}'"
)
store.delete_gradient(gradient_id) store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id) fire_entity_event("gradient", "deleted", gradient_id)
except (ValueError, EntityNotFoundError) as e: except (ValueError, EntityNotFoundError) as e:
@@ -5,13 +5,13 @@ import json
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_ha_manager, get_ha_manager,
get_ha_store, get_ha_store,
) )
from wled_controller.api.schemas.home_assistant import ( from ledgrab.api.schemas.home_assistant import (
HomeAssistantConnectionStatus, HomeAssistantConnectionStatus,
HomeAssistantEntityListResponse, HomeAssistantEntityListResponse,
HomeAssistantEntityResponse, HomeAssistantEntityResponse,
@@ -22,12 +22,12 @@ from wled_controller.api.schemas.home_assistant import (
HomeAssistantStatusResponse, HomeAssistantStatusResponse,
HomeAssistantTestResponse, HomeAssistantTestResponse,
) )
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.home_assistant.ha_runtime import HARuntime from ledgrab.core.home_assistant.ha_runtime import HARuntime
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from wled_controller.storage.home_assistant_source import HomeAssistantSource from ledgrab.storage.home_assistant_source import HomeAssistantSource
from wled_controller.storage.home_assistant_store import HomeAssistantStore from ledgrab.storage.home_assistant_store import HomeAssistantStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -5,13 +5,13 @@ import asyncio
import aiomqtt import aiomqtt
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_mqtt_manager, get_mqtt_manager,
get_mqtt_store, get_mqtt_store,
) )
from wled_controller.api.schemas.mqtt import ( from ledgrab.api.schemas.mqtt import (
MQTTConnectionStatus, MQTTConnectionStatus,
MQTTSourceCreate, MQTTSourceCreate,
MQTTSourceListResponse, MQTTSourceListResponse,
@@ -20,11 +20,11 @@ from wled_controller.api.schemas.mqtt import (
MQTTStatusResponse, MQTTStatusResponse,
MQTTTestResponse, MQTTTestResponse,
) )
from wled_controller.core.mqtt.mqtt_manager import MQTTManager from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from wled_controller.storage.mqtt_source import MQTTSource from ledgrab.storage.mqtt_source import MQTTSource
from wled_controller.storage.mqtt_source_store import MQTTSourceStore from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -5,14 +5,14 @@ from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Depends from fastapi import APIRouter, Body, HTTPException, Depends
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_device_store, get_device_store,
get_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.output_targets import ( from ledgrab.api.schemas.output_targets import (
HALightMappingSchema, HALightMappingSchema,
HALightOutputTargetResponse, HALightOutputTargetResponse,
LedOutputTargetResponse, LedOutputTargetResponse,
@@ -21,17 +21,17 @@ from wled_controller.api.schemas.output_targets import (
OutputTargetResponse, OutputTargetResponse,
OutputTargetUpdate, OutputTargetUpdate,
) )
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore from ledgrab.storage import DeviceStore
from wled_controller.storage.bindable import BindableFloat from ledgrab.storage.bindable import BindableFloat
from wled_controller.storage.wled_output_target import WledOutputTarget from ledgrab.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.ha_light_output_target import ( from ledgrab.storage.ha_light_output_target import (
HALightMapping, HALightMapping,
HALightOutputTarget, HALightOutputTarget,
) )
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) 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 fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
get_color_strip_store, get_color_strip_store,
get_output_target_store, get_output_target_store,
get_picture_source_store, get_picture_source_store,
get_processor_manager, get_processor_manager,
) )
from wled_controller.api.schemas.output_targets import ( from ledgrab.api.schemas.output_targets import (
BulkTargetRequest, BulkTargetRequest,
BulkTargetResponse, BulkTargetResponse,
TargetMetricsResponse, TargetMetricsResponse,
TargetProcessingState, TargetProcessingState,
) )
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import get_available_displays from ledgrab.core.capture.screen_capture import get_available_displays
from wled_controller.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import ( from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource, AdvancedPictureColorStripSource,
PictureColorStripSource, PictureColorStripSource,
) )
from wled_controller.storage.picture_source_store import PictureSourceStore from ledgrab.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.wled_output_target import WledOutputTarget from ledgrab.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -208,7 +208,7 @@ async def events_ws(
token: str = Query(""), token: str = Query(""),
): ):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>.""" """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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -272,7 +272,7 @@ async def start_target_overlay(
): ):
calibration = css.calibration calibration = css.calibration
# Resolve the display this CSS is capturing # 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, _resolve_display_index,
) )
@@ -348,7 +348,7 @@ async def ha_light_colors_ws(
Streams: {"type": "colors_update", "colors": {entity_id: {r,g,b,hex}, ...}} Streams: {"type": "colors_update", "colors": {entity_id: {r,g,b,hex}, ...}}
at the target's update_rate. 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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -390,7 +390,7 @@ async def led_preview_ws(
token: str = Query(""), token: str = Query(""),
): ):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>.""" """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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -2,24 +2,24 @@
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_pattern_template_store, get_pattern_template_store,
get_output_target_store, get_output_target_store,
) )
from wled_controller.api.schemas.pattern_templates import ( from ledgrab.api.schemas.pattern_templates import (
PatternTemplateCreate, PatternTemplateCreate,
PatternTemplateListResponse, PatternTemplateListResponse,
PatternTemplateResponse, PatternTemplateResponse,
PatternTemplateUpdate, PatternTemplateUpdate,
) )
from wled_controller.api.schemas.output_targets import KeyColorRectangleSchema from ledgrab.api.schemas.output_targets import KeyColorRectangleSchema
from wled_controller.storage.pattern_template import KeyColorRectangle from ledgrab.storage.pattern_template import KeyColorRectangle
from wled_controller.storage.pattern_template_store import PatternTemplateStore from ledgrab.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -9,20 +9,20 @@ import numpy as np
from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_picture_source_store, get_picture_source_store,
get_output_target_store, get_output_target_store,
get_pp_template_store, get_pp_template_store,
get_template_store, get_template_store,
) )
from wled_controller.api.schemas.common import ( from ledgrab.api.schemas.common import (
CaptureImage, CaptureImage,
PerformanceMetrics, PerformanceMetrics,
TemplateTestResponse, TemplateTestResponse,
) )
from wled_controller.api.schemas.picture_sources import ( from ledgrab.api.schemas.picture_sources import (
ImageValidateRequest, ImageValidateRequest,
ImageValidateResponse, ImageValidateResponse,
PictureSourceCreate, PictureSourceCreate,
@@ -35,20 +35,20 @@ from wled_controller.api.schemas.picture_sources import (
StaticImagePictureSourceResponse, StaticImagePictureSourceResponse,
VideoPictureSourceResponse, VideoPictureSourceResponse,
) )
from wled_controller.core.capture_engines import EngineRegistry from ledgrab.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool from ledgrab.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.storage.template_store import TemplateStore from ledgrab.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore from ledgrab.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ( from ledgrab.storage.picture_source import (
ProcessedPictureSource, ProcessedPictureSource,
ScreenCapturePictureSource, ScreenCapturePictureSource,
StaticImagePictureSource, StaticImagePictureSource,
VideoCaptureSource, VideoCaptureSource,
) )
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) 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.""" """Validate an image source (URL or file path) and return a preview thumbnail."""
try: 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() source = data.image_source.strip()
if not source: if not source:
@@ -161,7 +161,7 @@ async def validate_image(
img_bytes = path img_bytes = path
def _process_image(src): def _process_image(src):
from wled_controller.utils.image_codec import ( from ledgrab.utils.image_codec import (
encode_jpeg_data_uri, encode_jpeg_data_uri,
load_image_bytes, load_image_bytes,
load_image_file, load_image_file,
@@ -198,7 +198,7 @@ async def get_full_image(
): ):
"""Serve the full-resolution image for lightbox preview.""" """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: try:
if source.startswith(("http://", "https://")): if source.startswith(("http://", "https://")):
@@ -214,7 +214,7 @@ async def get_full_image(
img_bytes = path img_bytes = path
def _encode_full(src): def _encode_full(src):
from wled_controller.utils.image_codec import ( from ledgrab.utils.image_codec import (
encode_jpeg, encode_jpeg,
load_image_bytes, load_image_bytes,
load_image_file, load_image_file,
@@ -375,9 +375,9 @@ async def get_video_thumbnail(
store: PictureSourceStore = Depends(get_picture_source_store), store: PictureSourceStore = Depends(get_picture_source_store),
): ):
"""Get a thumbnail for a video picture source (first frame).""" """Get a thumbnail for a video picture source (first frame)."""
from wled_controller.core.processing.video_stream import extract_thumbnail from ledgrab.core.processing.video_stream import extract_thumbnail
from wled_controller.storage.picture_source import VideoCaptureSource from ledgrab.storage.picture_source import VideoCaptureSource
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
try: try:
source = store.get_stream(stream_id) 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") raise HTTPException(status_code=400, detail="Not a video source")
# Resolve video asset to file path # 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() asset_store = _get_asset_store()
video_path = ( video_path = (
@@ -449,8 +449,8 @@ async def test_picture_source(
if isinstance(raw_stream, StaticImagePictureSource): if isinstance(raw_stream, StaticImagePictureSource):
# Static image stream: load image from asset # Static image stream: load image 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
from wled_controller.utils.image_codec import load_image_file from ledgrab.utils.image_codec import load_image_file
asset_store = _get_asset_store() asset_store = _get_asset_store()
image_path = ( image_path = (
@@ -531,7 +531,7 @@ async def test_picture_source(
image = last_frame.image image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread) # 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, encode_jpeg_data_uri,
thumbnail as make_thumbnail, thumbnail as make_thumbnail,
) )
@@ -628,11 +628,11 @@ async def test_picture_source_ws(
preview_width: int = Query(0), preview_width: int = Query(0),
): ):
"""WebSocket for picture source test with intermediate frame previews.""" """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, authenticate_ws_token,
stream_capture_test, stream_capture_test,
) )
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
get_picture_source_store as _get_ps_store, get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store, get_template_store as _get_t_store,
get_pp_template_store as _get_pp_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 # Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource): if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream from ledgrab.core.processing.video_stream import VideoCaptureLiveStream
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2 from ledgrab.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2() asset_store = _get_asset_store2()
video_path = ( video_path = (
@@ -690,7 +690,7 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw): def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI.""" """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: if pw:
image = resize_down(image, pw) image = resize_down(image, pw)
@@ -5,34 +5,34 @@ import time
import numpy as np import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_picture_source_store, get_picture_source_store,
get_pp_template_store, get_pp_template_store,
get_template_store, get_template_store,
) )
from wled_controller.api.schemas.common import ( from ledgrab.api.schemas.common import (
CaptureImage, CaptureImage,
PerformanceMetrics, PerformanceMetrics,
TemplateTestResponse, TemplateTestResponse,
) )
from wled_controller.api.schemas.filters import FilterInstanceSchema from ledgrab.api.schemas.filters import FilterInstanceSchema
from wled_controller.api.schemas.postprocessing import ( from ledgrab.api.schemas.postprocessing import (
PostprocessingTemplateCreate, PostprocessingTemplateCreate,
PostprocessingTemplateListResponse, PostprocessingTemplateListResponse,
PostprocessingTemplateResponse, PostprocessingTemplateResponse,
PostprocessingTemplateUpdate, PostprocessingTemplateUpdate,
PPTemplateTestRequest, PPTemplateTestRequest,
) )
from wled_controller.core.capture_engines import EngineRegistry from ledgrab.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool from ledgrab.core.filters import FilterRegistry, FilterInstance, ImagePool
from wled_controller.storage.template_store import TemplateStore from ledgrab.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore from ledgrab.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource from ledgrab.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) 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( async def list_pp_templates(
_auth: AuthRequired, _auth: AuthRequired,
store: PostprocessingTemplateStore = Depends(get_pp_template_store), store: PostprocessingTemplateStore = Depends(get_pp_template_store),
@@ -63,7 +67,12 @@ async def list_pp_templates(
return PostprocessingTemplateListResponse(templates=responses, count=len(responses)) 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( async def create_pp_template(
data: PostprocessingTemplateCreate, data: PostprocessingTemplateCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -90,7 +99,11 @@ async def create_pp_template(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def get_pp_template(
template_id: str, template_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -101,10 +114,16 @@ async def get_pp_template(
template = store.get_template(template_id) template = store.get_template(template_id)
return _pp_template_to_response(template) return _pp_template_to_response(template)
except ValueError: 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( async def update_pp_template(
template_id: str, template_id: str,
data: PostprocessingTemplateUpdate, data: PostprocessingTemplateUpdate,
@@ -113,7 +132,11 @@ async def update_pp_template(
): ):
"""Update a postprocessing template.""" """Update a postprocessing template."""
try: 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 = store.update_template(
template_id=template_id, template_id=template_id,
name=data.name, name=data.name,
@@ -133,7 +156,11 @@ async def update_pp_template(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def delete_pp_template(
template_id: str, template_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -165,7 +192,11 @@ async def delete_pp_template(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def test_pp_template(
template_id: str, template_id: str,
test_request: PPTemplateTestRequest, test_request: PPTemplateTestRequest,
@@ -194,7 +225,7 @@ async def test_pp_template(
raw_stream = chain["raw_stream"] raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import ( from ledgrab.utils.image_codec import (
encode_jpeg_data_uri, encode_jpeg_data_uri,
load_image_file, load_image_file,
thumbnail as make_thumbnail, thumbnail as make_thumbnail,
@@ -202,10 +233,14 @@ async def test_pp_template(
if isinstance(raw_stream, StaticImagePictureSource): if isinstance(raw_stream, StaticImagePictureSource):
# Static image: load from asset # 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() 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: if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file") raise HTTPException(status_code=400, detail="Image asset not found or missing file")
@@ -238,7 +273,9 @@ async def test_pp_template(
) )
stream.initialize() 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 frame_count = 0
total_capture_time = 0.0 total_capture_time = 0.0
@@ -346,11 +383,11 @@ async def test_pp_template_ws(
preview_width: int = Query(0), preview_width: int = Query(0),
): ):
"""WebSocket for PP template test with intermediate frame previews.""" """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, authenticate_ws_token,
stream_capture_test, stream_capture_test,
) )
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
get_picture_source_store as _get_ps_store, get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store, get_template_store as _get_t_store,
get_pp_template_store as _get_pp_store, get_pp_template_store as _get_pp_store,
@@ -400,7 +437,9 @@ async def test_pp_template_ws(
return return
if capture_template.engine_type not in EngineRegistry.get_available_engines(): 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 return
# Resolve PP filters # Resolve PP filters
@@ -422,7 +461,9 @@ async def test_pp_template_ws(
try: try:
await stream_capture_test( await stream_capture_test(
websocket, engine_factory, duration, websocket,
engine_factory,
duration,
pp_filters=pp_filters, pp_filters=pp_filters,
preview_width=preview_width or None, preview_width=preview_width or None,
) )
@@ -5,30 +5,30 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
get_scene_preset_store, get_scene_preset_store,
) )
from wled_controller.api.schemas.scene_presets import ( from ledgrab.api.schemas.scene_presets import (
ActivateResponse, ActivateResponse,
ScenePresetCreate, ScenePresetCreate,
ScenePresetListResponse, ScenePresetListResponse,
ScenePresetResponse, ScenePresetResponse,
ScenePresetUpdate, ScenePresetUpdate,
) )
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from wled_controller.core.scenes.scene_activator import ( from ledgrab.core.scenes.scene_activator import (
apply_scene_state, apply_scene_state,
capture_current_snapshot, capture_current_snapshot,
) )
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.storage.scene_preset import ScenePreset from ledgrab.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore from ledgrab.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -39,13 +39,16 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
id=preset.id, id=preset.id,
name=preset.name, name=preset.name,
description=preset.description, description=preset.description,
targets=[{ targets=[
{
"target_id": t.target_id, "target_id": t.target_id,
"running": t.running, "running": t.running,
"color_strip_source_id": t.color_strip_source_id, "color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id, "brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps, "fps": t.fps,
} for t in preset.targets], }
for t in preset.targets
],
order=preset.order, order=preset.order,
tags=preset.tags, tags=preset.tags,
created_at=preset.created_at, created_at=preset.created_at,
@@ -55,6 +58,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
# ===== CRUD ===== # ===== CRUD =====
@router.post( @router.post(
"/api/v1/scene-presets", "/api/v1/scene-presets",
response_model=ScenePresetResponse, response_model=ScenePresetResponse,
@@ -180,7 +184,9 @@ async def update_scene_preset(
tags=data.tags, tags=data.tags,
) )
except ValueError as e: 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) fire_entity_event("scene_preset", "updated", preset_id)
return _preset_to_response(preset) return _preset_to_response(preset)
@@ -206,6 +212,7 @@ async def delete_scene_preset(
# ===== Recapture ===== # ===== Recapture =====
@router.post( @router.post(
"/api/v1/scene-presets/{preset_id}/recapture", "/api/v1/scene-presets/{preset_id}/recapture",
response_model=ScenePresetResponse, response_model=ScenePresetResponse,
@@ -244,6 +251,7 @@ async def recapture_scene_preset(
# ===== Activate ===== # ===== Activate =====
@router.post( @router.post(
"/api/v1/scene-presets/{preset_id}/activate", "/api/v1/scene-presets/{preset_id}/activate",
response_model=ActivateResponse, response_model=ActivateResponse,
@@ -2,25 +2,25 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_color_strip_store, get_color_strip_store,
get_sync_clock_manager, get_sync_clock_manager,
get_sync_clock_store, get_sync_clock_store,
) )
from wled_controller.api.schemas.sync_clocks import ( from ledgrab.api.schemas.sync_clocks import (
SyncClockCreate, SyncClockCreate,
SyncClockListResponse, SyncClockListResponse,
SyncClockResponse, SyncClockResponse,
SyncClockUpdate, SyncClockUpdate,
) )
from wled_controller.storage.sync_clock import SyncClock from ledgrab.storage.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore from ledgrab.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_store import ColorStripStore from ledgrab.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) 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( async def create_sync_clock(
data: SyncClockCreate, data: SyncClockCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -81,7 +83,9 @@ async def create_sync_clock(
raise HTTPException(status_code=400, detail=str(e)) 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( async def get_sync_clock(
clock_id: str, clock_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -96,7 +100,9 @@ async def get_sync_clock(
raise HTTPException(status_code=404, detail=str(e)) 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( async def update_sync_clock(
clock_id: str, clock_id: str,
data: SyncClockUpdate, data: SyncClockUpdate,
@@ -138,9 +144,7 @@ async def delete_sync_clock(
# Check references # Check references
for source in css_store.get_all_sources(): for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id: if getattr(source, "clock_id", None) == clock_id:
raise ValueError( raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
f"Cannot delete: referenced by color strip source '{source.name}'"
)
manager.release_all_for(clock_id) manager.release_all_for(clock_id)
store.delete_clock(clock_id) store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id) fire_entity_event("sync_clock", "deleted", clock_id)
@@ -153,7 +157,10 @@ async def delete_sync_clock(
# ── Runtime control ────────────────────────────────────────────────── # ── 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( async def pause_sync_clock(
clock_id: str, clock_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -170,7 +177,9 @@ async def pause_sync_clock(
return _to_response(clock, manager) 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( async def resume_sync_clock(
clock_id: str, clock_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -187,7 +196,9 @@ async def resume_sync_clock(
return _to_response(clock, manager) 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( async def reset_sync_clock(
clock_id: str, clock_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -15,9 +15,9 @@ import os
import psutil import psutil
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__, REPO_URL, DONATE_URL from ledgrab import __version__, REPO_URL, DONATE_URL
from wled_controller.api.auth import AuthRequired, is_auth_enabled from ledgrab.api.auth import AuthRequired, is_auth_enabled
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
get_audio_source_store, get_audio_source_store,
get_audio_template_store, get_audio_template_store,
get_automation_store, get_automation_store,
@@ -34,7 +34,7 @@ from wled_controller.api.dependencies import (
get_template_store, get_template_store,
get_value_source_store, get_value_source_store,
) )
from wled_controller.api.schemas.system import ( from ledgrab.api.schemas.system import (
DisplayInfo, DisplayInfo,
DisplayListResponse, DisplayListResponse,
GpuInfo, GpuInfo,
@@ -43,13 +43,13 @@ from wled_controller.api.schemas.system import (
ProcessListResponse, ProcessListResponse,
VersionResponse, VersionResponse,
) )
from wled_controller.config import get_config, is_demo_mode from ledgrab.config import get_config, is_demo_mode
from wled_controller.core.capture.screen_capture import get_available_displays from ledgrab.core.capture.screen_capture import get_available_displays
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
# Re-export load_external_url so existing callers still work # 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__) logger = get_logger(__name__)
@@ -59,7 +59,7 @@ _process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter _process.cpu_percent(interval=None) # prime process-level counter
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history) # 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_available as _nvml_available,
nvml as _nvml, nvml as _nvml,
nvml_handle as _nvml_handle, nvml_handle as _nvml_handle,
@@ -139,7 +139,7 @@ async def get_version():
async def list_all_tags(_: AuthRequired): async def list_all_tags(_: AuthRequired):
"""Get all tags used across all entities.""" """Get all tags used across all entities."""
all_tags: set[str] = set() all_tags: set[str] = set()
from wled_controller.api.dependencies import get_asset_store from ledgrab.api.dependencies import get_asset_store
store_getters = [ store_getters = [
get_device_store, get_device_store,
@@ -185,7 +185,7 @@ async def get_displays(
logger.info(f"Listing available displays (engine_type={engine_type})") logger.info(f"Listing available displays (engine_type={engine_type})")
try: try:
from wled_controller.core.capture_engines import EngineRegistry from ledgrab.core.capture_engines import EngineRegistry
if engine_type: if engine_type:
engine_cls = EngineRegistry.get_engine(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. 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: try:
detector = PlatformDetector() detector = PlatformDetector()
@@ -348,7 +348,7 @@ async def get_integrations_status(
Used by the dashboard to show connectivity indicators. 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 status
mqtt_service = get_mqtt_service() mqtt_service = get_mqtt_service()
@@ -10,9 +10,9 @@ import re
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel from pydantic import BaseModel
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import get_database from ledgrab.api.dependencies import get_database
from wled_controller.api.schemas.system import ( from ledgrab.api.schemas.system import (
ExternalUrlRequest, ExternalUrlRequest,
ExternalUrlResponse, ExternalUrlResponse,
LogLevelRequest, LogLevelRequest,
@@ -20,9 +20,9 @@ from wled_controller.api.schemas.system import (
MQTTSettingsRequest, MQTTSettingsRequest,
MQTTSettingsResponse, MQTTSettingsResponse,
) )
from wled_controller.config import get_config from ledgrab.config import get_config
from wled_controller.storage.database import Database from ledgrab.storage.database import Database
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -76,7 +76,9 @@ async def get_mqtt_settings(_: AuthRequired, db: Database = Depends(get_database
response_model=MQTTSettingsResponse, response_model=MQTTSettingsResponse,
tags=["System"], 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.""" """Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings(db) current = _load_mqtt_settings(db)
@@ -110,10 +112,12 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest, db: D
# External URL setting # External URL setting
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def load_external_url(db: Database | None = None) -> str: def load_external_url(db: Database | None = None) -> str:
"""Load the external URL setting. Returns empty string if not set.""" """Load the external URL setting. Returns empty string if not set."""
if db is None: if db is None:
from wled_controller.api.dependencies import get_database from ledgrab.api.dependencies import get_database
db = get_database() db = get_database()
data = db.get_setting("external_url") data = db.get_setting("external_url")
if data: if data:
@@ -136,7 +140,9 @@ async def get_external_url(_: AuthRequired, db: Database = Depends(get_database)
response_model=ExternalUrlResponse, response_model=ExternalUrlResponse,
tags=["System"], 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.""" """Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/") url = body.external_url.strip().rstrip("/")
db.set_setting("external_url", {"external_url": url}) 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 Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear. lines as individual text messages, then pushes new lines as they appear.
""" """
from wled_controller.api.auth import verify_ws_token from ledgrab.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster from ledgrab.utils import log_broadcaster
if not verify_ws_token(token): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") 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" # Regex: IPv4 address with optional port, e.g. "192.168.1.5" or "192.168.1.5:5555"
_ADB_ADDRESS_RE = re.compile( _ADB_ADDRESS_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$")
r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$"
)
class AdbConnectRequest(BaseModel): class AdbConnectRequest(BaseModel):
@@ -244,7 +248,8 @@ def _validate_adb_address(address: str) -> None:
def _get_adb_path() -> str: def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver.""" """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() return _get_adb()
@@ -265,7 +270,9 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
logger.info(f"Connecting ADB device: {address}") logger.info(f"Connecting ADB device: {address}")
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
adb, "connect", address, adb,
"connect",
address,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=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}") logger.info(f"Disconnecting ADB device: {address}")
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address, adb,
"disconnect",
address,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@@ -5,20 +5,20 @@ import time
import numpy as np import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_cspt_store, get_cspt_store,
get_picture_source_store, get_picture_source_store,
get_pp_template_store, get_pp_template_store,
get_template_store, get_template_store,
) )
from wled_controller.api.schemas.common import ( from ledgrab.api.schemas.common import (
CaptureImage, CaptureImage,
PerformanceMetrics, PerformanceMetrics,
TemplateTestResponse, TemplateTestResponse,
) )
from wled_controller.api.schemas.templates import ( from ledgrab.api.schemas.templates import (
EngineInfo, EngineInfo,
EngineListResponse, EngineListResponse,
TemplateCreate, TemplateCreate,
@@ -27,18 +27,18 @@ from wled_controller.api.schemas.templates import (
TemplateTestRequest, TemplateTestRequest,
TemplateUpdate, TemplateUpdate,
) )
from wled_controller.api.schemas.filters import ( from ledgrab.api.schemas.filters import (
FilterOptionDefSchema, FilterOptionDefSchema,
FilterTypeListResponse, FilterTypeListResponse,
FilterTypeResponse, FilterTypeResponse,
) )
from wled_controller.core.capture_engines import EngineRegistry from ledgrab.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry from ledgrab.core.filters import FilterRegistry
from wled_controller.storage.template_store import TemplateStore from ledgrab.storage.template_store import TemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore from ledgrab.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource from ledgrab.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -47,6 +47,7 @@ router = APIRouter()
# ===== CAPTURE TEMPLATE ENDPOINTS ===== # ===== CAPTURE TEMPLATE ENDPOINTS =====
@router.get("/api/v1/capture-templates", response_model=TemplateListResponse, tags=["Templates"]) @router.get("/api/v1/capture-templates", response_model=TemplateListResponse, tags=["Templates"])
async def list_templates( async def list_templates(
_auth: AuthRequired, _auth: AuthRequired,
@@ -80,7 +81,12 @@ async def list_templates(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def create_template(
template_data: TemplateCreate, template_data: TemplateCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -111,7 +117,6 @@ async def create_template(
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -119,7 +124,9 @@ async def create_template(
raise HTTPException(status_code=500, detail="Internal server error") 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( async def get_template(
template_id: str, template_id: str,
_auth: AuthRequired, _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( async def update_template(
template_id: str, template_id: str,
update_data: TemplateUpdate, update_data: TemplateUpdate,
@@ -176,7 +185,6 @@ async def update_template(
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
@@ -199,7 +207,10 @@ async def delete_template(
# Check if any streams are using this template # Check if any streams are using this template
streams_using_template = [] streams_using_template = []
for stream in stream_store.get_all_streams(): 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) streams_using_template.append(stream.name)
if streams_using_template: if streams_using_template:
@@ -207,7 +218,7 @@ async def delete_template(
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. " 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 # Proceed with deletion
@@ -245,7 +256,7 @@ async def list_engines(_auth: AuthRequired):
name=engine_type.upper(), name=engine_type.upper(),
default_config=engine_class.get_default_config(), default_config=engine_class.get_default_config(),
available=(engine_type in available_set), 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") 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( def test_template(
test_request: TemplateTestRequest, test_request: TemplateTestRequest,
_auth: AuthRequired, _auth: AuthRequired,
@@ -276,7 +289,7 @@ def test_template(
if test_request.engine_type not in EngineRegistry.get_available_engines(): if test_request.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException( raise HTTPException(
status_code=400, 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 # Create and initialize capture stream
@@ -286,7 +299,9 @@ def test_template(
stream.initialize() stream.initialize()
# Run sustained capture test # 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 frame_count = 0
total_capture_time = 0.0 total_capture_time = 0.0
@@ -321,7 +336,7 @@ def test_template(
raise ValueError("Unexpected image format from engine") raise ValueError("Unexpected image format from engine")
image = last_frame.image image = last_frame.image
from wled_controller.utils.image_codec import ( from ledgrab.utils.image_codec import (
encode_jpeg_data_uri, encode_jpeg_data_uri,
thumbnail as make_thumbnail, thumbnail as make_thumbnail,
) )
@@ -361,7 +376,6 @@ def test_template(
except EntityNotFoundError as e: except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as 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, Config is sent as the first client message (JSON with engine_type,
engine_config, display_index, capture_duration). engine_config, display_index, capture_duration).
""" """
from wled_controller.api.routes._preview_helpers import ( from ledgrab.api.routes._preview_helpers import (
authenticate_ws_token, authenticate_ws_token,
stream_capture_test, stream_capture_test,
) )
@@ -417,7 +431,9 @@ async def test_template_ws(
pw = int(config.get("preview_width", 0)) or None pw = int(config.get("preview_width", 0)) or None
if engine_type not in EngineRegistry.get_available_engines(): 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) await websocket.close(code=4003)
return return
@@ -428,7 +444,9 @@ async def test_template_ws(
s.initialize() s.initialize()
return s 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: try:
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw) await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
@@ -443,6 +461,7 @@ async def test_template_ws(
# ===== FILTER TYPE ENDPOINTS ===== # ===== FILTER TYPE ENDPOINTS =====
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"]) @router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_filter_types( async def list_filter_types(
_auth: AuthRequired, _auth: AuthRequired,
@@ -467,9 +486,14 @@ async def list_filter_types(
for opt in schema: for opt in schema:
choices = opt.choices choices = opt.choices
# Enrich filter_template choices with current template list # 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 choices = template_choices
opt_schemas.append(FilterOptionDefSchema( opt_schemas.append(
FilterOptionDefSchema(
key=opt.key, key=opt.key,
label=opt.label, label=opt.label,
type=opt.option_type, type=opt.option_type,
@@ -478,12 +502,15 @@ async def list_filter_types(
max_value=opt.max_value, max_value=opt.max_value,
step=opt.step, step=opt.step,
choices=choices, choices=choices,
)) )
responses.append(FilterTypeResponse( )
responses.append(
FilterTypeResponse(
filter_id=filter_cls.filter_id, filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name, filter_name=filter_cls.filter_name,
options_schema=opt_schemas, options_schema=opt_schemas,
)) )
)
return FilterTypeListResponse(filters=responses, count=len(responses)) return FilterTypeListResponse(filters=responses, count=len(responses))
@@ -512,9 +539,14 @@ async def list_strip_filter_types(
opt_schemas = [] opt_schemas = []
for opt in schema: for opt in schema:
choices = opt.choices 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 choices = cspt_choices
opt_schemas.append(FilterOptionDefSchema( opt_schemas.append(
FilterOptionDefSchema(
key=opt.key, key=opt.key,
label=opt.label, label=opt.label,
type=opt.option_type, type=opt.option_type,
@@ -523,10 +555,13 @@ async def list_strip_filter_types(
max_value=opt.max_value, max_value=opt.max_value,
step=opt.step, step=opt.step,
choices=choices, choices=choices,
)) )
responses.append(FilterTypeResponse( )
responses.append(
FilterTypeResponse(
filter_id=filter_cls.filter_id, filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name, filter_name=filter_cls.filter_name,
options_schema=opt_schemas, options_schema=opt_schemas,
)) )
)
return FilterTypeListResponse(filters=responses, count=len(responses)) return FilterTypeListResponse(filters=responses, count=len(responses))
@@ -3,16 +3,16 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import get_update_service from ledgrab.api.dependencies import get_update_service
from wled_controller.api.schemas.update import ( from ledgrab.api.schemas.update import (
DismissRequest, DismissRequest,
UpdateSettingsRequest, UpdateSettingsRequest,
UpdateSettingsResponse, UpdateSettingsResponse,
UpdateStatusResponse, UpdateStatusResponse,
) )
from wled_controller.core.update.update_service import UpdateService from ledgrab.core.update.update_service import UpdateService
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -57,7 +57,9 @@ async def apply_update(
if not status["can_auto_update"]: if not status["can_auto_update"]:
return JSONResponse( return JSONResponse(
status_code=400, 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: try:
await service.apply_update() await service.apply_update()
@@ -5,14 +5,14 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_output_target_store, get_output_target_store,
get_processor_manager, get_processor_manager,
get_value_source_store, get_value_source_store,
) )
from wled_controller.api.schemas.value_sources import ( from ledgrab.api.schemas.value_sources import (
AdaptiveSceneValueSourceResponse, AdaptiveSceneValueSourceResponse,
AdaptiveTimeColorValueSourceResponse, AdaptiveTimeColorValueSourceResponse,
AdaptiveTimeValueSourceResponse, AdaptiveTimeValueSourceResponse,
@@ -31,7 +31,7 @@ from wled_controller.api.schemas.value_sources import (
ValueSourceResponse, ValueSourceResponse,
ValueSourceUpdate, ValueSourceUpdate,
) )
from wled_controller.storage.value_source import ( from ledgrab.storage.value_source import (
AdaptiveTimeColorValueSource, AdaptiveTimeColorValueSource,
AdaptiveValueSource, AdaptiveValueSource,
AnimatedColorValueSource, AnimatedColorValueSource,
@@ -46,12 +46,12 @@ from wled_controller.storage.value_source import (
SystemMetricsValueSource, SystemMetricsValueSource,
ValueSource, ValueSource,
) )
from wled_controller.storage.value_source_store import ValueSourceStore from ledgrab.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore from ledgrab.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager from ledgrab.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.value_stream import ValueStream from ledgrab.core.processing.value_stream import ValueStream
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -340,7 +340,7 @@ async def delete_value_source(
"""Delete a value source.""" """Delete a value source."""
try: try:
# Check if any targets reference this value source # 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(): for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget): 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, Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
and streams {value: float} JSON to the client. 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): if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized") await websocket.close(code=4001, reason="Unauthorized")
@@ -2,25 +2,25 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from ledgrab.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from ledgrab.api.dependencies import (
fire_entity_event, fire_entity_event,
get_weather_manager, get_weather_manager,
get_weather_source_store, get_weather_source_store,
) )
from wled_controller.api.schemas.weather_sources import ( from ledgrab.api.schemas.weather_sources import (
WeatherSourceCreate, WeatherSourceCreate,
WeatherSourceListResponse, WeatherSourceListResponse,
WeatherSourceResponse, WeatherSourceResponse,
WeatherSourceUpdate, WeatherSourceUpdate,
WeatherTestResponse, WeatherTestResponse,
) )
from wled_controller.core.weather.weather_manager import WeatherManager from ledgrab.core.weather.weather_manager import WeatherManager
from wled_controller.core.weather.weather_provider import WMO_CONDITION_NAMES from ledgrab.core.weather.weather_provider import WMO_CONDITION_NAMES
from wled_controller.storage.base_store import EntityNotFoundError from ledgrab.storage.base_store import EntityNotFoundError
from wled_controller.storage.weather_source import WeatherSource from ledgrab.storage.weather_source import WeatherSource
from wled_controller.storage.weather_source_store import WeatherSourceStore from ledgrab.storage.weather_source_store import WeatherSourceStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) 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( async def list_weather_sources(
_auth: AuthRequired, _auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store), 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( async def create_weather_source(
data: WeatherSourceCreate, data: WeatherSourceCreate,
_auth: AuthRequired, _auth: AuthRequired,
@@ -79,7 +86,11 @@ async def create_weather_source(
return _to_response(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( async def get_weather_source(
source_id: str, source_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -91,7 +102,11 @@ async def get_weather_source(
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found") 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( async def update_weather_source(
source_id: str, source_id: str,
data: WeatherSourceUpdate, data: WeatherSourceUpdate,
@@ -133,7 +148,11 @@ async def delete_weather_source(
fire_entity_event("weather_source", "deleted", source_id) 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( async def test_weather_source(
source_id: str, source_id: str,
_auth: AuthRequired, _auth: AuthRequired,
@@ -13,11 +13,11 @@ from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from wled_controller.api.dependencies import get_automation_engine, get_automation_store from ledgrab.api.dependencies import get_automation_engine, get_automation_store
from wled_controller.core.automations.automation_engine import AutomationEngine from ledgrab.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import WebhookCondition from ledgrab.storage.automation import WebhookCondition
from wled_controller.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from wled_controller.utils import get_logger from ledgrab.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
@@ -75,12 +75,17 @@ async def handle_webhook(
# Find the automation that owns this token # Find the automation that owns this token
for automation in store.get_all_automations(): for automation in store.get_all_automations():
for condition in automation.conditions: 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" active = body.action == "activate"
await engine.set_webhook_state(token, active) await engine.set_webhook_state(token, active)
logger.info( logger.info(
"Webhook %s: automation '%s' (%s) → %s", "Webhook %s: automation '%s' (%s) → %s",
token[:8], automation.name, automation.id, body.action, token[:8],
automation.name,
automation.id,
body.action,
) )
return { return {
"ok": True, "ok": True,
@@ -10,7 +10,9 @@ class AudioTemplateCreate(BaseModel):
"""Request to create an audio capture template.""" """Request to create an audio capture template."""
name: str = Field(description="Template name", min_length=1, max_length=100) 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") engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") 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.""" """Request to create a color strip processing template."""
name: str = Field(description="Template name", min_length=1, max_length=100) 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) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") 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.""" """Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) 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) description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None 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 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.""" """Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail") 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") performance: PerformanceMetrics = Field(description="Performance metrics")
@@ -22,8 +22,12 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value") min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value") max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment") step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type") choices: Optional[List[Dict[str, str]]] = Field(
max_length: Optional[int] = Field(default=None, description="Maximum string length for string type") 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): class FilterTypeResponse(BaseModel):

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