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