Compare commits
41 Commits
v0.1.0-alp
...
v0.2.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 7939322a7f | |||
| 7da5084337 | |||
| d9cb1eb225 | |||
| 9a3433a733 | |||
| 382a42755d | |||
| d2b3fdf786 | |||
| 2da5c047f9 | |||
| 9dfd2365f4 | |||
| 29fb944494 | |||
| ea5dc47641 | |||
| 347b252f06 | |||
| d6f796a499 | |||
| c1940dadb7 | |||
| ae0a5cb160 | |||
| a62e2f474d | |||
| ef33935188 | |||
| 0723c5c68c | |||
| bbef7e5869 | |||
| 4caafbb78f | |||
| 9b4dbac088 | |||
| 73947eb6cb | |||
| b63944bb34 | |||
| 40e951c882 | |||
| 524f910cf0 | |||
| 178d115cc5 | |||
| fc62d5d3b1 | |||
| 1111ab7355 | |||
| c0d0d839dc | |||
| 227b82f522 | |||
| 6a881f8fdd | |||
| c26aec916e | |||
| 2c3f08344c | |||
| 9b80076b5b | |||
| c4dce19b2e | |||
| b27ac8783b | |||
| 73b2ee6222 | |||
| 1b5b04afaa | |||
| 4975a74ff3 | |||
| cd3137b0ec | |||
| e391346b4b | |||
| f376622482 |
@@ -74,9 +74,16 @@ jobs:
|
||||
\"prerelease\": $IS_PRE
|
||||
}")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
# Fallback: if release already exists for this tag, fetch it instead
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "::warning::Release already exists for tag $TAG — reusing existing release"
|
||||
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
|
||||
-H "Authorization: token $GITEA_TOKEN")
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
fi
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Created release ID: $RELEASE_ID"
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
|
||||
# ── Windows portable ZIP (cross-built from Linux) ─────────
|
||||
build-windows:
|
||||
@@ -124,27 +131,31 @@ jobs:
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
# Upload ZIP
|
||||
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
|
||||
if [ -f "$ZIP_FILE" ]; then
|
||||
# Upload helper — deletes existing asset with same name to prevent duplicates on re-run
|
||||
upload_asset() {
|
||||
local FILE="$1"
|
||||
local NAME=$(basename "$FILE")
|
||||
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
echo "Replaced existing asset: $NAME"
|
||||
fi
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$ZIP_FILE")" \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$ZIP_FILE"
|
||||
echo "Uploaded: $(basename "$ZIP_FILE")"
|
||||
fi
|
||||
--data-binary "@$FILE"
|
||||
echo "Uploaded: $NAME"
|
||||
}
|
||||
|
||||
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
|
||||
[ -f "$ZIP_FILE" ] && upload_asset "$ZIP_FILE"
|
||||
|
||||
# Upload installer
|
||||
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
|
||||
if [ -f "$SETUP_FILE" ]; then
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$SETUP_FILE")" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$SETUP_FILE"
|
||||
echo "Uploaded: $(basename "$SETUP_FILE")"
|
||||
fi
|
||||
[ -f "$SETUP_FILE" ] && upload_asset "$SETUP_FILE"
|
||||
|
||||
# ── Linux tarball ──────────────────────────────────────────
|
||||
build-linux:
|
||||
@@ -187,18 +198,26 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
|
||||
TAR_NAME=$(basename "$TAR_FILE")
|
||||
|
||||
# Delete existing asset with same name to prevent duplicates on re-run
|
||||
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$TAR_NAME'),''))" 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
echo "Replaced existing asset: $TAR_NAME"
|
||||
fi
|
||||
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$TAR_NAME" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$TAR_FILE"
|
||||
|
||||
echo "Uploaded: $TAR_NAME"
|
||||
|
||||
# ── Docker image ───────────────────────────────────────────
|
||||
@@ -240,6 +259,7 @@ jobs:
|
||||
REGISTRY="${{ steps.meta.outputs.registry }}"
|
||||
|
||||
docker build \
|
||||
--build-arg APP_VERSION="${{ steps.meta.outputs.version }}" \
|
||||
--label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \
|
||||
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
|
||||
-t "$REGISTRY:$TAG" \
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -39,6 +39,7 @@ ast-index changed --base master # Show symbols changed in current bran
|
||||
| [contexts/server-operations.md](contexts/server-operations.md) | Server restart, startup modes, demo mode |
|
||||
| [contexts/chrome-tools.md](contexts/chrome-tools.md) | Chrome MCP tool usage for testing |
|
||||
| [contexts/ci-cd.md](contexts/ci-cd.md) | CI/CD pipelines, release workflow, build scripts |
|
||||
| [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) | Reusable CI/CD patterns: Gitea Actions, cross-build, NSIS, Docker |
|
||||
| [server/CLAUDE.md](server/CLAUDE.md) | Backend architecture, API patterns, common tasks |
|
||||
|
||||
## Task Tracking via TODO.md
|
||||
@@ -65,15 +66,24 @@ This applies to: file paths in `StorageConfig`, JSON root keys (e.g. `picture_ta
|
||||
|
||||
**Incident context:** A past rename of `picture_targets.json` → `output_targets.json` was done without migration. The app created a new empty `output_targets.json` while the user's 7 targets sat unread in the old file. Data was silently lost.
|
||||
|
||||
## UI Component Rules (CRITICAL)
|
||||
|
||||
**NEVER use plain HTML `<select>` elements.** The project uses custom selector components:
|
||||
- **IconSelect** (icon grid) — for predefined items (effect types, palettes, easing modes, animation types)
|
||||
- **EntitySelect** (entity picker) — for entity references (sources, templates, devices)
|
||||
|
||||
Plain HTML selects break the visual consistency of the UI.
|
||||
|
||||
## Pre-Commit Checks (MANDATORY)
|
||||
|
||||
Before every commit, run the relevant linters and fix any issues:
|
||||
Before every commit, run the relevant checks and fix any issues:
|
||||
|
||||
- **Python changes**: `cd server && ruff check src/ tests/ --fix`
|
||||
- **TypeScript changes**: `cd server && npx tsc --noEmit && npm run build`
|
||||
- **Both**: Run both checks
|
||||
- **Always run tests**: `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests MUST pass before committing. Do NOT commit code that fails tests.
|
||||
|
||||
Do NOT commit code that fails linting. Fix the issues first.
|
||||
Do NOT commit code that fails linting or tests. Fix the issues first.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
|
||||
114
TODO-css-improvements.md
Normal file
114
TODO-css-improvements.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# TODO
|
||||
|
||||
## IMPORTANT: Remove WLED naming throughout the app
|
||||
|
||||
- [ ] 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`)
|
||||
- [ ] 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)
|
||||
|
||||
---
|
||||
|
||||
## Donation / Open-Source Banner
|
||||
|
||||
- [ ] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
|
||||
- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
||||
- [ ] Remember dismissal in localStorage so it doesn't reappear every session
|
||||
- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
|
||||
|
||||
---
|
||||
|
||||
# Color Strip Source Improvements
|
||||
|
||||
## New Source Types
|
||||
|
||||
- [x] **`weather`** — Weather-reactive ambient: maps weather conditions (rain, snow, clear, storm) to colors/animations via API
|
||||
- [ ] **`music_sync`** — Beat-synced patterns: BPM detection, energy envelope, drop detection (higher-level than raw `audio`)
|
||||
- [ ] **`math_wave`** — Mathematical wave generator: user-defined sine/triangle/sawtooth expressions, superposition
|
||||
- [ ] **`text_scroll`** — Scrolling text marquee: bitmap font rendering, static text or RSS/API data source *(delayed)*
|
||||
|
||||
### Discuss: `home_assistant`
|
||||
|
||||
Need to research HAOS communication options first (WebSocket API, REST API, MQTT, etc.) before deciding scope.
|
||||
|
||||
### Deferred
|
||||
|
||||
- `image` — Static image sampler *(not now)*
|
||||
- `clock` — Time display *(not now)*
|
||||
|
||||
## Improvements to Existing Sources
|
||||
|
||||
### `effect` (now 12 types)
|
||||
|
||||
- [x] Add effects: rain, comet, bouncing ball, fireworks, sparkle rain, lava lamp, wave interference
|
||||
- [x] Custom palette support: user-defined [[pos,R,G,B],...] stops via JSON textarea
|
||||
|
||||
### `gradient`
|
||||
|
||||
- [x] Noise-perturbed gradient: value noise displacement on stop positions (`noise_perturb` animation type)
|
||||
- [x] Gradient hue rotation: `hue_rotate` animation type — preserves S/V, rotates H
|
||||
- [x] Easing functions between stops: linear, ease_in_out (smoothstep), step, cubic
|
||||
|
||||
### `audio`
|
||||
|
||||
- [x] New audio source type: band extractor (bass/mid/treble split) — responsibility of audio source layer, not CSS
|
||||
- [ ] Peak hold indicator: global option on audio source (not per-mode), configurable decay time
|
||||
|
||||
### `daylight`
|
||||
|
||||
- [x] Longitude support for accurate solar position (NOAA solar equations)
|
||||
- [x] Season awareness (day-of-year drives sunrise/sunset via solar declination)
|
||||
|
||||
### `candlelight`
|
||||
|
||||
- [x] Wind simulation: correlated flicker bursts across all candles (wind_strength 0.0-2.0)
|
||||
- [x] Candle type presets: taper (steady), votive (flickery), bonfire (chaotic) — applied at render time
|
||||
- [x] Wax drip effect: localized brightness dips with fade-in/fade-out recovery
|
||||
|
||||
### `composite`
|
||||
|
||||
- [ ] Allow nested composites (with cycle detection)
|
||||
- [x] More blend modes: overlay, soft light, hard light, difference, exclusion
|
||||
- [x] Per-layer LED range masks (optional start/end/reverse on each composite layer)
|
||||
|
||||
### `notification`
|
||||
|
||||
- [x] Chase effect (light bounces across strip with glowing tail)
|
||||
- [x] Gradient flash (bright center fades to edges, exponential decay)
|
||||
- [x] Queue priority levels (color_override = high priority, interrupts current)
|
||||
|
||||
### `api_input`
|
||||
|
||||
- [ ] Crossfade transition when new data arrives
|
||||
- [ ] Interpolation when incoming LED count differs from strip count
|
||||
- [ ] Last-write-wins from any client (no multi-source blending)
|
||||
|
||||
## Architectural / Pipeline
|
||||
|
||||
### Processing Templates (CSPT)
|
||||
|
||||
- [x] HSL shift filter (hue rotation + lightness adjustment)
|
||||
- [x] ~~Color temperature filter~~ — already exists as `color_correction`
|
||||
- [x] Contrast filter
|
||||
- [x] ~~Saturation filter~~ — already exists
|
||||
- [x] ~~Pixelation filter~~ — already exists as `pixelate`
|
||||
- [x] Temporal blur filter (blend frames over time)
|
||||
|
||||
### Transition Engine
|
||||
|
||||
Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransition` that defines how source switches happen (crossfade, wipe, etc.). Interacts with automations when they switch a target's active source.
|
||||
|
||||
### Deferred
|
||||
|
||||
- Global BPM sync *(not sure)*
|
||||
- Recording/playback *(not now)*
|
||||
- Source preview in editor modal *(not needed — overlay preview on devices is sufficient)*
|
||||
|
||||
---
|
||||
|
||||
## Remaining Open Discussion
|
||||
|
||||
1. **`home_assistant` source** — Need to research HAOS communication protocols first
|
||||
2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it?
|
||||
37
TODO.md
Normal file
37
TODO.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Build Size Reduction
|
||||
|
||||
## Phase 1: Quick Wins (build scripts)
|
||||
|
||||
- [x] Strip unused NumPy submodules (polynomial, linalg, ma, lib, distutils)
|
||||
- [x] Strip debug symbols from .pyd/.dll/.so files
|
||||
- [x] Remove zeroconf service database
|
||||
- [x] Remove .py source from site-packages after compiling to .pyc
|
||||
- [x] Strip unused PIL image plugins (keep JPEG/PNG/ICO/BMP for tray)
|
||||
|
||||
## Phase 2: Replace Pillow with cv2
|
||||
|
||||
- [x] Create `utils/image_codec.py` with cv2-based image helpers
|
||||
- [x] Replace PIL in `_preview_helpers.py`
|
||||
- [x] Replace PIL in `picture_sources.py`
|
||||
- [x] Replace PIL in `color_strip_sources.py`
|
||||
- [x] Replace PIL in `templates.py`
|
||||
- [x] Replace PIL in `postprocessing.py`
|
||||
- [x] Replace PIL in `output_targets_keycolors.py`
|
||||
- [x] Replace PIL in `kc_target_processor.py`
|
||||
- [x] Replace PIL in `pixelate.py` filter
|
||||
- [x] Replace PIL in `downscaler.py` filter
|
||||
- [x] Replace PIL in `scrcpy_engine.py`
|
||||
- [x] Replace PIL in `live_stream_manager.py`
|
||||
- [x] Move Pillow from core deps to [tray] optional in pyproject.toml
|
||||
- [x] Make PIL import conditional in `tray.py`
|
||||
- [x] Move opencv-python-headless to core dependencies
|
||||
|
||||
## Phase 4: OpenCV stripping (build scripts)
|
||||
|
||||
- [x] Strip ffmpeg DLL, Haar cascades, dev files (already existed)
|
||||
- [x] Strip typing stubs (already existed)
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] Lint: `ruff check src/ tests/ --fix`
|
||||
- [x] Tests: 341 passed
|
||||
140
build-common.sh
Normal file
140
build-common.sh
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Shared build functions for LedGrab distribution packaging.
|
||||
# Sourced by build-dist.sh (Linux) and build-dist-windows.sh (Windows).
|
||||
#
|
||||
# Expected variables set by the caller before sourcing:
|
||||
# SCRIPT_DIR, BUILD_DIR, DIST_DIR, SERVER_DIR, APP_DIR
|
||||
|
||||
# ── Version detection ────────────────────────────────────────
|
||||
|
||||
detect_version() {
|
||||
# Usage: detect_version [explicit_version]
|
||||
local version="${1:-}"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
version=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
if [ -z "$version" ]; then
|
||||
version=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${version#v}"
|
||||
|
||||
# Stamp the resolved version into pyproject.toml so that
|
||||
# importlib.metadata reads the correct value at runtime.
|
||||
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
|
||||
}
|
||||
|
||||
# ── Clean previous build ─────────────────────────────────────
|
||||
|
||||
clean_dist() {
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
echo " Cleaning previous build..."
|
||||
rm -rf "$DIST_DIR"
|
||||
fi
|
||||
mkdir -p "$DIST_DIR"
|
||||
}
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
build_frontend() {
|
||||
echo " Building frontend bundle..."
|
||||
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
|
||||
grep -v 'RemoteException' || true
|
||||
}
|
||||
}
|
||||
|
||||
# ── Copy application files ───────────────────────────────────
|
||||
|
||||
copy_app_files() {
|
||||
echo " Copying application files..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
|
||||
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
|
||||
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
|
||||
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Site-packages cleanup ────────────────────────────────────
|
||||
#
|
||||
# Strips tests, type stubs, unused submodules, and debug symbols
|
||||
# from the installed site-packages directory.
|
||||
#
|
||||
# Args:
|
||||
# $1 — path to site-packages directory
|
||||
# $2 — native extension suffix: "pyd" (Windows) or "so" (Linux)
|
||||
# $3 — native lib suffix for OpenCV ffmpeg: "dll" or "so"
|
||||
|
||||
cleanup_site_packages() {
|
||||
local sp_dir="$1"
|
||||
local ext_suffix="${2:-so}"
|
||||
local lib_suffix="${3:-so}"
|
||||
|
||||
echo " Cleaning up site-packages to reduce size..."
|
||||
|
||||
# ── Generic cleanup ──────────────────────────────────────
|
||||
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name test -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
|
||||
|
||||
# ── pip / setuptools (not needed at runtime) ─────────────
|
||||
rm -rf "$sp_dir"/pip "$sp_dir"/pip-* 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/setuptools "$sp_dir"/setuptools-* "$sp_dir"/pkg_resources 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/_distutils_hack 2>/dev/null || true
|
||||
|
||||
# ── OpenCV ───────────────────────────────────────────────
|
||||
local cv2_dir="$sp_dir/cv2"
|
||||
if [ -d "$cv2_dir" ]; then
|
||||
# Remove ffmpeg (28 MB on Windows), Haar cascades, dev files
|
||||
rm -f "$cv2_dir"/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
|
||||
rm -rf "$cv2_dir/data" "$cv2_dir/gapi" "$cv2_dir/misc" "$cv2_dir/utils" 2>/dev/null || true
|
||||
rm -rf "$cv2_dir/typing_stubs" "$cv2_dir/typing" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── NumPy ────────────────────────────────────────────────
|
||||
# Remove unused submodules (only core, fft, random are used)
|
||||
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
|
||||
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true
|
||||
|
||||
# ── Pillow (only used for system tray icon) ──────────────
|
||||
rm -rf "$sp_dir/PIL/tests" 2>/dev/null || true
|
||||
# Remove unused image format plugins (keep JPEG, PNG, ICO, BMP)
|
||||
for plugin in Eps Gif Tiff Webp Psd Pcx Xbm Xpm Dds Ftex Gbr Grib \
|
||||
Icns Im Imt Iptc McIrdas Mpo Msp Pcd Pixar Ppm Sgi \
|
||||
Spider Sun Tga Wal Wmf; do
|
||||
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.py" 2>/dev/null || true
|
||||
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.pyc" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── zeroconf ─────────────────────────────────────────────
|
||||
rm -rf "$sp_dir/zeroconf/_services" 2>/dev/null || true
|
||||
|
||||
# ── Strip debug symbols ──────────────────────────────────
|
||||
if command -v strip &>/dev/null; then
|
||||
echo " Stripping debug symbols from .$ext_suffix files..."
|
||||
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Remove .py source (keep .pyc bytecode) ───────────────
|
||||
echo " Removing .py source from site-packages (keeping .pyc)..."
|
||||
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
|
||||
|
||||
# ── Remove wled_controller if pip-installed ───────────────
|
||||
rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.dist-info 2>/dev/null || true
|
||||
|
||||
local cleaned_size
|
||||
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
|
||||
echo " Site-packages after cleanup: $cleaned_size"
|
||||
}
|
||||
@@ -22,21 +22,11 @@ PYTHON_DIR="$DIST_DIR/python"
|
||||
APP_DIR="$DIST_DIR/app"
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}"
|
||||
|
||||
source "$SCRIPT_DIR/build-common.sh"
|
||||
|
||||
# ── Version detection ────────────────────────────────────────
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' "$SERVER_DIR/src/wled_controller/__init__.py" 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
detect_version "${1:-}"
|
||||
ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip"
|
||||
|
||||
echo "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ==="
|
||||
@@ -46,11 +36,8 @@ echo ""
|
||||
|
||||
# ── Clean ────────────────────────────────────────────────────
|
||||
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
echo "[1/9] Cleaning previous build..."
|
||||
rm -rf "$DIST_DIR"
|
||||
fi
|
||||
mkdir -p "$DIST_DIR"
|
||||
echo "[1/9] Cleaning..."
|
||||
clean_dist
|
||||
|
||||
# ── Download Windows embedded Python ─────────────────────────
|
||||
|
||||
@@ -191,15 +178,11 @@ WHEEL_DIR="$BUILD_DIR/win-wheels"
|
||||
mkdir -p "$WHEEL_DIR"
|
||||
|
||||
# Core dependencies (cross-platform, should have win_amd64 wheels)
|
||||
# We parse pyproject.toml deps and download win_amd64 wheels.
|
||||
# For packages that are pure Python, --only-binary will fail,
|
||||
# so we fall back to allowing source for those.
|
||||
DEPS=(
|
||||
"fastapi>=0.115.0"
|
||||
"uvicorn[standard]>=0.32.0"
|
||||
"httpx>=0.27.2"
|
||||
"mss>=9.0.2"
|
||||
"Pillow>=10.4.0"
|
||||
"numpy>=2.1.3"
|
||||
"pydantic>=2.9.2"
|
||||
"pydantic-settings>=2.6.0"
|
||||
@@ -216,7 +199,6 @@ DEPS=(
|
||||
"sounddevice>=0.5"
|
||||
"aiomqtt>=2.0.0"
|
||||
"openrgb-python>=0.2.15"
|
||||
# camera extra
|
||||
"opencv-python-headless>=4.8.0"
|
||||
)
|
||||
|
||||
@@ -228,6 +210,9 @@ WIN_DEPS=(
|
||||
"winrt-Windows.Foundation>=3.0.0"
|
||||
"winrt-Windows.Foundation.Collections>=3.0.0"
|
||||
"winrt-Windows.ApplicationModel>=3.0.0"
|
||||
# System tray (Pillow needed by pystray for tray icon)
|
||||
"pystray>=0.19.0"
|
||||
"Pillow>=10.4.0"
|
||||
)
|
||||
|
||||
# Download cross-platform deps (prefer binary, allow source for pure Python)
|
||||
@@ -280,73 +265,26 @@ for sdist in "$WHEEL_DIR"/*.tar.gz; do
|
||||
done
|
||||
|
||||
# ── Reduce package size ────────────────────────────────────────
|
||||
echo " Cleaning up to reduce size..."
|
||||
|
||||
# Remove caches, tests, docs, type stubs
|
||||
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name test -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
|
||||
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
|
||||
|
||||
# Remove pip and setuptools (not needed at runtime)
|
||||
rm -rf "$SITE_PACKAGES"/pip "$SITE_PACKAGES"/pip-* 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES"/setuptools "$SITE_PACKAGES"/setuptools-* "$SITE_PACKAGES"/pkg_resources 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES"/_distutils_hack 2>/dev/null || true
|
||||
|
||||
# Remove pythonwin GUI IDE and help file (ships with pywin32 but not needed)
|
||||
# Windows-specific cleanup
|
||||
rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true
|
||||
rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true
|
||||
|
||||
# OpenCV: remove ffmpeg DLL (28MB, only for video file I/O, not camera),
|
||||
# Haar cascades (2.6MB), and misc dev files
|
||||
CV2_DIR="$SITE_PACKAGES/cv2"
|
||||
if [ -d "$CV2_DIR" ]; then
|
||||
rm -f "$CV2_DIR"/opencv_videoio_ffmpeg*.dll 2>/dev/null || true
|
||||
rm -rf "$CV2_DIR/data" "$CV2_DIR/gapi" "$CV2_DIR/misc" "$CV2_DIR/utils" 2>/dev/null || true
|
||||
rm -rf "$CV2_DIR/typing_stubs" "$CV2_DIR/typing" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# numpy: remove tests, f2py, typing stubs
|
||||
rm -rf "$SITE_PACKAGES/numpy/tests" "$SITE_PACKAGES/numpy/*/tests" 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES/numpy/f2py" 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES/numpy/typing" 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES/numpy/_pyinstaller" 2>/dev/null || true
|
||||
|
||||
# Pillow: remove unused image plugins' test data
|
||||
rm -rf "$SITE_PACKAGES/PIL/tests" 2>/dev/null || true
|
||||
|
||||
# winrt: remove type stubs
|
||||
find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true
|
||||
|
||||
# Remove wled_controller if it got installed
|
||||
rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true
|
||||
|
||||
CLEANED_SIZE=$(du -sh "$SITE_PACKAGES" | cut -f1)
|
||||
echo " Site-packages after cleanup: $CLEANED_SIZE"
|
||||
|
||||
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
|
||||
echo " Installed $WHEEL_COUNT packages"
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
echo "[7/9] Building frontend bundle..."
|
||||
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
|
||||
grep -v 'RemoteException' || true
|
||||
}
|
||||
echo "[7/9] Building frontend..."
|
||||
build_frontend
|
||||
|
||||
# ── Copy application files ───────────────────────────────────
|
||||
|
||||
echo "[8/9] Copying application files..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
|
||||
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
|
||||
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
|
||||
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
copy_app_files
|
||||
|
||||
# Pre-compile Python bytecode for faster startup
|
||||
echo " Pre-compiling Python bytecode..."
|
||||
@@ -359,7 +297,6 @@ echo "[8b/9] Creating launcher and packaging..."
|
||||
|
||||
cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER
|
||||
@echo off
|
||||
title LedGrab v${VERSION_CLEAN}
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Set paths
|
||||
@@ -370,15 +307,17 @@ set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||
|
||||
:: Start the server — reads port from config, prints its own banner
|
||||
"%~dp0python\python.exe" -m wled_controller.main
|
||||
|
||||
pause
|
||||
:: Start the server (tray icon handles UI and exit)
|
||||
"%~dp0python\pythonw.exe" -m wled_controller
|
||||
LAUNCHER
|
||||
|
||||
# Convert launcher to Windows line endings
|
||||
sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
|
||||
|
||||
# Copy hidden launcher VBS
|
||||
mkdir -p "$DIST_DIR/scripts"
|
||||
cp server/scripts/start-hidden.vbs "$DIST_DIR/scripts/"
|
||||
|
||||
# ── Create autostart scripts ─────────────────────────────────
|
||||
|
||||
cat > "$DIST_DIR/install-autostart.bat" << 'AUTOSTART'
|
||||
|
||||
@@ -130,7 +130,7 @@ if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
|
||||
# ── Install dependencies ──────────────────────────────────────
|
||||
|
||||
Write-Host "[5/8] Installing dependencies..."
|
||||
$extras = "camera,notifications"
|
||||
$extras = "camera,notifications,tray"
|
||||
if (-not $SkipPerf) { $extras += ",perf" }
|
||||
|
||||
# Install the project (pulls all deps via pyproject.toml), then remove
|
||||
@@ -202,7 +202,6 @@ Write-Host "[8/8] Creating launcher..."
|
||||
|
||||
$launcherContent = @'
|
||||
@echo off
|
||||
title LedGrab v%VERSION%
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Set paths
|
||||
@@ -213,24 +212,19 @@ set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
|
||||
if not exist "%~dp0data" mkdir "%~dp0data"
|
||||
if not exist "%~dp0logs" mkdir "%~dp0logs"
|
||||
|
||||
echo.
|
||||
echo =============================================
|
||||
echo LedGrab v%VERSION%
|
||||
echo Open http://localhost:8080 in your browser
|
||||
echo =============================================
|
||||
echo.
|
||||
|
||||
:: Start the server (open browser after short delay)
|
||||
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
|
||||
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
|
||||
pause
|
||||
:: Start the server (tray icon handles UI and exit)
|
||||
"%~dp0python\pythonw.exe" -m wled_controller
|
||||
'@
|
||||
|
||||
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
|
||||
$launcherPath = Join-Path $DistDir "LedGrab.bat"
|
||||
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
|
||||
|
||||
# Copy hidden launcher VBS
|
||||
$scriptsDir = Join-Path $DistDir "scripts"
|
||||
New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $ServerDir "scripts\start-hidden.vbs") -Destination $scriptsDir
|
||||
|
||||
# ── Create ZIP ─────────────────────────────────────────────────
|
||||
|
||||
$ZipPath = Join-Path $BuildDir $ZipName
|
||||
|
||||
@@ -17,21 +17,11 @@ SERVER_DIR="$SCRIPT_DIR/server"
|
||||
VENV_DIR="$DIST_DIR/venv"
|
||||
APP_DIR="$DIST_DIR/app"
|
||||
|
||||
source "$SCRIPT_DIR/build-common.sh"
|
||||
|
||||
# ── Version detection ────────────────────────────────────────
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' "$SERVER_DIR/src/wled_controller/__init__.py" 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
detect_version "${1:-}"
|
||||
TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz"
|
||||
|
||||
echo "=== Building LedGrab v${VERSION_CLEAN} (Linux) ==="
|
||||
@@ -40,11 +30,8 @@ echo ""
|
||||
|
||||
# ── Clean ────────────────────────────────────────────────────
|
||||
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
echo "[1/7] Cleaning previous build..."
|
||||
rm -rf "$DIST_DIR"
|
||||
fi
|
||||
mkdir -p "$DIST_DIR"
|
||||
echo "[1/7] Cleaning..."
|
||||
clean_dist
|
||||
|
||||
# ── Create virtualenv ────────────────────────────────────────
|
||||
|
||||
@@ -56,38 +43,25 @@ pip install --upgrade pip --quiet
|
||||
# ── Install dependencies ─────────────────────────────────────
|
||||
|
||||
echo "[3/7] Installing dependencies..."
|
||||
pip install --quiet "${SERVER_DIR}[camera,notifications]" 2>&1 | {
|
||||
pip install --quiet "${SERVER_DIR}[notifications]" 2>&1 | {
|
||||
grep -i 'error\|failed' || true
|
||||
}
|
||||
|
||||
# Remove the installed wled_controller package (PYTHONPATH handles app code)
|
||||
SITE_PACKAGES="$VENV_DIR/lib/python*/site-packages"
|
||||
rm -rf $SITE_PACKAGES/wled_controller* $SITE_PACKAGES/wled*.dist-info 2>/dev/null || true
|
||||
# Resolve site-packages path (glob expand)
|
||||
SITE_PACKAGES=$(echo "$VENV_DIR"/lib/python*/site-packages)
|
||||
|
||||
# Clean up caches
|
||||
find "$VENV_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$VENV_DIR" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$VENV_DIR" -type d -name test -exec rm -rf {} + 2>/dev/null || true
|
||||
# Clean up with shared function
|
||||
cleanup_site_packages "$SITE_PACKAGES" "so" "so"
|
||||
|
||||
# ── Build frontend ───────────────────────────────────────────
|
||||
|
||||
echo "[4/7] Building frontend bundle..."
|
||||
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
|
||||
grep -v 'RemoteException' || true
|
||||
}
|
||||
echo "[4/7] Building frontend..."
|
||||
build_frontend
|
||||
|
||||
# ── Copy application files ───────────────────────────────────
|
||||
|
||||
echo "[5/7] Copying application files..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
cp -r "$SERVER_DIR/src" "$APP_DIR/src"
|
||||
cp -r "$SERVER_DIR/config" "$APP_DIR/config"
|
||||
mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs"
|
||||
|
||||
# Clean up source maps and __pycache__
|
||||
find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true
|
||||
find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
copy_app_files
|
||||
|
||||
# ── Create launcher ──────────────────────────────────────────
|
||||
|
||||
|
||||
143
contexts/auto-update-plan.md
Normal file
143
contexts/auto-update-plan.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Auto-Update Plan — Phase 1: Check & Notify
|
||||
|
||||
> Created: 2026-03-25. Status: **planned, not started.**
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Release Provider Abstraction
|
||||
|
||||
```
|
||||
core/update/
|
||||
release_provider.py — ABC: get_releases(), get_releases_page_url()
|
||||
gitea_provider.py — Gitea REST API implementation
|
||||
version_check.py — normalize_version(), is_newer() using packaging.version
|
||||
update_service.py — Background asyncio task + state machine
|
||||
```
|
||||
|
||||
**`ReleaseProvider` interface** — two methods:
|
||||
- `get_releases(limit) → list[ReleaseInfo]` — fetch releases (newest first)
|
||||
- `get_releases_page_url() → str` — link for "view on web"
|
||||
|
||||
**`GiteaReleaseProvider`** calls `GET {base_url}/api/v1/repos/{repo}/releases`. Swapping to GitHub later means implementing the same interface against `api.github.com`.
|
||||
|
||||
**Data models:**
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AssetInfo:
|
||||
name: str # "LedGrab-v0.3.0-win-x64.zip"
|
||||
size: int # bytes
|
||||
download_url: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
tag: str # "v0.3.0"
|
||||
version: str # "0.3.0"
|
||||
name: str # "LedGrab v0.3.0"
|
||||
body: str # release notes markdown
|
||||
prerelease: bool
|
||||
published_at: str # ISO 8601
|
||||
assets: tuple[AssetInfo, ...]
|
||||
```
|
||||
|
||||
### Version Comparison
|
||||
|
||||
`version_check.py` — normalize Gitea tags to PEP 440:
|
||||
- `v0.3.0-alpha.1` → `0.3.0a1`
|
||||
- `v0.3.0-beta.2` → `0.3.0b2`
|
||||
- `v0.3.0-rc.3` → `0.3.0rc3`
|
||||
|
||||
Uses `packaging.version.Version` for comparison.
|
||||
|
||||
### Update Service
|
||||
|
||||
Follows the **AutoBackupEngine pattern**:
|
||||
- Settings in `Database.get_setting("auto_update")`
|
||||
- asyncio.Task for periodic checks
|
||||
- 30s startup delay (avoid slowing boot)
|
||||
- 60s debounce on manual checks
|
||||
|
||||
**State machine (Phase 1):** `IDLE → CHECKING → UPDATE_AVAILABLE`
|
||||
|
||||
No download/apply in Phase 1 — just detection and notification.
|
||||
|
||||
**Settings:** `enabled` (bool), `check_interval_hours` (float), `channel` ("stable" | "pre-release")
|
||||
|
||||
**Persisted state:** `dismissed_version`, `last_check` (survives restarts)
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/v1/system/update/status` | Current state + available version |
|
||||
| `POST` | `/api/v1/system/update/check` | Trigger immediate check |
|
||||
| `POST` | `/api/v1/system/update/dismiss` | Dismiss notification for current version |
|
||||
| `GET` | `/api/v1/system/update/settings` | Get settings |
|
||||
| `PUT` | `/api/v1/system/update/settings` | Update settings |
|
||||
|
||||
### Wiring
|
||||
|
||||
- New `get_update_service()` in `dependencies.py`
|
||||
- `UpdateService` created in `main.py` lifespan, `start()`/`stop()` alongside other engines
|
||||
- Router registered in `api/__init__.py`
|
||||
- WebSocket event: `update_available` fired via `processor_manager.fire_event()`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Version badge highlight
|
||||
|
||||
The existing `#server-version` pill in the header gets a CSS class `has-update` when a newer version exists — changes the background to `var(--warning-color)` with a subtle pulse, making it clickable to open the update panel in settings.
|
||||
|
||||
### Notification popup
|
||||
|
||||
On `server:update_available` WebSocket event (and on page load if status says `has_update` and not dismissed):
|
||||
- A **persistent dismissible banner** slides in below the header (not the ephemeral 3s toast)
|
||||
- Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss]
|
||||
- Dismiss calls `POST /dismiss` and hides the bar for that version
|
||||
- Stored in `localStorage` so it doesn't re-show after page refresh for dismissed versions
|
||||
|
||||
### Settings tab: "Updates"
|
||||
|
||||
New 5th tab in the settings modal:
|
||||
- Current version display
|
||||
- "Check for updates" button + spinner
|
||||
- Channel selector (stable / pre-release) via IconSelect
|
||||
- Auto-check toggle + interval selector
|
||||
- When update available: release name, notes preview, link to releases page
|
||||
|
||||
### i18n keys
|
||||
|
||||
New `update.*` keys in `en.json`, `ru.json`, `zh.json` for all labels.
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `core/update/__init__.py` | Package init |
|
||||
| `core/update/release_provider.py` | Abstract provider interface + data models |
|
||||
| `core/update/gitea_provider.py` | Gitea API implementation |
|
||||
| `core/update/version_check.py` | Semver normalization + comparison |
|
||||
| `core/update/update_service.py` | Background service + state machine |
|
||||
| `api/routes/update.py` | REST endpoints |
|
||||
| `api/schemas/update.py` | Pydantic request/response models |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `api/__init__.py` | Register update router |
|
||||
| `api/dependencies.py` | Add `get_update_service()` |
|
||||
| `main.py` | Create & start/stop UpdateService in lifespan |
|
||||
| `templates/modals/settings.html` | Add Updates tab |
|
||||
| `static/js/features/settings.ts` | Update check/settings UI logic |
|
||||
| `static/js/core/api.ts` | Version badge highlight on health check |
|
||||
| `static/css/layout.css` | `.has-update` styles for version badge |
|
||||
| `static/locales/en.json` | i18n keys |
|
||||
| `static/locales/ru.json` | i18n keys |
|
||||
| `static/locales/zh.json` | i18n keys |
|
||||
|
||||
## Future Phases (not in scope)
|
||||
|
||||
- **Phase 2**: Download & stage artifacts
|
||||
- **Phase 3**: Apply update & restart (external updater script, NSIS silent mode)
|
||||
- **Phase 4**: Checksums, "What's new" dialog, update history
|
||||
@@ -1,5 +1,7 @@
|
||||
# CI/CD & Release Workflow
|
||||
|
||||
> **Reference guide:** [Gitea Python CI/CD Guide](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) — reusable patterns for Gitea Actions, cross-build, NSIS, Docker. When modifying workflows or build scripts, consult this guide to stay in sync with established patterns.
|
||||
|
||||
## Workflows
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
@@ -59,6 +61,33 @@ Creates the Gitea release with a description table listing all artifacts. **The
|
||||
- Display-dependent tests are skipped via `@requires_display` marker
|
||||
- Uses `python` not `python3` (Git Bash on Windows resolves `python3` to MS Store stub)
|
||||
|
||||
## Version Detection Pattern
|
||||
|
||||
Build scripts use a fallback chain: CLI argument → exact git tag → CI env var (`GITEA_REF_NAME` / `GITHUB_REF_NAME`) → hardcoded in source. Always strip leading `v` for clean version strings.
|
||||
|
||||
## NSIS Installer Best Practices
|
||||
|
||||
- **User-scoped install** (`$LOCALAPPDATA`, `RequestExecutionLevel user`) — no admin required
|
||||
- **Launch after install**: Use `MUI_FINISHPAGE_RUN_FUNCTION` (not `MUI_FINISHPAGE_RUN_PARAMETERS` — NSIS `Exec` chokes on quoting). Still requires `MUI_FINISHPAGE_RUN ""` defined for checkbox visibility
|
||||
- **Detect running instance**: `.onInit` checks file lock on `python.exe`, offers to kill process before install
|
||||
- **Uninstall preserves user data**: Remove `python/`, `app/`, `logs/` but NOT `data/`
|
||||
- **CI build**: `sudo apt-get install -y nsis msitools zip` then `makensis -DVERSION="${VERSION}" installer.nsi`
|
||||
|
||||
## Hidden Launcher (VBS)
|
||||
|
||||
All shortcuts and the installer finish page use `scripts/start-hidden.vbs` instead of `.bat` to avoid console window flash. The VBS launcher must include an embedded Python fallback — installed distributions don't have Python on PATH, dev environment uses system Python.
|
||||
|
||||
## Gitea vs GitHub Actions Differences
|
||||
|
||||
| Feature | GitHub Actions | Gitea Actions |
|
||||
| ------- | -------------- | ------------- |
|
||||
| Context prefix | `github.*` | `gitea.*` |
|
||||
| Ref name | `${{ github.ref_name }}` | `${{ gitea.ref_name }}` |
|
||||
| Server URL | `${{ github.server_url }}` | `${{ gitea.server_url }}` |
|
||||
| Output vars | `$GITHUB_OUTPUT` | `$GITHUB_OUTPUT` (same) |
|
||||
| Secrets | `${{ secrets.NAME }}` | `${{ secrets.NAME }}` (same) |
|
||||
| Docker Buildx | Available | May not work (runner networking) |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Creating a release
|
||||
@@ -78,3 +107,48 @@ git push origin v0.2.0-alpha.1
|
||||
2. Add upload step in the relevant `build-*` job
|
||||
3. **Update the release description** in `create-release` job body template
|
||||
4. Test with a pre-release tag first
|
||||
|
||||
### Re-triggering a failed release workflow
|
||||
|
||||
```bash
|
||||
# Option A: Delete and re-push the same tag
|
||||
git push origin :refs/tags/v0.1.0-alpha.2
|
||||
# Delete the release in Gitea UI or via API
|
||||
git tag -f v0.1.0-alpha.2
|
||||
git push origin v0.1.0-alpha.2
|
||||
|
||||
# Option B: Just bump the version (simpler)
|
||||
git tag v0.1.0-alpha.3
|
||||
git push origin v0.1.0-alpha.3
|
||||
```
|
||||
|
||||
The `create-release` job has fallback logic — if the release already exists for a tag, it fetches and reuses the existing release ID.
|
||||
|
||||
## Local Build Testing (Windows)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- NSIS: `& "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe" install NSIS.NSIS`
|
||||
- Installs to `C:\Program Files (x86)\NSIS\makensis.exe`
|
||||
|
||||
### Build steps
|
||||
|
||||
```bash
|
||||
npm ci && npm run build # frontend
|
||||
bash build-dist-windows.sh v1.0.0 # Windows dist
|
||||
"/c/Program Files (x86)/NSIS/makensis.exe" -DVERSION="1.0.0" installer.nsi # installer
|
||||
```
|
||||
|
||||
### Iterating on installer only
|
||||
If only `installer.nsi` changed (not app code), skip the full rebuild — just re-run `makensis`. If app code changed, re-run `build-dist-windows.sh` first since `dist/` is a snapshot.
|
||||
|
||||
### Common issues
|
||||
|
||||
| Issue | Fix |
|
||||
| ----- | --- |
|
||||
| `zip: command not found` | Git Bash doesn't include `zip` — harmless for installer builds |
|
||||
| `Exec expects 1 parameters, got 2` | Use `MUI_FINISHPAGE_RUN_FUNCTION` instead of `MUI_FINISHPAGE_RUN_PARAMETERS` |
|
||||
| `Error opening file for writing: python\_asyncio.pyd` | Server is running — stop it before installing |
|
||||
| App doesn't start after install | VBS must use embedded Python fallback, not bare `python` |
|
||||
| `winget` not recognized | Use full path: `$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe` |
|
||||
| `dist/` has stale files | Re-run full build script — `dist/` doesn't auto-update |
|
||||
|
||||
@@ -32,6 +32,16 @@ Defined in `server/src/wled_controller/static/css/base.css`.
|
||||
| `--success-color` | `#28a745` | `#2e7d32` | Success indicators |
|
||||
| `--shadow-color` | `rgba(0,0,0,0.3)` | `rgba(0,0,0,0.12)` | Box shadows |
|
||||
|
||||
## Icons — No Emoji (IMPORTANT)
|
||||
|
||||
**NEVER use emoji characters (`🔗`, `📋`, `🔍`, etc.) in buttons, labels, or card metadata.** Always use SVG icons from `core/icons.ts` (which wraps Lucide icon paths from `core/icon-paths.ts`).
|
||||
|
||||
- Import the constant: `import { ICON_CLONE } from '../core/icons.ts'`
|
||||
- Use in template literals: `` `<button class="btn btn-icon">${ICON_CLONE}</button>` ``
|
||||
- To add a new icon: copy inner SVG elements from [Lucide](https://lucide.dev) into `icon-paths.ts`, then export a named constant in `icons.ts`
|
||||
|
||||
Emoji render inconsistently across OS, break monochrome icon themes, and cannot be styled with CSS `color`/`stroke`.
|
||||
|
||||
## UI Conventions for Dialogs
|
||||
|
||||
### Hints
|
||||
@@ -70,13 +80,17 @@ For `EntitySelect` with `allowNone: true`, pass the same i18n string as `noneLab
|
||||
|
||||
### Enhanced selectors (IconSelect & EntitySelect)
|
||||
|
||||
**IMPORTANT:** Always use icon grid or entity pickers instead of plain `<select>` dropdowns wherever appropriate. Plain HTML selects break the visual consistency of the UI. Any selector with a small fixed set of options (types, modes, presets, bands) should use `IconSelect`; any selector referencing dynamic entities should use `EntitySelect`.
|
||||
|
||||
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
|
||||
|
||||
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.ts`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.ts` for examples.
|
||||
|
||||
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.ts`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.ts` or `_lineSourceEntitySelect` in `advanced-calibration.ts` for examples.
|
||||
|
||||
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
||||
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. **The `<select>` and the visual widget are two separate things — changing one does NOT automatically update the other.** After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
||||
|
||||
**Common pitfall:** Using a preset/palette selector (e.g. gradient preset dropdown or effect type picker) that changes the underlying `<select>` value but forgets to call `.setValue()` on the IconSelect — the visual grid still shows the old selection.
|
||||
|
||||
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.ts` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
|
||||
|
||||
@@ -190,6 +204,17 @@ document.addEventListener('languageChanged', () => {
|
||||
|
||||
Static HTML using `data-i18n` attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
|
||||
|
||||
## API Calls (CRITICAL)
|
||||
|
||||
**ALWAYS use `fetchWithAuth()` from `core/api.ts` for authenticated API requests.** It auto-prepends `API_BASE` (`/api/v1`) and attaches the auth token.
|
||||
|
||||
- **Paths are relative to `/api/v1`** — pass `/gradients`, NOT `/api/v1/gradients`
|
||||
- `fetchWithAuth('/gradients')` → `GET /api/v1/gradients` with auth header
|
||||
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123`
|
||||
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)
|
||||
|
||||
For raw `fetch()` without auth (rare), use the full path manually.
|
||||
|
||||
## Bundling & Development Workflow
|
||||
|
||||
The frontend uses **esbuild** to bundle all JS modules and CSS files into single files for production.
|
||||
@@ -262,6 +287,315 @@ Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(-
|
||||
|
||||
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.ts`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
|
||||
|
||||
## Adding a New Entity Type (Full Checklist)
|
||||
|
||||
This section documents the complete pattern for adding a new entity type to the frontend, covering tabs, cards, modals, CRUD operations, and all required wiring.
|
||||
|
||||
### 1. DataCache (state.ts)
|
||||
|
||||
Register a cache for the new entity's API endpoint in `static/js/core/state.ts`:
|
||||
|
||||
```typescript
|
||||
export const myEntitiesCache = new DataCache<MyEntity[]>({
|
||||
endpoint: '/my-entities',
|
||||
extractData: json => json.entities || [],
|
||||
});
|
||||
```
|
||||
|
||||
- `endpoint` is relative to `/api/v1` (fetchWithAuth prepends it)
|
||||
- `extractData` unwraps the response envelope
|
||||
- Subscribe to sync into a legacy variable if needed: `myEntitiesCache.subscribe(v => { _cachedMyEntities = v; });`
|
||||
|
||||
### 2. CardSection instance
|
||||
|
||||
Create a global CardSection in the feature module (e.g. `features/my-entities.ts`):
|
||||
|
||||
```typescript
|
||||
const csMyEntities = new CardSection('my-entities', {
|
||||
titleKey: 'my_entity.section_title', // i18n key for section header
|
||||
gridClass: 'templates-grid', // CSS grid class
|
||||
addCardOnclick: "showMyEntityEditor()", // onclick for the "+" card
|
||||
keyAttr: 'data-my-id', // attribute used to match cards during reconcile
|
||||
emptyKey: 'section.empty.my_entities', // i18n key shown when no cards
|
||||
bulkActions: _myEntityBulkActions, // optional bulk action definitions
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Tab registration (streams.ts)
|
||||
|
||||
Add the tab entry in the `tabs` array inside `loadSourcesTab()`:
|
||||
|
||||
```typescript
|
||||
{ key: 'my_entities', icon: ICON_MY_ENTITY, titleKey: 'streams.group.my_entities', count: myEntities.length },
|
||||
```
|
||||
|
||||
Then add the rendering block for the tab content:
|
||||
|
||||
```typescript
|
||||
// First load: full render
|
||||
if (!csMyEntities.isMounted()) {
|
||||
const items = myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) }));
|
||||
html += csMyEntities.render(csMyEntities.applySortOrder(items));
|
||||
} else {
|
||||
// Incremental update
|
||||
csMyEntities.reconcile(myEntities.map(e => ({ key: e.id, html: createMyEntityCard(e) })));
|
||||
}
|
||||
```
|
||||
|
||||
After `innerHTML` assignment, call `csMyEntities.bind()` for first mount.
|
||||
|
||||
### 4. Card builder function
|
||||
|
||||
Build cards using `wrapCard()` from `core/card-colors.ts`:
|
||||
|
||||
```typescript
|
||||
function createMyEntityCard(entity: MyEntity): string {
|
||||
return wrapCard({
|
||||
dataAttr: 'data-my-id',
|
||||
id: entity.id,
|
||||
removeOnclick: `deleteMyEntity('${entity.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="card-header">
|
||||
<div class="card-title" title="${escapeHtml(entity.name)}">
|
||||
${ICON_MY_ENTITY} <span class="card-title-text">${escapeHtml(entity.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">🏷️ ${escapeHtml(entity.description || '')}</span>
|
||||
</div>
|
||||
${renderTagChips(entity.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneMyEntity('${entity.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showMyEntityEditor('${entity.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Required HTML classes:**
|
||||
- `.template-card` — root (auto-added by wrapCard)
|
||||
- `.card-header` > `.card-title` > `.card-title-text` — title with icon
|
||||
- `.stream-card-props` > `.stream-card-prop` — property badges
|
||||
- `.template-card-actions` — button row (auto-added by wrapCard)
|
||||
- `.card-remove-btn` — delete X button (auto-added by wrapCard)
|
||||
|
||||
### 5. Modal HTML template
|
||||
|
||||
Create `templates/modals/my-entity-editor.html`:
|
||||
|
||||
```html
|
||||
<div id="my-entity-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="my-entity-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="my-entity-title" data-i18n="my_entity.add">Add Entity</h2>
|
||||
<button class="modal-close-btn" onclick="closeMyEntityModal()" data-i18n-aria-label="aria.close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="my-entity-id">
|
||||
<div id="my-entity-error" class="modal-error" style="display:none"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="my-entity-name" data-i18n="my_entity.name">Name:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="my_entity.name.hint">...</small>
|
||||
<input type="text" id="my-entity-name" required>
|
||||
</div>
|
||||
|
||||
<!-- Type-specific fields here -->
|
||||
|
||||
<div id="my-entity-tags-container"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeMyEntityModal()" title="Cancel" data-i18n-title="settings.button.cancel">×</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Include it in `templates/index.html`: `{% include 'modals/my-entity-editor.html' %}`
|
||||
|
||||
### 6. Modal class (dirty checking)
|
||||
|
||||
```typescript
|
||||
class MyEntityModal extends Modal {
|
||||
constructor() { super('my-entity-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: (document.getElementById('my-entity-name') as HTMLInputElement).value,
|
||||
// ... all tracked fields, serialize complex state as JSON strings
|
||||
tags: JSON.stringify(_tagsInput ? _tagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
// Cleanup: destroy tag inputs, entity selects, etc.
|
||||
if (_tagsInput) { _tagsInput.destroy(); _tagsInput = null; }
|
||||
}
|
||||
}
|
||||
const myEntityModal = new MyEntityModal();
|
||||
```
|
||||
|
||||
### 7. CRUD functions
|
||||
|
||||
**Create / Edit (unified):**
|
||||
|
||||
```typescript
|
||||
export async function showMyEntityEditor(editId: string | null = null) {
|
||||
const titleEl = document.getElementById('my-entity-title')!;
|
||||
const idInput = document.getElementById('my-entity-id') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('my-entity-name') as HTMLInputElement;
|
||||
|
||||
idInput.value = '';
|
||||
nameInput.value = '';
|
||||
|
||||
if (editId) {
|
||||
// Edit mode: populate from cache
|
||||
const entities = await myEntitiesCache.fetch();
|
||||
const entity = entities.find(e => e.id === editId);
|
||||
if (!entity) return;
|
||||
idInput.value = entity.id;
|
||||
nameInput.value = entity.name;
|
||||
titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.edit')}`;
|
||||
} else {
|
||||
titleEl.innerHTML = `${ICON_MY_ENTITY} ${t('my_entity.add')}`;
|
||||
}
|
||||
|
||||
myEntityModal.open();
|
||||
myEntityModal.snapshot();
|
||||
}
|
||||
```
|
||||
|
||||
**Clone:** Fetch existing entity, open editor with its data but no ID (creates new):
|
||||
|
||||
```typescript
|
||||
export async function cloneMyEntity(entityId: string) {
|
||||
const entities = await myEntitiesCache.fetch();
|
||||
const source = entities.find(e => e.id === entityId);
|
||||
if (!source) return;
|
||||
|
||||
// Open editor as "create" with pre-filled data
|
||||
await showMyEntityEditor(null);
|
||||
(document.getElementById('my-entity-name') as HTMLInputElement).value = source.name + ' (Copy)';
|
||||
// ... populate other fields from source
|
||||
myEntityModal.snapshot(); // Re-snapshot after populating clone data
|
||||
}
|
||||
```
|
||||
|
||||
**Save:** POST (new) or PUT (edit) based on hidden ID field:
|
||||
|
||||
```typescript
|
||||
export async function saveMyEntity() {
|
||||
const id = (document.getElementById('my-entity-id') as HTMLInputElement).value;
|
||||
const name = (document.getElementById('my-entity-name') as HTMLInputElement).value.trim();
|
||||
if (!name) { myEntityModal.showError(t('my_entity.error.name_required')); return; }
|
||||
|
||||
const payload = { name, /* ... other fields */ };
|
||||
|
||||
try {
|
||||
const url = id ? `/my-entities/${id}` : '/my-entities';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||
if (!res!.ok) { const err = await res!.json(); throw new Error(err.detail); }
|
||||
|
||||
showToast(id ? t('my_entity.updated') : t('my_entity.created'), 'success');
|
||||
myEntitiesCache.invalidate();
|
||||
myEntityModal.forceClose();
|
||||
if (window.loadSourcesTab) window.loadSourcesTab();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
myEntityModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Delete:** Confirm, API call, invalidate cache, reload:
|
||||
|
||||
```typescript
|
||||
export async function deleteMyEntity(entityId: string) {
|
||||
const ok = await showConfirm(t('my_entity.confirm_delete'));
|
||||
if (!ok) return;
|
||||
try {
|
||||
await fetchWithAuth(`/my-entities/${entityId}`, { method: 'DELETE' });
|
||||
showToast(t('my_entity.deleted'), 'success');
|
||||
myEntitiesCache.invalidate();
|
||||
if (window.loadSourcesTab) window.loadSourcesTab();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message || t('my_entity.error.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Window exports (app.ts)
|
||||
|
||||
Import and expose all onclick handlers:
|
||||
|
||||
```typescript
|
||||
import { showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity } from './features/my-entities.ts';
|
||||
|
||||
Object.assign(window, {
|
||||
showMyEntityEditor, saveMyEntity, closeMyEntityModal, cloneMyEntity, deleteMyEntity,
|
||||
});
|
||||
```
|
||||
|
||||
**Critical:** Functions used in `onclick="..."` HTML attributes MUST appear in `Object.assign(window, ...)` or they will be undefined at runtime.
|
||||
|
||||
### 9. global.d.ts
|
||||
|
||||
Add the window function declarations so TypeScript doesn't complain:
|
||||
|
||||
```typescript
|
||||
showMyEntityEditor?: (id?: string | null) => void;
|
||||
cloneMyEntity?: (id: string) => void;
|
||||
deleteMyEntity?: (id: string) => void;
|
||||
```
|
||||
|
||||
### 10. i18n keys
|
||||
|
||||
Add keys to all three locale files (`en.json`, `ru.json`, `zh.json`):
|
||||
|
||||
```json
|
||||
"my_entity.section_title": "My Entities",
|
||||
"my_entity.add": "Add Entity",
|
||||
"my_entity.edit": "Edit Entity",
|
||||
"my_entity.created": "Entity created",
|
||||
"my_entity.updated": "Entity updated",
|
||||
"my_entity.deleted": "Entity deleted",
|
||||
"my_entity.confirm_delete": "Delete this entity?",
|
||||
"my_entity.error.name_required": "Name is required",
|
||||
"my_entity.name": "Name:",
|
||||
"my_entity.name.hint": "A descriptive name for this entity",
|
||||
"section.empty.my_entities": "No entities yet. Click + to create one."
|
||||
```
|
||||
|
||||
### 11. Cross-references
|
||||
|
||||
After adding the entity:
|
||||
|
||||
- **Backup/restore:** Add to `STORE_MAP` in `api/routes/system.py`
|
||||
- **Graph editor:** Update entity maps in graph editor files (see graph-editor.md)
|
||||
- **Tutorials:** Update tutorial steps if adding a new tab
|
||||
|
||||
### CRITICAL: Common Pitfalls (MUST READ)
|
||||
|
||||
These mistakes have been made repeatedly. **Check every one before considering the entity complete:**
|
||||
|
||||
1. **DOM ID conflicts:** If your modal reuses a shared component (e.g. gradient stop editor, color picker) that uses hardcoded `document.getElementById()` calls, **both modals exist in the DOM simultaneously**. The shared component will render into whichever element it finds first (the wrong one). Fix: add an ID prefix mechanism to the shared component, set it before init, reset it on modal close.
|
||||
|
||||
2. **Tags go under the name input:** The tags container `<div>` goes **inside the same `form-group` as the name `<input>`**, directly after it — NOT in a separate section or at the bottom of the modal. Look at any existing modal (css-editor.html, audio-source-editor.html, device-settings.html) for the pattern.
|
||||
|
||||
3. **Cache reload after save/delete/clone:** Use `cache.invalidate()` then `await loadPictureSources()` (imported directly from streams.ts). Do NOT use `window.loadSourcesTab()` without `await` — it reads the stale cache before the invalidation takes effect, so the new entity won't appear until page reload.
|
||||
|
||||
4. **IconSelect / EntitySelect sync:** When programmatically changing a `<select>` value (e.g. loading a preset, populating for edit), you MUST also call `.setValue(val)` (IconSelect) or `.refresh()` (EntitySelect). The native `<select>` and the visual widget are **separate** — changing one does NOT update the other.
|
||||
|
||||
5. **Never use `window.prompt()`:** Always use a proper Modal subclass with `snapshotValues()` for dirty checking. Prompts break the UX and have no validation, no hint text, no i18n.
|
||||
|
||||
6. **Never do API-only clone:** Clone should open the editor modal pre-filled with the source entity's data (name + " (Copy)"), with an empty ID field so saving creates a new entity. Do NOT call a `/clone` endpoint and refresh — the user must be able to edit before saving.
|
||||
|
||||
## Visual Graph Editor
|
||||
|
||||
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.
|
||||
|
||||
@@ -8,7 +8,7 @@ Two independent server modes with separate configs, ports, and data directories:
|
||||
|
||||
| Mode | Command | Config | Port | API Key | Data |
|
||||
| ---- | ------- | ------ | ---- | ------- | ---- |
|
||||
| **Real** | `python -m wled_controller.main` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
|
||||
| **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/` |
|
||||
|
||||
Both can run simultaneously on different ports.
|
||||
|
||||
@@ -21,7 +21,7 @@ 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.Required(CONF_API_KEY): str,
|
||||
vol.Optional(CONF_API_KEY, default=""): str,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -57,21 +57,25 @@ async def validate_server(
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConnectionError(f"Cannot connect to server: {err}") from err
|
||||
|
||||
# Step 2: Validate API key via authenticated endpoint
|
||||
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
|
||||
# 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}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
self.session = session
|
||||
self.api_key = api_key
|
||||
self.server_version = "unknown"
|
||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
|
||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
; ── Metadata ────────────────────────────────────────────────
|
||||
|
||||
!define APPNAME "LedGrab"
|
||||
!define VBSNAME "start-hidden.vbs"
|
||||
!define DESCRIPTION "Ambient lighting system — captures screen content and drives LED strips in real time"
|
||||
!define VERSIONMAJOR 0
|
||||
!define VERSIONMINOR 1
|
||||
@@ -33,6 +34,12 @@ SetCompressor /SOLID lzma
|
||||
|
||||
; ── Pages ───────────────────────────────────────────────────
|
||||
|
||||
; Use MUI_FINISHPAGE_RUN_FUNCTION instead of MUI_FINISHPAGE_RUN_PARAMETERS —
|
||||
; NSIS Exec command chokes on the quoting with RUN_PARAMETERS.
|
||||
!define MUI_FINISHPAGE_RUN ""
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
|
||||
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_COMPONENTS
|
||||
@@ -44,6 +51,33 @@ SetCompressor /SOLID lzma
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
; ── Functions ─────────────────────────────────────────────
|
||||
|
||||
Function LaunchApp
|
||||
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
|
||||
Sleep 2000
|
||||
ExecShell "open" "http://localhost:8080/"
|
||||
FunctionEnd
|
||||
|
||||
; Detect running instance before install (file lock check on python.exe)
|
||||
Function .onInit
|
||||
IfFileExists "$INSTDIR\python\python.exe" 0 done
|
||||
ClearErrors
|
||||
FileOpen $0 "$INSTDIR\python\python.exe" a
|
||||
IfErrors locked
|
||||
FileClose $0
|
||||
Goto done
|
||||
locked:
|
||||
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
|
||||
"${APPNAME} is currently running.$\n$\nYes = Stop and continue$\nNo = Continue anyway (may cause errors)$\nCancel = Abort" \
|
||||
IDYES kill IDNO done
|
||||
Abort
|
||||
kill:
|
||||
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%${APPNAME}%python%$\'" call terminate'
|
||||
Sleep 2000
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
; ── Installer Sections ──────────────────────────────────────
|
||||
|
||||
Section "!${APPNAME} (required)" SecCore
|
||||
@@ -54,6 +88,7 @@ Section "!${APPNAME} (required)" SecCore
|
||||
; Copy the entire portable build
|
||||
File /r "build\LedGrab\python"
|
||||
File /r "build\LedGrab\app"
|
||||
File /r "build\LedGrab\scripts"
|
||||
File "build\LedGrab\LedGrab.bat"
|
||||
|
||||
; Create data and logs directories
|
||||
@@ -65,8 +100,9 @@ Section "!${APPNAME} (required)" SecCore
|
||||
|
||||
; Start Menu shortcuts
|
||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
|
||||
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\pythonw.exe" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
|
||||
|
||||
; Registry: install location + Add/Remove Programs entry
|
||||
@@ -98,13 +134,15 @@ Section "!${APPNAME} (required)" SecCore
|
||||
SectionEnd
|
||||
|
||||
Section "Desktop shortcut" SecDesktop
|
||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
|
||||
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
|
||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\pythonw.exe" 0
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" "$INSTDIR\LedGrab.bat" \
|
||||
"" "" "" SW_SHOWMINIMIZED "" "${DESCRIPTION}"
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\pythonw.exe" 0
|
||||
SectionEnd
|
||||
|
||||
; ── Section Descriptions ────────────────────────────────────
|
||||
@@ -131,6 +169,7 @@ Section "Uninstall"
|
||||
; Remove application files (but NOT data/ — preserve user config)
|
||||
RMDir /r "$INSTDIR\python"
|
||||
RMDir /r "$INSTDIR\app"
|
||||
RMDir /r "$INSTDIR\scripts"
|
||||
Delete "$INSTDIR\LedGrab.bat"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ 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`
|
||||
- Dev key: `development-key-change-in-production`
|
||||
- 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"`)
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ RUN npm run build
|
||||
## Stage 2: Python application
|
||||
FROM python:3.11.11-slim AS runtime
|
||||
|
||||
ARG APP_VERSION=0.0.0
|
||||
|
||||
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="0.2.0"
|
||||
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.licenses="MIT"
|
||||
@@ -34,7 +36,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Copy pyproject.toml with a minimal package stub so pip can resolve deps.
|
||||
# The real source is copied afterward, keeping the dep layer cached.
|
||||
COPY pyproject.toml .
|
||||
RUN mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
|
||||
RUN sed -i "s/^version = .*/version = \"${APP_VERSION}\"/" pyproject.toml \
|
||||
&& mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
|
||||
&& pip install --no-cache-dir ".[notifications]" \
|
||||
&& rm -rf src/wled_controller
|
||||
|
||||
|
||||
@@ -8,19 +8,14 @@ server:
|
||||
- "http://localhost:8080"
|
||||
|
||||
auth:
|
||||
# API keys are REQUIRED - authentication is always enforced
|
||||
# Format: label: "api-key"
|
||||
# API keys — when empty, authentication is disabled (open access).
|
||||
# To enable auth, add one or more label: "api-key" entries.
|
||||
# Generate secure keys: openssl rand -hex 32
|
||||
api_keys:
|
||||
# Generate secure keys: openssl rand -hex 32
|
||||
dev: "development-key-change-in-production" # Development key - CHANGE THIS!
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
templates_file: "data/capture_templates.json"
|
||||
postprocessing_templates_file: "data/postprocessing_templates.json"
|
||||
picture_sources_file: "data/picture_sources.json"
|
||||
output_targets_file: "data/output_targets.json"
|
||||
pattern_templates_file: "data/pattern_templates.json"
|
||||
database_file: "data/ledgrab.db"
|
||||
|
||||
mqtt:
|
||||
enabled: false
|
||||
|
||||
@@ -19,12 +19,7 @@ auth:
|
||||
demo: "demo"
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
templates_file: "data/capture_templates.json"
|
||||
postprocessing_templates_file: "data/postprocessing_templates.json"
|
||||
picture_sources_file: "data/picture_sources.json"
|
||||
output_targets_file: "data/output_targets.json"
|
||||
pattern_templates_file: "data/pattern_templates.json"
|
||||
database_file: "data/ledgrab.db"
|
||||
|
||||
mqtt:
|
||||
enabled: false
|
||||
|
||||
@@ -11,12 +11,7 @@ auth:
|
||||
test_client: "eb8a89cfd33ab067751fd0e38f74ddf7ac3d75ff012fbab35a616c45c12e0c8d"
|
||||
|
||||
storage:
|
||||
devices_file: "data/test_devices.json"
|
||||
templates_file: "data/capture_templates.json"
|
||||
postprocessing_templates_file: "data/postprocessing_templates.json"
|
||||
picture_sources_file: "data/picture_sources.json"
|
||||
output_targets_file: "data/output_targets.json"
|
||||
pattern_templates_file: "data/pattern_templates.json"
|
||||
database_file: "data/test_ledgrab.db"
|
||||
|
||||
logging:
|
||||
format: "text"
|
||||
|
||||
@@ -26,8 +26,8 @@ dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"httpx>=0.27.2",
|
||||
"packaging>=23.0",
|
||||
"mss>=9.0.2",
|
||||
"Pillow>=10.4.0",
|
||||
"numpy>=2.1.3",
|
||||
"pydantic>=2.9.2",
|
||||
"pydantic-settings>=2.6.0",
|
||||
@@ -45,6 +45,7 @@ dependencies = [
|
||||
"sounddevice>=0.5",
|
||||
"aiomqtt>=2.0.0",
|
||||
"openrgb-python>=0.2.15",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -56,9 +57,11 @@ dev = [
|
||||
"black>=24.0.0",
|
||||
"ruff>=0.6.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
"Pillow>=10.4.0",
|
||||
]
|
||||
camera = [
|
||||
"opencv-python-headless>=4.8.0",
|
||||
# opencv-python-headless is now a core dependency (used for image encoding)
|
||||
# camera extra kept for backwards compatibility
|
||||
]
|
||||
# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
|
||||
notifications = [
|
||||
@@ -75,6 +78,10 @@ perf = [
|
||||
"bettercam>=1.0.0; sys_platform == 'win32'",
|
||||
"windows-capture>=1.5.0; sys_platform == 'win32'",
|
||||
]
|
||||
tray = [
|
||||
"pystray>=0.19.0; sys_platform == 'win32'",
|
||||
"Pillow>=10.4.0; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
|
||||
@@ -1,12 +1,76 @@
|
||||
# Restart the WLED Screen Controller server
|
||||
# Stop any running instance
|
||||
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*wled_controller.main*' }
|
||||
foreach ($p in $procs) {
|
||||
Write-Host "Stopping server (PID $($p.ProcessId))..."
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
# 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'
|
||||
|
||||
# Read API key from config for authenticated shutdown request
|
||||
$configPath = Join-Path $serverRoot 'config\default_config.yaml'
|
||||
$apiKey = $null
|
||||
if (Test-Path $configPath) {
|
||||
$inKeys = $false
|
||||
foreach ($line in Get-Content $configPath) {
|
||||
if ($line -match '^\s*api_keys:') { $inKeys = $true; continue }
|
||||
if ($inKeys -and $line -match '^\s+\w+:\s*"(.+)"') {
|
||||
$apiKey = $Matches[1]; break
|
||||
}
|
||||
if ($inKeys -and $line -match '^\S') { break } # left the api_keys block
|
||||
}
|
||||
}
|
||||
|
||||
# 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*' }
|
||||
|
||||
if ($procs) {
|
||||
# Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save)
|
||||
$shutdownOk = $false
|
||||
if ($apiKey) {
|
||||
Write-Host "Requesting graceful shutdown..."
|
||||
try {
|
||||
$headers = @{ Authorization = "Bearer $apiKey" }
|
||||
Invoke-RestMethod -Uri 'http://localhost:8080/api/v1/system/shutdown' `
|
||||
-Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null
|
||||
$shutdownOk = $true
|
||||
} catch {
|
||||
Write-Host " API shutdown failed ($($_.Exception.Message)), falling back to process kill"
|
||||
}
|
||||
}
|
||||
|
||||
if ($shutdownOk) {
|
||||
# Step 2: Wait for the server to exit gracefully (up to 15 seconds)
|
||||
# The server needs time to stop processors, disconnect devices, and persist stores.
|
||||
Write-Host "Waiting for graceful shutdown..."
|
||||
$waited = 0
|
||||
while ($waited -lt 15) {
|
||||
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*' }
|
||||
if (-not $still) {
|
||||
Write-Host " Server exited cleanly after ${waited}s"
|
||||
break
|
||||
}
|
||||
}
|
||||
# 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*' }
|
||||
if ($still) {
|
||||
Write-Host " Force-killing remaining processes..."
|
||||
foreach ($p in $still) {
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
} else {
|
||||
# No API key or API call failed — force-kill directly
|
||||
foreach ($p in $procs) {
|
||||
Write-Host "Stopping server (PID $($p.ProcessId))..."
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
if ($procs) { Start-Sleep -Seconds 2 }
|
||||
|
||||
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
|
||||
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
@@ -19,17 +83,23 @@ if ($regUser) {
|
||||
}
|
||||
}
|
||||
|
||||
# Start server detached
|
||||
# Start server detached (set WLED_RESTART=1 to skip browser open)
|
||||
Write-Host "Starting server..."
|
||||
Start-Process -FilePath python -ArgumentList '-m', 'wled_controller.main' `
|
||||
-WorkingDirectory 'c:\Users\Alexei\Documents\wled-screen-controller\server' `
|
||||
$env:WLED_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' `
|
||||
-WorkingDirectory $serverRoot `
|
||||
-WindowStyle Hidden
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# Verify it's running
|
||||
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*wled_controller.main*' }
|
||||
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||
if ($check) {
|
||||
Write-Host "Server started (PID $($check[0].ProcessId))"
|
||||
} else {
|
||||
|
||||
@@ -18,7 +18,7 @@ cd /d "%~dp0\.."
|
||||
REM Start the server
|
||||
echo.
|
||||
echo [2/2] Starting server...
|
||||
python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
python -m wled_controller
|
||||
|
||||
REM If the server exits, pause to show any error messages
|
||||
pause
|
||||
|
||||
13
server/scripts/start-hidden.vbs
Normal file
13
server/scripts/start-hidden.vbs
Normal file
@@ -0,0 +1,13 @@
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
' Get the directory of this script (scripts\), then go up to app root
|
||||
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||
appRoot = fso.GetParentFolderName(scriptDir)
|
||||
WshShell.CurrentDirectory = appRoot
|
||||
' Use embedded Python if present (installed dist), otherwise system Python
|
||||
embeddedPython = appRoot & "\python\pythonw.exe"
|
||||
If fso.FileExists(embeddedPython) Then
|
||||
WshShell.Run """" & embeddedPython & """ -m wled_controller", 0, False
|
||||
Else
|
||||
WshShell.Run "python -m wled_controller", 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 uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080", 0, False
|
||||
WshShell.Run "python -m wled_controller", 0, False
|
||||
Set FSO = Nothing
|
||||
Set WshShell = Nothing
|
||||
|
||||
@@ -9,7 +9,7 @@ REM Change to the server directory (parent of scripts folder)
|
||||
cd /d "%~dp0\.."
|
||||
|
||||
REM Start the server
|
||||
python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
|
||||
python -m wled_controller
|
||||
|
||||
REM If the server exits, pause to show any error messages
|
||||
pause
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"""LED Grab - Ambient lighting based on screen content."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
try:
|
||||
__version__ = version("wled-screen-controller")
|
||||
except PackageNotFoundError:
|
||||
# Running from source without pip install (e.g. dev, embedded Python)
|
||||
__version__ = "0.0.0-dev"
|
||||
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
|
||||
111
server/src/wled_controller/__main__.py
Normal file
111
server/src/wled_controller/__main__.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Entry point for ``python -m wled_controller``.
|
||||
|
||||
Starts the uvicorn server and, on Windows when *pystray* is installed,
|
||||
shows a system-tray icon with **Show UI** / **Exit** actions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.server_ref import set_server, set_tray
|
||||
from wled_controller.tray import PYSTRAY_AVAILABLE, TrayManager
|
||||
from wled_controller.utils import setup_logging, get_logger
|
||||
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png"
|
||||
|
||||
|
||||
def _run_server(server: uvicorn.Server) -> None:
|
||||
"""Run uvicorn in a dedicated asyncio event loop (background thread)."""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def _open_browser(port: int, delay: float = 2.0) -> None:
|
||||
"""Open the UI in the default browser after a short delay."""
|
||||
time.sleep(delay)
|
||||
webbrowser.open(f"http://localhost:{port}")
|
||||
|
||||
|
||||
def _is_restart() -> bool:
|
||||
"""Detect if this is a restart (vs first launch)."""
|
||||
return os.environ.get("WLED_RESTART", "") == "1"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = get_config()
|
||||
|
||||
uv_config = uvicorn.Config(
|
||||
"wled_controller.main:app",
|
||||
host=config.server.host,
|
||||
port=config.server.port,
|
||||
log_level=config.server.log_level.lower(),
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
set_server(server)
|
||||
|
||||
use_tray = PYSTRAY_AVAILABLE and (
|
||||
sys.platform == "win32" or _force_tray()
|
||||
)
|
||||
|
||||
if use_tray:
|
||||
logger.info("Starting with system tray icon")
|
||||
|
||||
# Uvicorn in a background thread
|
||||
server_thread = threading.Thread(
|
||||
target=_run_server, args=(server,), daemon=True,
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
# Browser after a short delay (skip on restart — user already has a tab)
|
||||
if not _is_restart():
|
||||
threading.Thread(
|
||||
target=_open_browser,
|
||||
args=(config.server.port,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
# Tray on main thread (blocking)
|
||||
tray = TrayManager(
|
||||
icon_path=_ICON_PATH,
|
||||
port=config.server.port,
|
||||
on_exit=lambda: _request_shutdown(server),
|
||||
)
|
||||
set_tray(tray)
|
||||
tray.run()
|
||||
|
||||
# Tray exited — wait for server to finish its graceful shutdown
|
||||
server_thread.join(timeout=10)
|
||||
else:
|
||||
if not PYSTRAY_AVAILABLE:
|
||||
logger.info(
|
||||
"System tray not available (install pystray for tray support)"
|
||||
)
|
||||
server.run()
|
||||
|
||||
|
||||
def _request_shutdown(server: uvicorn.Server) -> None:
|
||||
"""Signal uvicorn to perform a graceful shutdown."""
|
||||
server.should_exit = True
|
||||
|
||||
|
||||
def _force_tray() -> bool:
|
||||
"""Allow forcing tray on non-Windows via WLED_TRAY=1."""
|
||||
import os
|
||||
|
||||
return os.environ.get("WLED_TRAY", "").strip() in ("1", "true", "yes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,6 +23,9 @@ from .routes.scene_presets import router as scene_presets_router
|
||||
from .routes.webhooks import router as webhooks_router
|
||||
from .routes.sync_clocks import router as sync_clocks_router
|
||||
from .routes.color_strip_processing import router as cspt_router
|
||||
from .routes.gradients import router as gradients_router
|
||||
from .routes.weather_sources import router as weather_sources_router
|
||||
from .routes.update import router as update_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -46,5 +49,8 @@ router.include_router(scene_presets_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(sync_clocks_router)
|
||||
router.include_router(cspt_router)
|
||||
router.include_router(gradients_router)
|
||||
router.include_router(weather_sources_router)
|
||||
router.include_router(update_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -15,11 +15,19 @@ logger = get_logger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Return True when at least one API key is configured."""
|
||||
return bool(get_config().auth.api_keys)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
|
||||
) -> str:
|
||||
"""Verify API key from Authorization header.
|
||||
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
requests are allowed through as "anonymous".
|
||||
|
||||
Args:
|
||||
credentials: HTTP authorization credentials
|
||||
|
||||
@@ -31,6 +39,10 @@ def verify_api_key(
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
# No keys configured → auth disabled, allow all requests
|
||||
if not config.auth.api_keys:
|
||||
return "anonymous"
|
||||
|
||||
# Check if credentials are provided
|
||||
if not credentials:
|
||||
logger.warning("Request missing Authorization header")
|
||||
@@ -43,14 +55,6 @@ def verify_api_key(
|
||||
# Extract token
|
||||
token = credentials.credentials
|
||||
|
||||
# Verify against configured API keys
|
||||
if not config.auth.api_keys:
|
||||
logger.error("No API keys configured - server misconfiguration")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Server authentication not configured properly",
|
||||
)
|
||||
|
||||
# Find matching key and return its label using constant-time comparison
|
||||
authenticated_as = None
|
||||
for label, api_key in config.auth.api_keys.items():
|
||||
@@ -80,10 +84,14 @@ AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
WebSocket connections are allowed.
|
||||
"""
|
||||
config = get_config()
|
||||
if token and config.auth.api_keys:
|
||||
# No keys configured → auth disabled, allow all connections
|
||||
if not config.auth.api_keys:
|
||||
return True
|
||||
if token:
|
||||
for _label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -21,9 +22,13 @@ from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||
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
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -114,6 +119,26 @@ def get_cspt_store() -> ColorStripProcessingTemplateStore:
|
||||
return _get("cspt_store", "Color strip processing template store")
|
||||
|
||||
|
||||
def get_gradient_store() -> GradientStore:
|
||||
return _get("gradient_store", "Gradient store")
|
||||
|
||||
|
||||
def get_weather_source_store() -> WeatherSourceStore:
|
||||
return _get("weather_source_store", "Weather source store")
|
||||
|
||||
|
||||
def get_weather_manager() -> WeatherManager:
|
||||
return _get("weather_manager", "Weather manager")
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
return _get("database", "Database")
|
||||
|
||||
|
||||
def get_update_service() -> UpdateService:
|
||||
return _get("update_service", "Update service")
|
||||
|
||||
|
||||
# ── Event helper ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -142,6 +167,7 @@ def init_dependencies(
|
||||
device_store: DeviceStore,
|
||||
template_store: TemplateStore,
|
||||
processor_manager: ProcessorManager,
|
||||
database: Database | None = None,
|
||||
pp_template_store: PostprocessingTemplateStore | None = None,
|
||||
pattern_template_store: PatternTemplateStore | None = None,
|
||||
picture_source_store: PictureSourceStore | None = None,
|
||||
@@ -157,9 +183,14 @@ def init_dependencies(
|
||||
sync_clock_store: SyncClockStore | None = None,
|
||||
sync_clock_manager: SyncClockManager | None = None,
|
||||
cspt_store: ColorStripProcessingTemplateStore | None = None,
|
||||
gradient_store: GradientStore | None = None,
|
||||
weather_source_store: WeatherSourceStore | None = None,
|
||||
weather_manager: WeatherManager | None = None,
|
||||
update_service: UpdateService | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update({
|
||||
"database": database,
|
||||
"device_store": device_store,
|
||||
"template_store": template_store,
|
||||
"processor_manager": processor_manager,
|
||||
@@ -178,4 +209,8 @@ def init_dependencies(
|
||||
"sync_clock_store": sync_clock_store,
|
||||
"sync_clock_manager": sync_clock_manager,
|
||||
"cspt_store": cspt_store,
|
||||
"gradient_store": gradient_store,
|
||||
"weather_source_store": weather_source_store,
|
||||
"weather_manager": weather_manager,
|
||||
"update_service": update_service,
|
||||
})
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Shared helpers for WebSocket-based capture preview endpoints."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
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 (
|
||||
encode_jpeg,
|
||||
encode_jpeg_data_uri,
|
||||
resize_down,
|
||||
thumbnail,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -32,47 +35,35 @@ def authenticate_ws_token(token: str) -> bool:
|
||||
return verify_ws_token(token)
|
||||
|
||||
|
||||
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||
"""Encode a PIL image as a JPEG base64 data URI."""
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=quality)
|
||||
buf.seek(0)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
def _encode_jpeg(image: np.ndarray, quality: int = 85) -> str:
|
||||
"""Encode a numpy RGB image as a JPEG base64 data URI."""
|
||||
return encode_jpeg_data_uri(image, quality)
|
||||
|
||||
|
||||
def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes:
|
||||
"""Encode a numpy RGB image to JPEG bytes, optionally downscaling."""
|
||||
pil_img = Image.fromarray(image)
|
||||
if max_width and image.shape[1] > max_width:
|
||||
scale = max_width / image.shape[1]
|
||||
new_h = int(image.shape[0] * scale)
|
||||
pil_img = pil_img.resize((max_width, new_h), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
pil_img.save(buf, format="JPEG", quality=quality)
|
||||
return buf.getvalue()
|
||||
if max_width:
|
||||
image = resize_down(image, max_width)
|
||||
return encode_jpeg(image, quality)
|
||||
|
||||
|
||||
def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image:
|
||||
def _make_thumbnail(image: np.ndarray, max_width: int) -> np.ndarray:
|
||||
"""Create a thumbnail copy of the image, preserving aspect ratio."""
|
||||
thumb = pil_image.copy()
|
||||
aspect = pil_image.height / pil_image.width
|
||||
thumb.thumbnail((max_width, int(max_width * aspect)), Image.Resampling.LANCZOS)
|
||||
return thumb
|
||||
return thumbnail(image, max_width)
|
||||
|
||||
|
||||
def _apply_pp_filters(pil_image: Image.Image, flat_filters: list) -> Image.Image:
|
||||
"""Apply postprocessing filter instances to a PIL image."""
|
||||
def _apply_pp_filters(image: np.ndarray, flat_filters: list) -> np.ndarray:
|
||||
"""Apply postprocessing filter instances to a numpy image."""
|
||||
if not flat_filters:
|
||||
return pil_image
|
||||
return image
|
||||
pool = ImagePool()
|
||||
arr = np.array(pil_image)
|
||||
arr = image
|
||||
for fi in flat_filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
return arr
|
||||
|
||||
|
||||
async def stream_capture_test(
|
||||
@@ -98,7 +89,7 @@ async def stream_capture_test(
|
||||
thumb_width = preview_width or PREVIEW_MAX_WIDTH
|
||||
|
||||
# Shared state between capture thread and async loop
|
||||
latest_frame = None # PIL Image (converted from numpy)
|
||||
latest_frame = None # numpy RGB array
|
||||
frame_count = 0
|
||||
total_capture_time = 0.0
|
||||
stop_event = threading.Event()
|
||||
@@ -121,9 +112,8 @@ async def stream_capture_test(
|
||||
continue
|
||||
total_capture_time += t1 - t0
|
||||
frame_count += 1
|
||||
# Convert numpy -> PIL once in the capture thread
|
||||
if isinstance(capture.image, np.ndarray):
|
||||
latest_frame = Image.fromarray(capture.image)
|
||||
latest_frame = capture.image
|
||||
else:
|
||||
latest_frame = capture.image
|
||||
except Exception as e:
|
||||
@@ -202,7 +192,7 @@ async def stream_capture_test(
|
||||
if pp_filters:
|
||||
final_frame = _apply_pp_filters(final_frame, pp_filters)
|
||||
|
||||
w, h = final_frame.size
|
||||
h, w = final_frame.shape[:2]
|
||||
|
||||
full_uri = _encode_jpeg(final_frame, FINAL_JPEG_QUALITY)
|
||||
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)
|
||||
|
||||
@@ -42,6 +42,9 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
audio_template_id=getattr(source, "audio_template_id", None),
|
||||
audio_source_id=getattr(source, "audio_source_id", None),
|
||||
channel=getattr(source, "channel", None),
|
||||
band=getattr(source, "band", None),
|
||||
freq_low=getattr(source, "freq_low", None),
|
||||
freq_high=getattr(source, "freq_high", None),
|
||||
description=source.description,
|
||||
tags=source.tags,
|
||||
created_at=source.created_at,
|
||||
@@ -52,7 +55,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse:
|
||||
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
|
||||
async def list_audio_sources(
|
||||
_auth: AuthRequired,
|
||||
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel or mono"),
|
||||
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel, mono, or band_extract"),
|
||||
store: AudioSourceStore = Depends(get_audio_source_store),
|
||||
):
|
||||
"""List all audio sources, optionally filtered by type."""
|
||||
@@ -83,6 +86,9 @@ async def create_audio_source(
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
band=data.band,
|
||||
freq_low=data.freq_low,
|
||||
freq_high=data.freq_high,
|
||||
)
|
||||
fire_entity_event("audio_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
@@ -126,6 +132,9 @@ async def update_audio_source(
|
||||
description=data.description,
|
||||
audio_template_id=data.audio_template_id,
|
||||
tags=data.tags,
|
||||
band=data.band,
|
||||
freq_low=data.freq_low,
|
||||
freq_high=data.freq_high,
|
||||
)
|
||||
fire_entity_event("audio_source", "updated", source_id)
|
||||
return _to_response(source)
|
||||
@@ -182,17 +191,28 @@ async def test_audio_source_ws(
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Resolve source → device info
|
||||
# Resolve source → device info + optional band filter
|
||||
store = get_audio_source_store()
|
||||
template_store = get_audio_template_store()
|
||||
manager = get_processor_manager()
|
||||
|
||||
try:
|
||||
device_index, is_loopback, channel, audio_template_id = store.resolve_audio_source(source_id)
|
||||
resolved = store.resolve_audio_source(source_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
device_index = resolved.device_index
|
||||
is_loopback = resolved.is_loopback
|
||||
channel = resolved.channel
|
||||
audio_template_id = resolved.audio_template_id
|
||||
|
||||
# Precompute band mask if this is a band_extract source
|
||||
band_mask = None
|
||||
if resolved.freq_low is not None and resolved.freq_high is not None:
|
||||
from wled_controller.core.audio.band_filter import compute_band_mask
|
||||
band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
|
||||
|
||||
# Resolve template → engine_type + config
|
||||
engine_type = None
|
||||
engine_config = None
|
||||
@@ -233,6 +253,11 @@ async def test_audio_source_ws(
|
||||
spectrum = analysis.spectrum
|
||||
rms = analysis.rms
|
||||
|
||||
# Apply band filter if present
|
||||
if band_mask is not None:
|
||||
from wled_controller.core.audio.band_filter import apply_band_filter
|
||||
spectrum, rms = apply_band_filter(spectrum, rms, band_mask)
|
||||
|
||||
await websocket.send_json({
|
||||
"spectrum": spectrum.tolist(),
|
||||
"rms": round(rms, 4),
|
||||
|
||||
@@ -42,40 +42,37 @@ router = APIRouter()
|
||||
# ===== Helpers =====
|
||||
|
||||
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
||||
if s.condition_type == "always":
|
||||
return AlwaysCondition()
|
||||
if s.condition_type == "application":
|
||||
return ApplicationCondition(
|
||||
_SCHEMA_TO_CONDITION = {
|
||||
"always": lambda: AlwaysCondition(),
|
||||
"application": lambda: ApplicationCondition(
|
||||
apps=s.apps or [],
|
||||
match_type=s.match_type or "running",
|
||||
)
|
||||
if s.condition_type == "time_of_day":
|
||||
return TimeOfDayCondition(
|
||||
),
|
||||
"time_of_day": lambda: TimeOfDayCondition(
|
||||
start_time=s.start_time or "00:00",
|
||||
end_time=s.end_time or "23:59",
|
||||
)
|
||||
if s.condition_type == "system_idle":
|
||||
return SystemIdleCondition(
|
||||
),
|
||||
"system_idle": lambda: SystemIdleCondition(
|
||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||
when_idle=s.when_idle if s.when_idle is not None else True,
|
||||
)
|
||||
if s.condition_type == "display_state":
|
||||
return DisplayStateCondition(
|
||||
),
|
||||
"display_state": lambda: DisplayStateCondition(
|
||||
state=s.state or "on",
|
||||
)
|
||||
if s.condition_type == "mqtt":
|
||||
return MQTTCondition(
|
||||
),
|
||||
"mqtt": lambda: MQTTCondition(
|
||||
topic=s.topic or "",
|
||||
payload=s.payload or "",
|
||||
match_mode=s.match_mode or "exact",
|
||||
)
|
||||
if s.condition_type == "webhook":
|
||||
return WebhookCondition(
|
||||
),
|
||||
"webhook": lambda: WebhookCondition(
|
||||
token=s.token or secrets.token_hex(16),
|
||||
)
|
||||
if s.condition_type == "startup":
|
||||
return StartupCondition()
|
||||
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
||||
),
|
||||
"startup": lambda: StartupCondition(),
|
||||
}
|
||||
factory = _SCHEMA_TO_CONDITION.get(s.condition_type)
|
||||
if factory is None:
|
||||
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
||||
return factory()
|
||||
|
||||
|
||||
def _condition_to_schema(c: Condition) -> ConditionSchema:
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
"""System routes: backup, restore, export, import, auto-backup.
|
||||
"""System routes: backup, restore, auto-backup.
|
||||
|
||||
Extracted from system.py to keep files under 800 lines.
|
||||
All backups are SQLite database snapshots (.db files).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import get_auto_backup_engine
|
||||
from wled_controller.api.dependencies import get_auto_backup_engine, get_database
|
||||
from wled_controller.api.schemas.system import (
|
||||
AutoBackupSettings,
|
||||
AutoBackupStatusResponse,
|
||||
@@ -26,35 +23,13 @@ from wled_controller.api.schemas.system import (
|
||||
RestoreResponse,
|
||||
)
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.storage.database import Database, freeze_writes
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration backup / restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping: logical store name -> StorageConfig attribute name
|
||||
STORE_MAP = {
|
||||
"devices": "devices_file",
|
||||
"capture_templates": "templates_file",
|
||||
"postprocessing_templates": "postprocessing_templates_file",
|
||||
"picture_sources": "picture_sources_file",
|
||||
"output_targets": "output_targets_file",
|
||||
"pattern_templates": "pattern_templates_file",
|
||||
"color_strip_sources": "color_strip_sources_file",
|
||||
"audio_sources": "audio_sources_file",
|
||||
"audio_templates": "audio_templates_file",
|
||||
"value_sources": "value_sources_file",
|
||||
"sync_clocks": "sync_clocks_file",
|
||||
"color_strip_processing_templates": "color_strip_processing_templates_file",
|
||||
"automations": "automations_file",
|
||||
"scene_presets": "scene_presets_file",
|
||||
}
|
||||
|
||||
_SERVER_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
@@ -79,225 +54,94 @@ def _schedule_restart() -> None:
|
||||
threading.Thread(target=_restart, daemon=True).start()
|
||||
|
||||
|
||||
@router.get("/api/v1/system/export/{store_key}", tags=["System"])
|
||||
def export_store(store_key: str, _: AuthRequired):
|
||||
"""Download a single entity store as a JSON file."""
|
||||
if store_key not in STORE_MAP:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||
)
|
||||
config = get_config()
|
||||
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
export = {
|
||||
"meta": {
|
||||
"format": "ledgrab-partial-export",
|
||||
"format_version": 1,
|
||||
"store_key": store_key,
|
||||
"app_version": __version__,
|
||||
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
},
|
||||
"store": data,
|
||||
}
|
||||
content = json.dumps(export, indent=2, ensure_ascii=False)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-{store_key}-{timestamp}.json"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content.encode("utf-8")),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/import/{store_key}", tags=["System"])
|
||||
async def import_store(
|
||||
store_key: str,
|
||||
_: AuthRequired,
|
||||
file: UploadFile = File(...),
|
||||
merge: bool = Query(False, description="Merge into existing data instead of replacing"),
|
||||
):
|
||||
"""Upload a partial export file to replace or merge one entity store. Triggers server restart."""
|
||||
if store_key not in STORE_MAP:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown store '{store_key}'. Valid keys: {sorted(STORE_MAP.keys())}",
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await file.read()
|
||||
if len(raw) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||
|
||||
# Support both full-backup format and partial-export format
|
||||
if "stores" in payload and isinstance(payload.get("meta"), dict):
|
||||
# Full backup: extract the specific store
|
||||
if payload["meta"].get("format") not in ("ledgrab-backup",):
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||
stores = payload.get("stores", {})
|
||||
if store_key not in stores:
|
||||
raise HTTPException(status_code=400, detail=f"Backup does not contain store '{store_key}'")
|
||||
incoming = stores[store_key]
|
||||
elif isinstance(payload.get("meta"), dict) and payload["meta"].get("format") == "ledgrab-partial-export":
|
||||
# Partial export format
|
||||
if payload["meta"].get("store_key") != store_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File is for store '{payload['meta']['store_key']}', not '{store_key}'",
|
||||
)
|
||||
incoming = payload.get("store", {})
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup or partial export file")
|
||||
|
||||
if not isinstance(incoming, dict):
|
||||
raise HTTPException(status_code=400, detail="Store data must be a JSON object")
|
||||
|
||||
config = get_config()
|
||||
file_path = Path(getattr(config.storage, STORE_MAP[store_key]))
|
||||
|
||||
def _write():
|
||||
if merge and file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if isinstance(existing, dict):
|
||||
existing.update(incoming)
|
||||
atomic_write_json(file_path, existing)
|
||||
return len(existing)
|
||||
atomic_write_json(file_path, incoming)
|
||||
return len(incoming)
|
||||
|
||||
count = await asyncio.to_thread(_write)
|
||||
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
|
||||
_schedule_restart()
|
||||
return {
|
||||
"status": "imported",
|
||||
"store_key": store_key,
|
||||
"entries": count,
|
||||
"merge": merge,
|
||||
"restart_scheduled": True,
|
||||
"message": f"Imported {count} entries for '{store_key}'. Server restarting...",
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup / restore (SQLite snapshots)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/api/v1/system/backup", tags=["System"])
|
||||
def backup_config(_: AuthRequired):
|
||||
"""Download all configuration as a single JSON backup file."""
|
||||
config = get_config()
|
||||
stores = {}
|
||||
for store_key, config_attr in STORE_MAP.items():
|
||||
file_path = Path(getattr(config.storage, config_attr))
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
stores[store_key] = json.load(f)
|
||||
else:
|
||||
stores[store_key] = {}
|
||||
def backup_config(_: AuthRequired, db: Database = Depends(get_database)):
|
||||
"""Download a full database backup as a .db file."""
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
backup = {
|
||||
"meta": {
|
||||
"format": "ledgrab-backup",
|
||||
"format_version": 1,
|
||||
"app_version": __version__,
|
||||
"created_at": datetime.now(timezone.utc).isoformat() + "Z",
|
||||
"store_count": len(stores),
|
||||
},
|
||||
"stores": stores,
|
||||
}
|
||||
try:
|
||||
db.backup_to(tmp_path)
|
||||
content = tmp_path.read_bytes()
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||
from datetime import datetime, timezone
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-backup-{timestamp}.json"
|
||||
filename = f"ledgrab-backup-{timestamp}.db"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content.encode("utf-8")),
|
||||
media_type="application/json",
|
||||
io.BytesIO(content),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/system/restart", tags=["System"])
|
||||
def restart_server(_: AuthRequired):
|
||||
"""Schedule a server restart and return immediately."""
|
||||
_schedule_restart()
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
||||
async def restore_config(
|
||||
_: AuthRequired,
|
||||
file: UploadFile = File(...),
|
||||
db: Database = Depends(get_database),
|
||||
):
|
||||
"""Upload a backup file to restore all configuration. Triggers server restart."""
|
||||
# Read and parse
|
||||
"""Upload a .db backup file to restore all configuration. Triggers server restart."""
|
||||
raw = await file.read()
|
||||
if len(raw) > 50 * 1024 * 1024: # 50 MB limit
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)")
|
||||
|
||||
if len(raw) < 100:
|
||||
raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database")
|
||||
|
||||
# SQLite files start with "SQLite format 3\000"
|
||||
if not raw[:16].startswith(b"SQLite format 3"):
|
||||
raise HTTPException(status_code=400, detail="Not a valid SQLite database file")
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
tmp.write(raw)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
raw = await file.read()
|
||||
if len(raw) > 10 * 1024 * 1024: # 10 MB limit
|
||||
raise HTTPException(status_code=400, detail="Backup file too large (max 10 MB)")
|
||||
backup = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON file: {e}")
|
||||
def _restore():
|
||||
db.restore_from(tmp_path)
|
||||
|
||||
# Validate envelope
|
||||
meta = backup.get("meta")
|
||||
if not isinstance(meta, dict) or meta.get("format") != "ledgrab-backup":
|
||||
raise HTTPException(status_code=400, detail="Not a valid LED Grab backup file")
|
||||
await asyncio.to_thread(_restore)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
fmt_version = meta.get("format_version", 0)
|
||||
if fmt_version > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Backup format version {fmt_version} is not supported by this server version",
|
||||
)
|
||||
|
||||
stores = backup.get("stores")
|
||||
if not isinstance(stores, dict):
|
||||
raise HTTPException(status_code=400, detail="Backup file missing 'stores' section")
|
||||
|
||||
known_keys = set(STORE_MAP.keys())
|
||||
present_keys = known_keys & set(stores.keys())
|
||||
if not present_keys:
|
||||
raise HTTPException(status_code=400, detail="Backup contains no recognized store data")
|
||||
|
||||
for key in present_keys:
|
||||
if not isinstance(stores[key], dict):
|
||||
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
|
||||
|
||||
# Write store files atomically (in thread to avoid blocking event loop)
|
||||
config = get_config()
|
||||
|
||||
def _write_stores():
|
||||
count = 0
|
||||
for store_key, config_attr in STORE_MAP.items():
|
||||
if store_key in stores:
|
||||
file_path = Path(getattr(config.storage, config_attr))
|
||||
atomic_write_json(file_path, stores[store_key])
|
||||
count += 1
|
||||
logger.info(f"Restored store: {store_key} -> {file_path}")
|
||||
return count
|
||||
|
||||
written = await asyncio.to_thread(_write_stores)
|
||||
|
||||
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
|
||||
freeze_writes()
|
||||
logger.info("Database restored from uploaded backup. Scheduling restart...")
|
||||
_schedule_restart()
|
||||
|
||||
missing = known_keys - present_keys
|
||||
return RestoreResponse(
|
||||
status="restored",
|
||||
stores_written=written,
|
||||
stores_total=len(STORE_MAP),
|
||||
missing_stores=sorted(missing) if missing else [],
|
||||
restart_scheduled=True,
|
||||
message=f"Restored {written} stores. Server restarting...",
|
||||
message="Database restored from backup. Server restarting...",
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
_broadcast_restarting()
|
||||
_schedule_restart()
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@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
|
||||
request_shutdown()
|
||||
return {"status": "shutting_down"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-backup settings & saved backups
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -376,7 +220,7 @@ def download_saved_backup(
|
||||
content = path.read_bytes()
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content),
|
||||
media_type="application/json",
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Color strip source routes: CRUD, calibration test, preview, and API input push."""
|
||||
|
||||
import asyncio
|
||||
import io as _io
|
||||
import json as _json
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
@@ -507,7 +506,7 @@ async def os_notification_history(_auth: AuthRequired):
|
||||
|
||||
# ── Transient Preview WebSocket ────────────────────────────────────────
|
||||
|
||||
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"}
|
||||
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight", "notification"}
|
||||
|
||||
|
||||
@router.websocket("/api/v1/color-strip-sources/preview/ws")
|
||||
@@ -567,6 +566,13 @@ async def preview_color_strip_ws(
|
||||
if not stream_cls:
|
||||
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
|
||||
s = stream_cls(source)
|
||||
# Inject gradient store for palette resolution
|
||||
if hasattr(s, "set_gradient_store"):
|
||||
try:
|
||||
from wled_controller.api.dependencies import get_gradient_store
|
||||
s.set_gradient_store(get_gradient_store())
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(s, "configure"):
|
||||
s.configure(led_count)
|
||||
# Inject sync clock if requested
|
||||
@@ -648,6 +654,17 @@ async def preview_color_strip_ws(
|
||||
if msg is not None:
|
||||
try:
|
||||
new_config = _json.loads(msg)
|
||||
|
||||
# Handle "fire" command for notification streams
|
||||
if new_config.get("action") == "fire":
|
||||
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
|
||||
if isinstance(stream, NotificationColorStripStream):
|
||||
stream.fire(
|
||||
app_name=new_config.get("app", ""),
|
||||
color_override=new_config.get("color"),
|
||||
)
|
||||
continue
|
||||
|
||||
new_type = new_config.get("source_type")
|
||||
if new_type not in _PREVIEW_ALLOWED_TYPES:
|
||||
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
|
||||
@@ -829,6 +846,15 @@ async def test_color_strip_ws(
|
||||
if hasattr(stream, "configure"):
|
||||
stream.configure(max(1, led_count))
|
||||
|
||||
# Reject picture sources with 0 calibration LEDs (no edges configured)
|
||||
if stream.led_count <= 0:
|
||||
csm.release(source_id, consumer_id)
|
||||
await websocket.close(
|
||||
code=4005,
|
||||
reason="No LEDs configured. Open Calibration and set LED counts for each edge.",
|
||||
)
|
||||
return
|
||||
|
||||
# Clamp FPS to sane range
|
||||
fps = max(1, min(60, fps))
|
||||
_frame_interval = 1.0 / fps
|
||||
@@ -962,7 +988,8 @@ 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 PIL import Image as _PIL_Image
|
||||
from wled_controller.utils.image_codec import encode_jpeg
|
||||
import cv2 as _cv2
|
||||
img = frame.image
|
||||
# Ensure 3-channel RGB (some engines may produce BGRA)
|
||||
if img.ndim == 3 and img.shape[2] == 4:
|
||||
@@ -981,13 +1008,9 @@ async def test_color_strip_ws(
|
||||
if scale < 1.0:
|
||||
new_w = max(1, int(w * scale))
|
||||
new_h = max(1, int(h * scale))
|
||||
pil = _PIL_Image.fromarray(img).resize((new_w, new_h), _PIL_Image.LANCZOS)
|
||||
else:
|
||||
pil = _PIL_Image.fromarray(img)
|
||||
buf = _io.BytesIO()
|
||||
pil.save(buf, format='JPEG', quality=70)
|
||||
img = _cv2.resize(img, (new_w, new_h), interpolation=_cv2.INTER_AREA)
|
||||
# Wire format: [0xFD] [jpeg_bytes]
|
||||
await websocket.send_bytes(b'\xfd' + buf.getvalue())
|
||||
await websocket.send_bytes(b'\xfd' + encode_jpeg(img, quality=70))
|
||||
except Exception as e:
|
||||
logger.warning(f"JPEG frame preview error: {e}")
|
||||
|
||||
|
||||
153
server/src/wled_controller/api/routes/gradients.py
Normal file
153
server/src/wled_controller/api/routes/gradients.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Gradient routes: CRUD for reusable gradient definitions."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_gradient_store,
|
||||
)
|
||||
from wled_controller.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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(gradient: Gradient) -> GradientResponse:
|
||||
return GradientResponse(
|
||||
id=gradient.id,
|
||||
name=gradient.name,
|
||||
stops=[{"position": s["position"], "color": s["color"]} for s in gradient.stops],
|
||||
is_builtin=gradient.is_builtin,
|
||||
description=gradient.description,
|
||||
tags=gradient.tags,
|
||||
created_at=gradient.created_at,
|
||||
updated_at=gradient.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/gradients", response_model=GradientListResponse, tags=["Gradients"])
|
||||
async def list_gradients(
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""List all gradients (built-in + user-created)."""
|
||||
gradients = store.get_all_gradients()
|
||||
return GradientListResponse(
|
||||
gradients=[_to_response(g) for g in gradients],
|
||||
count=len(gradients),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/gradients", response_model=GradientResponse, status_code=201, tags=["Gradients"])
|
||||
async def create_gradient(
|
||||
data: GradientCreate,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Create a new user-defined gradient."""
|
||||
try:
|
||||
gradient = store.create_gradient(
|
||||
name=data.name,
|
||||
stops=[s.model_dump() for s in data.stops],
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("gradient", "created", gradient.id)
|
||||
return _to_response(gradient)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/gradients/{gradient_id}", response_model=GradientResponse, tags=["Gradients"])
|
||||
async def get_gradient(
|
||||
gradient_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Get a gradient by ID."""
|
||||
try:
|
||||
gradient = store.get_gradient(gradient_id)
|
||||
return _to_response(gradient)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/v1/gradients/{gradient_id}", response_model=GradientResponse, tags=["Gradients"])
|
||||
async def update_gradient(
|
||||
gradient_id: str,
|
||||
data: GradientUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Update a gradient (built-in gradients are read-only)."""
|
||||
try:
|
||||
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
|
||||
gradient = store.update_gradient(
|
||||
gradient_id=gradient_id,
|
||||
name=data.name,
|
||||
stops=stops,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("gradient", "updated", gradient_id)
|
||||
return _to_response(gradient)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
status = 404 if "not found" in str(e).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=str(e))
|
||||
|
||||
|
||||
@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,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
):
|
||||
"""Clone a gradient (useful for customizing built-in gradients)."""
|
||||
try:
|
||||
original = store.get_gradient(gradient_id)
|
||||
clone = store.create_gradient(
|
||||
name=f"{original.name} (copy)",
|
||||
stops=original.stops,
|
||||
description=original.description,
|
||||
tags=original.tags,
|
||||
)
|
||||
fire_entity_event("gradient", "created", clone.id)
|
||||
return _to_response(clone)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
status = 404 if "not found" in str(e).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/gradients/{gradient_id}", status_code=204, tags=["Gradients"])
|
||||
async def delete_gradient(
|
||||
gradient_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GradientStore = Depends(get_gradient_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a gradient (fails if built-in or referenced by sources)."""
|
||||
try:
|
||||
# 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}'"
|
||||
)
|
||||
store.delete_gradient(gradient_id)
|
||||
fire_entity_event("gradient", "deleted", gradient_id)
|
||||
except (ValueError, EntityNotFoundError) as e:
|
||||
status = 404 if "not found" in str(e).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=str(e))
|
||||
@@ -4,13 +4,10 @@ Extracted from output_targets.py to keep files under 800 lines.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
@@ -133,19 +130,21 @@ async def test_kc_target(
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
source = raw_stream.image_source
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
image = load_image_bytes(resp.content)
|
||||
else:
|
||||
from pathlib import Path
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
image = load_image_file(path)
|
||||
|
||||
elif isinstance(raw_stream, ScreenCapturePictureSource):
|
||||
try:
|
||||
@@ -186,17 +185,15 @@ async def test_kc_target(
|
||||
if screen_capture is None:
|
||||
raise RuntimeError("No frame captured")
|
||||
|
||||
if isinstance(screen_capture.image, np.ndarray):
|
||||
pil_image = Image.fromarray(screen_capture.image)
|
||||
else:
|
||||
if not isinstance(screen_capture.image, np.ndarray):
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
image = screen_capture.image
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported picture source type")
|
||||
|
||||
# 3b. Apply postprocessing filters (if the picture source has a filter chain)
|
||||
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||
if pp_template_ids and pp_template_store:
|
||||
img_array = np.array(pil_image)
|
||||
image_pool = ImagePool()
|
||||
for pp_id in pp_template_ids:
|
||||
try:
|
||||
@@ -208,15 +205,14 @@ async def test_kc_target(
|
||||
for fi in flat_filters:
|
||||
try:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(img_array, image_pool)
|
||||
result = f.process_image(image, image_pool)
|
||||
if result is not None:
|
||||
img_array = result
|
||||
image = result
|
||||
except ValueError:
|
||||
logger.warning(f"KC test: unknown filter '{fi.filter_id}', skipping")
|
||||
pil_image = Image.fromarray(img_array)
|
||||
|
||||
# 4. Extract colors from each rectangle
|
||||
img_array = np.array(pil_image)
|
||||
img_array = image
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
calc_fns = {
|
||||
@@ -250,11 +246,8 @@ async def test_kc_target(
|
||||
))
|
||||
|
||||
# 5. Encode frame as base64 JPEG
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
image_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
from wled_controller.utils.image_codec import encode_jpeg_data_uri
|
||||
image_data_uri = encode_jpeg_data_uri(image, quality=90)
|
||||
|
||||
return KCTestResponse(
|
||||
image=image_data_uri,
|
||||
@@ -411,8 +404,11 @@ async def test_kc_target_ws(
|
||||
continue
|
||||
prev_frame_ref = capture
|
||||
|
||||
pil_image = Image.fromarray(capture.image) if isinstance(capture.image, np.ndarray) else None
|
||||
if pil_image is None:
|
||||
if not isinstance(capture.image, np.ndarray):
|
||||
await asyncio.sleep(frame_interval)
|
||||
continue
|
||||
cur_image = capture.image
|
||||
if cur_image is None:
|
||||
await asyncio.sleep(frame_interval)
|
||||
continue
|
||||
|
||||
@@ -420,7 +416,6 @@ async def test_kc_target_ws(
|
||||
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
|
||||
pp_template_ids = chain.get("postprocessing_template_ids", [])
|
||||
if pp_template_ids and pp_template_store_inst:
|
||||
img_array = np.array(pil_image)
|
||||
image_pool = ImagePool()
|
||||
for pp_id in pp_template_ids:
|
||||
try:
|
||||
@@ -431,15 +426,14 @@ async def test_kc_target_ws(
|
||||
for fi in flat_filters:
|
||||
try:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(img_array, image_pool)
|
||||
result = f.process_image(cur_image, image_pool)
|
||||
if result is not None:
|
||||
img_array = result
|
||||
cur_image = result
|
||||
except ValueError:
|
||||
pass
|
||||
pil_image = Image.fromarray(img_array)
|
||||
|
||||
# Extract colors
|
||||
img_array = np.array(pil_image)
|
||||
img_array = cur_image
|
||||
h, w = img_array.shape[:2]
|
||||
|
||||
result_rects = []
|
||||
@@ -466,18 +460,13 @@ async def test_kc_target_ws(
|
||||
})
|
||||
|
||||
# Encode frame as JPEG
|
||||
if preview_width and pil_image.width > preview_width:
|
||||
ratio = preview_width / pil_image.width
|
||||
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
|
||||
else:
|
||||
thumb = pil_image
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=85)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
|
||||
frame_to_encode = resize_down(cur_image, preview_width) if preview_width else cur_image
|
||||
frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85)
|
||||
|
||||
await websocket.send_text(_json.dumps({
|
||||
"type": "frame",
|
||||
"image": f"data:image/jpeg;base64,{b64}",
|
||||
"image": frame_uri,
|
||||
"rectangles": result_rects,
|
||||
"pattern_template_name": pattern_tmpl.name,
|
||||
"interpolation_mode": settings.interpolation_mode,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"""Picture source routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import Response
|
||||
|
||||
@@ -115,16 +112,20 @@ async def validate_image(
|
||||
img_bytes = path
|
||||
|
||||
def _process_image(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
thumb = pil_image.copy()
|
||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=80)
|
||||
buf.seek(0)
|
||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
return width, height, preview
|
||||
from wled_controller.utils.image_codec import (
|
||||
encode_jpeg_data_uri,
|
||||
load_image_bytes,
|
||||
load_image_file,
|
||||
thumbnail as make_thumbnail,
|
||||
)
|
||||
if isinstance(src, bytes):
|
||||
image = load_image_bytes(src)
|
||||
else:
|
||||
image = load_image_file(src)
|
||||
h, w = image.shape[:2]
|
||||
thumb = make_thumbnail(image, 320)
|
||||
preview = encode_jpeg_data_uri(thumb, quality=80)
|
||||
return w, h, preview
|
||||
|
||||
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||
|
||||
@@ -161,11 +162,12 @@ async def get_full_image(
|
||||
img_bytes = path
|
||||
|
||||
def _encode_full(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
return buf.getvalue()
|
||||
from wled_controller.utils.image_codec import encode_jpeg, load_image_bytes, load_image_file
|
||||
if isinstance(src, bytes):
|
||||
image = load_image_bytes(src)
|
||||
else:
|
||||
image = load_image_file(src)
|
||||
return encode_jpeg(image, quality=90)
|
||||
|
||||
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
@@ -333,13 +335,9 @@ async def get_video_thumbnail(
|
||||
store: PictureSourceStore = Depends(get_picture_source_store),
|
||||
):
|
||||
"""Get a thumbnail for a video picture source (first frame)."""
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.core.processing.video_stream import extract_thumbnail
|
||||
from wled_controller.storage.picture_source import VideoCaptureSource
|
||||
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
|
||||
|
||||
try:
|
||||
source = store.get_stream(stream_id)
|
||||
@@ -352,18 +350,12 @@ async def get_video_thumbnail(
|
||||
if frame is None:
|
||||
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
|
||||
|
||||
# Encode as JPEG
|
||||
pil_img = Image.fromarray(frame)
|
||||
# Resize to max 320px wide for thumbnail
|
||||
if pil_img.width > 320:
|
||||
ratio = 320 / pil_img.width
|
||||
pil_img = pil_img.resize((320, int(pil_img.height * ratio)), Image.LANCZOS)
|
||||
frame = resize_down(frame, 320)
|
||||
h, w = frame.shape[:2]
|
||||
data_uri = encode_jpeg_data_uri(frame, quality=80)
|
||||
|
||||
buf = BytesIO()
|
||||
pil_img.save(buf, format="JPEG", quality=80)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return {"thumbnail": f"data:image/jpeg;base64,{b64}", "width": pil_img.width, "height": pil_img.height}
|
||||
return {"thumbnail": data_uri, "width": w, "height": h}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -408,16 +400,18 @@ async def test_picture_source(
|
||||
source = raw_stream.image_source
|
||||
start_time = time.perf_counter()
|
||||
|
||||
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
|
||||
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
image = load_image_bytes(resp.content)
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
|
||||
image = await asyncio.to_thread(load_image_file, path)
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -479,12 +473,13 @@ async def test_picture_source(
|
||||
if last_frame is None:
|
||||
raise RuntimeError("No frames captured during test")
|
||||
|
||||
if isinstance(last_frame.image, np.ndarray):
|
||||
pil_image = Image.fromarray(last_frame.image)
|
||||
else:
|
||||
if not isinstance(last_frame.image, np.ndarray):
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
image = last_frame.image
|
||||
|
||||
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||
from wled_controller.utils.image_codec import encode_jpeg_data_uri, thumbnail as make_thumbnail
|
||||
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
flat_filters = None
|
||||
if pp_template_ids:
|
||||
@@ -494,45 +489,33 @@ async def test_picture_source(
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
def _create_thumbnails_and_encode(pil_img, filters):
|
||||
thumbnail_w = 640
|
||||
aspect_ratio = pil_img.height / pil_img.width
|
||||
thumbnail_h = int(thumbnail_w * aspect_ratio)
|
||||
thumb = pil_img.copy()
|
||||
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
|
||||
def _create_thumbnails_and_encode(img, filters):
|
||||
thumb = make_thumbnail(img, 640)
|
||||
|
||||
if filters:
|
||||
pool = ImagePool()
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
def apply_filters(arr):
|
||||
for fi in filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
return arr
|
||||
thumb = apply_filters(thumb)
|
||||
pil_img = apply_filters(pil_img)
|
||||
img = apply_filters(img)
|
||||
|
||||
img_buffer = io.BytesIO()
|
||||
thumb.save(img_buffer, format='JPEG', quality=85)
|
||||
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumb_uri = encode_jpeg_data_uri(thumb, quality=85)
|
||||
full_uri = encode_jpeg_data_uri(img, quality=90)
|
||||
th, tw = thumb.shape[:2]
|
||||
return tw, th, thumb_uri, full_uri
|
||||
|
||||
full_buffer = io.BytesIO()
|
||||
pil_img.save(full_buffer, format='JPEG', quality=90)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
|
||||
|
||||
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
|
||||
_create_thumbnails_and_encode, pil_image, flat_filters
|
||||
thumbnail_width, thumbnail_height, thumbnail_data_uri, full_data_uri = await asyncio.to_thread(
|
||||
_create_thumbnails_and_encode, image, flat_filters
|
||||
)
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
||||
width, height = pil_image.size
|
||||
height, width = image.shape[:2]
|
||||
|
||||
return TemplateTestResponse(
|
||||
full_capture=CaptureImage(
|
||||
@@ -635,15 +618,11 @@ async def test_picture_source_ws(
|
||||
|
||||
def _encode_video_frame(image, pw):
|
||||
"""Encode numpy RGB image as JPEG base64 data URI."""
|
||||
from PIL import Image as PILImage
|
||||
pil = PILImage.fromarray(image)
|
||||
if pw and pil.width > pw:
|
||||
ratio = pw / pil.width
|
||||
pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
pil.save(buf, format="JPEG", quality=80)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
return f"data:image/jpeg;base64,{b64}", pil.width, pil.height
|
||||
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
|
||||
if pw:
|
||||
image = resize_down(image, pw)
|
||||
h, w = image.shape[:2]
|
||||
return encode_jpeg_data_uri(image, quality=80), w, h
|
||||
|
||||
try:
|
||||
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""Postprocessing template routes."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -198,6 +195,13 @@ async def test_pp_template(
|
||||
|
||||
raw_stream = chain["raw_stream"]
|
||||
|
||||
from wled_controller.utils.image_codec import (
|
||||
encode_jpeg_data_uri,
|
||||
load_image_bytes,
|
||||
load_image_file,
|
||||
thumbnail as make_thumbnail,
|
||||
)
|
||||
|
||||
if isinstance(raw_stream, StaticImagePictureSource):
|
||||
# Static image: load directly
|
||||
from pathlib import Path
|
||||
@@ -209,12 +213,12 @@ async def test_pp_template(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
image = load_image_bytes(resp.content)
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
image = load_image_file(path)
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -268,53 +272,37 @@ async def test_pp_template(
|
||||
if last_frame is None:
|
||||
raise RuntimeError("No frames captured during test")
|
||||
|
||||
if isinstance(last_frame.image, np.ndarray):
|
||||
pil_image = Image.fromarray(last_frame.image)
|
||||
else:
|
||||
if not isinstance(last_frame.image, np.ndarray):
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
image = last_frame.image
|
||||
|
||||
# Create thumbnail
|
||||
thumbnail_width = 640
|
||||
aspect_ratio = pil_image.height / pil_image.width
|
||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
thumb = make_thumbnail(image, 640)
|
||||
|
||||
# Apply postprocessing filters (expand filter_template references)
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||
if flat_filters:
|
||||
pool = ImagePool()
|
||||
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
def apply_filters(arr):
|
||||
for fi in flat_filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
return arr
|
||||
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
thumb = apply_filters(thumb)
|
||||
image = apply_filters(image)
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
|
||||
# Encode full-resolution image
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
# Encode as JPEG
|
||||
thumbnail_data_uri = encode_jpeg_data_uri(thumb, quality=85)
|
||||
full_data_uri = encode_jpeg_data_uri(image, quality=90)
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
||||
width, height = pil_image.size
|
||||
thumb_w, thumb_h = thumbnail.size
|
||||
height, width = image.shape[:2]
|
||||
thumb_h, thumb_w = thumb.shape[:2]
|
||||
|
||||
return TemplateTestResponse(
|
||||
full_capture=CaptureImage(
|
||||
|
||||
@@ -14,7 +14,7 @@ import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.auth import AuthRequired, is_auth_enabled
|
||||
from wled_controller.api.dependencies import (
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
@@ -45,8 +45,7 @@ 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
|
||||
|
||||
# Re-export STORE_MAP and load_external_url so existing callers still work
|
||||
from wled_controller.api.routes.backup import STORE_MAP # noqa: F401
|
||||
# Re-export load_external_url so existing callers still work
|
||||
from wled_controller.api.routes.system_settings import load_external_url # noqa: F401
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -107,6 +106,7 @@ async def health_check():
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
version=__version__,
|
||||
demo_mode=get_config().demo,
|
||||
auth_required=is_auth_enabled(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,14 @@ Extracted from system.py to keep files under 800 lines.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from 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 (
|
||||
ExternalUrlRequest,
|
||||
ExternalUrlResponse,
|
||||
@@ -22,6 +21,7 @@ from wled_controller.api.schemas.system import (
|
||||
MQTTSettingsResponse,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -33,21 +33,9 @@ router = APIRouter()
|
||||
# MQTT settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MQTT_SETTINGS_FILE: Path | None = None
|
||||
|
||||
|
||||
def _get_mqtt_settings_path() -> Path:
|
||||
global _MQTT_SETTINGS_FILE
|
||||
if _MQTT_SETTINGS_FILE is None:
|
||||
cfg = get_config()
|
||||
# Derive the data directory from any known storage file path
|
||||
data_dir = Path(cfg.storage.devices_file).parent
|
||||
_MQTT_SETTINGS_FILE = data_dir / "mqtt_settings.json"
|
||||
return _MQTT_SETTINGS_FILE
|
||||
|
||||
|
||||
def _load_mqtt_settings() -> dict:
|
||||
"""Load MQTT settings: YAML config defaults overridden by JSON overrides file."""
|
||||
def _load_mqtt_settings(db: Database) -> dict:
|
||||
"""Load MQTT settings: YAML config defaults overridden by DB settings."""
|
||||
cfg = get_config()
|
||||
defaults = {
|
||||
"enabled": cfg.mqtt.enabled,
|
||||
@@ -58,31 +46,20 @@ def _load_mqtt_settings() -> dict:
|
||||
"client_id": cfg.mqtt.client_id,
|
||||
"base_topic": cfg.mqtt.base_topic,
|
||||
}
|
||||
path = _get_mqtt_settings_path()
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
overrides = json.load(f)
|
||||
defaults.update(overrides)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load MQTT settings override file: {e}")
|
||||
overrides = db.get_setting("mqtt")
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
def _save_mqtt_settings(settings: dict) -> None:
|
||||
"""Persist MQTT settings to the JSON override file."""
|
||||
from wled_controller.utils import atomic_write_json
|
||||
atomic_write_json(_get_mqtt_settings_path(), settings)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/mqtt/settings",
|
||||
response_model=MQTTSettingsResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_mqtt_settings(_: AuthRequired):
|
||||
async def get_mqtt_settings(_: AuthRequired, db: Database = Depends(get_database)):
|
||||
"""Get current MQTT broker settings. Password is masked."""
|
||||
s = _load_mqtt_settings()
|
||||
s = _load_mqtt_settings(db)
|
||||
return MQTTSettingsResponse(
|
||||
enabled=s["enabled"],
|
||||
broker_host=s["broker_host"],
|
||||
@@ -99,9 +76,9 @@ async def get_mqtt_settings(_: AuthRequired):
|
||||
response_model=MQTTSettingsResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
|
||||
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()
|
||||
current = _load_mqtt_settings(db)
|
||||
|
||||
# If caller sends an empty password, keep the existing one
|
||||
password = body.password if body.password else current.get("password", "")
|
||||
@@ -115,7 +92,7 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
|
||||
"client_id": body.client_id,
|
||||
"base_topic": body.base_topic,
|
||||
}
|
||||
_save_mqtt_settings(new_settings)
|
||||
db.set_setting("mqtt", new_settings)
|
||||
logger.info("MQTT settings updated")
|
||||
|
||||
return MQTTSettingsResponse(
|
||||
@@ -133,44 +110,25 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest):
|
||||
# External URL setting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXTERNAL_URL_FILE: Path | None = None
|
||||
|
||||
|
||||
def _get_external_url_path() -> Path:
|
||||
global _EXTERNAL_URL_FILE
|
||||
if _EXTERNAL_URL_FILE is None:
|
||||
cfg = get_config()
|
||||
data_dir = Path(cfg.storage.devices_file).parent
|
||||
_EXTERNAL_URL_FILE = data_dir / "external_url.json"
|
||||
return _EXTERNAL_URL_FILE
|
||||
|
||||
|
||||
def load_external_url() -> str:
|
||||
def load_external_url(db: Database | None = None) -> str:
|
||||
"""Load the external URL setting. Returns empty string if not set."""
|
||||
path = _get_external_url_path()
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("external_url", "")
|
||||
except Exception:
|
||||
pass
|
||||
if db is None:
|
||||
from wled_controller.api.dependencies import get_database
|
||||
db = get_database()
|
||||
data = db.get_setting("external_url")
|
||||
if data:
|
||||
return data.get("external_url", "")
|
||||
return ""
|
||||
|
||||
|
||||
def _save_external_url(url: str) -> None:
|
||||
from wled_controller.utils import atomic_write_json
|
||||
atomic_write_json(_get_external_url_path(), {"external_url": url})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/system/external-url",
|
||||
response_model=ExternalUrlResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def get_external_url(_: AuthRequired):
|
||||
async def get_external_url(_: AuthRequired, db: Database = Depends(get_database)):
|
||||
"""Get the configured external base URL."""
|
||||
return ExternalUrlResponse(external_url=load_external_url())
|
||||
return ExternalUrlResponse(external_url=load_external_url(db))
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -178,10 +136,10 @@ async def get_external_url(_: AuthRequired):
|
||||
response_model=ExternalUrlResponse,
|
||||
tags=["System"],
|
||||
)
|
||||
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest):
|
||||
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("/")
|
||||
_save_external_url(url)
|
||||
db.set_setting("external_url", {"external_url": url})
|
||||
logger.info("External URL updated: %s", url or "(cleared)")
|
||||
return ExternalUrlResponse(external_url=url)
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Capture template, engine, and filter routes."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -320,38 +317,28 @@ def test_template(
|
||||
if last_frame is None:
|
||||
raise RuntimeError("No frames captured during test")
|
||||
|
||||
# Convert numpy array to PIL Image
|
||||
if isinstance(last_frame.image, np.ndarray):
|
||||
pil_image = Image.fromarray(last_frame.image)
|
||||
else:
|
||||
if not isinstance(last_frame.image, np.ndarray):
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
image = last_frame.image
|
||||
|
||||
from wled_controller.utils.image_codec import (
|
||||
encode_jpeg_data_uri,
|
||||
thumbnail as make_thumbnail,
|
||||
)
|
||||
|
||||
# Create thumbnail (640px wide, maintain aspect ratio)
|
||||
thumbnail_width = 640
|
||||
aspect_ratio = pil_image.height / pil_image.width
|
||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
thumb = make_thumbnail(image, 640)
|
||||
thumb_h, thumb_w = thumb.shape[:2]
|
||||
|
||||
# Encode thumbnail as JPEG
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
|
||||
# Encode full-resolution image as JPEG
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
# Encode as JPEG
|
||||
thumbnail_data_uri = encode_jpeg_data_uri(thumb, quality=85)
|
||||
full_data_uri = encode_jpeg_data_uri(image, quality=90)
|
||||
|
||||
# Calculate metrics
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
|
||||
|
||||
width, height = pil_image.size
|
||||
height, width = image.shape[:2]
|
||||
|
||||
return TemplateTestResponse(
|
||||
full_capture=CaptureImage(
|
||||
@@ -359,8 +346,8 @@ def test_template(
|
||||
full_image=full_data_uri,
|
||||
width=width,
|
||||
height=height,
|
||||
thumbnail_width=thumbnail_width,
|
||||
thumbnail_height=thumbnail_height,
|
||||
thumbnail_width=thumb_w,
|
||||
thumbnail_height=thumb_h,
|
||||
),
|
||||
border_extraction=None,
|
||||
performance=PerformanceMetrics(
|
||||
|
||||
81
server/src/wled_controller/api/routes/update.py
Normal file
81
server/src/wled_controller/api/routes/update.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""API routes for the auto-update system."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from wled_controller.api.dependencies import get_update_service
|
||||
from wled_controller.api.schemas.update import (
|
||||
DismissRequest,
|
||||
UpdateSettingsRequest,
|
||||
UpdateSettingsResponse,
|
||||
UpdateStatusResponse,
|
||||
)
|
||||
from wled_controller.core.update.update_service import UpdateService
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
|
||||
|
||||
|
||||
@router.get("/status", response_model=UpdateStatusResponse)
|
||||
async def get_update_status(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return service.get_status()
|
||||
|
||||
|
||||
@router.post("/check", response_model=UpdateStatusResponse)
|
||||
async def check_for_updates(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return await service.check_now()
|
||||
|
||||
|
||||
@router.post("/dismiss")
|
||||
async def dismiss_update(
|
||||
body: DismissRequest,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
service.dismiss(body.version)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_update(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
"""Download (if needed) and apply the available update."""
|
||||
status = service.get_status()
|
||||
if not status["has_update"]:
|
||||
return JSONResponse(status_code=400, content={"detail": "No update available"})
|
||||
if not status["can_auto_update"]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": f"Auto-update not supported for install type: {status['install_type']}"},
|
||||
)
|
||||
try:
|
||||
await service.apply_update()
|
||||
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@router.get("/settings", response_model=UpdateSettingsResponse)
|
||||
async def get_update_settings(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return service.get_settings()
|
||||
|
||||
|
||||
@router.put("/settings", response_model=UpdateSettingsResponse)
|
||||
async def update_update_settings(
|
||||
body: UpdateSettingsRequest,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return await service.update_settings(
|
||||
enabled=body.enabled,
|
||||
check_interval_hours=body.check_interval_hours,
|
||||
include_prerelease=body.include_prerelease,
|
||||
)
|
||||
157
server/src/wled_controller/api/routes/weather_sources.py
Normal file
157
server/src/wled_controller/api/routes/weather_sources.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Weather source routes: CRUD + test endpoint."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_weather_manager,
|
||||
get_weather_source_store,
|
||||
)
|
||||
from wled_controller.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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(source: WeatherSource) -> WeatherSourceResponse:
|
||||
d = source.to_dict()
|
||||
return WeatherSourceResponse(
|
||||
id=d["id"],
|
||||
name=d["name"],
|
||||
provider=d["provider"],
|
||||
provider_config=d.get("provider_config", {}),
|
||||
latitude=d["latitude"],
|
||||
longitude=d["longitude"],
|
||||
update_interval=d["update_interval"],
|
||||
description=d.get("description"),
|
||||
tags=d.get("tags", []),
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
sources = store.get_all_sources()
|
||||
return WeatherSourceListResponse(
|
||||
sources=[_to_response(s) for s in sources],
|
||||
count=len(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,
|
||||
store: WeatherSourceStore = Depends(get_weather_source_store),
|
||||
):
|
||||
try:
|
||||
source = store.create_source(
|
||||
name=data.name,
|
||||
provider=data.provider,
|
||||
provider_config=data.provider_config,
|
||||
latitude=data.latitude,
|
||||
longitude=data.longitude,
|
||||
update_interval=data.update_interval,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
fire_entity_event("weather_source", "created", source.id)
|
||||
return _to_response(source)
|
||||
|
||||
|
||||
@router.get("/api/v1/weather-sources/{source_id}", response_model=WeatherSourceResponse, tags=["Weather Sources"])
|
||||
async def get_weather_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: WeatherSourceStore = Depends(get_weather_source_store),
|
||||
):
|
||||
try:
|
||||
return _to_response(store.get_source(source_id))
|
||||
except EntityNotFoundError:
|
||||
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"])
|
||||
async def update_weather_source(
|
||||
source_id: str,
|
||||
data: WeatherSourceUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: WeatherSourceStore = Depends(get_weather_source_store),
|
||||
manager: WeatherManager = Depends(get_weather_manager),
|
||||
):
|
||||
try:
|
||||
source = store.update_source(
|
||||
source_id,
|
||||
name=data.name,
|
||||
provider=data.provider,
|
||||
provider_config=data.provider_config,
|
||||
latitude=data.latitude,
|
||||
longitude=data.longitude,
|
||||
update_interval=data.update_interval,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
manager.update_source(source_id)
|
||||
fire_entity_event("weather_source", "updated", source.id)
|
||||
return _to_response(source)
|
||||
|
||||
|
||||
@router.delete("/api/v1/weather-sources/{source_id}", status_code=204, tags=["Weather Sources"])
|
||||
async def delete_weather_source(
|
||||
source_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: WeatherSourceStore = Depends(get_weather_source_store),
|
||||
):
|
||||
try:
|
||||
store.delete_source(source_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
|
||||
fire_entity_event("weather_source", "deleted", source_id)
|
||||
|
||||
|
||||
@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,
|
||||
store: WeatherSourceStore = Depends(get_weather_source_store),
|
||||
manager: WeatherManager = Depends(get_weather_manager),
|
||||
):
|
||||
"""Force-fetch current weather and return the result."""
|
||||
try:
|
||||
store.get_source(source_id) # validate exists
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
|
||||
|
||||
data = manager.fetch_now(source_id)
|
||||
condition = WMO_CONDITION_NAMES.get(data.code, f"Unknown ({data.code})")
|
||||
return WeatherTestResponse(
|
||||
code=data.code,
|
||||
condition=condition,
|
||||
temperature=data.temperature,
|
||||
wind_speed=data.wind_speed,
|
||||
cloud_cover=data.cloud_cover,
|
||||
)
|
||||
@@ -10,14 +10,18 @@ class AudioSourceCreate(BaseModel):
|
||||
"""Request to create an audio source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["multichannel", "mono"] = Field(description="Source type")
|
||||
source_type: Literal["multichannel", "mono", "band_extract"] = Field(description="Source type")
|
||||
# multichannel fields
|
||||
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
# mono fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
# band_extract fields
|
||||
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
|
||||
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
@@ -29,8 +33,11 @@ class AudioSourceUpdate(BaseModel):
|
||||
device_index: Optional[int] = Field(None, description="Audio device index (-1 = default)")
|
||||
is_loopback: Optional[bool] = Field(None, description="True for system audio (WASAPI loopback)")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID")
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)", ge=20, le=20000)
|
||||
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)", ge=20, le=20000)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@@ -40,12 +47,15 @@ class AudioSourceResponse(BaseModel):
|
||||
|
||||
id: str = Field(description="Source ID")
|
||||
name: str = Field(description="Source name")
|
||||
source_type: str = Field(description="Source type: multichannel or mono")
|
||||
source_type: str = Field(description="Source type: multichannel, mono, or band_extract")
|
||||
device_index: Optional[int] = Field(None, description="Audio device index")
|
||||
is_loopback: Optional[bool] = Field(None, description="WASAPI loopback mode")
|
||||
audio_template_id: Optional[str] = Field(None, description="Audio capture template ID")
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID")
|
||||
audio_source_id: Optional[str] = Field(None, description="Parent audio source ID")
|
||||
channel: Optional[str] = Field(None, description="Channel: mono|left|right")
|
||||
band: Optional[str] = Field(None, description="Band preset: bass|mid|treble|custom")
|
||||
freq_low: Optional[float] = Field(None, description="Low frequency bound (Hz)")
|
||||
freq_high: Optional[float] = Field(None, description="High frequency bound (Hz)")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
|
||||
@@ -36,6 +36,9 @@ class CompositeLayer(BaseModel):
|
||||
enabled: bool = Field(default=True, description="Whether this layer is active")
|
||||
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
|
||||
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
|
||||
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
|
||||
end: int = Field(default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)")
|
||||
reverse: bool = Field(default=False, description="Reverse layer output within its range")
|
||||
|
||||
|
||||
class MappedZone(BaseModel):
|
||||
@@ -51,7 +54,7 @@ class ColorStripSourceCreate(BaseModel):
|
||||
"""Request to create a color strip source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type")
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed", "weather"] = Field(default="picture", description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||
@@ -64,11 +67,16 @@ class ColorStripSourceCreate(BaseModel):
|
||||
# color_cycle-type fields
|
||||
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
|
||||
# effect-type fields
|
||||
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
|
||||
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)")
|
||||
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference")
|
||||
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'")
|
||||
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
|
||||
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
|
||||
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)")
|
||||
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor/comet)")
|
||||
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
|
||||
# gradient entity reference (effect, gradient, audio types)
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)")
|
||||
# gradient-type easing
|
||||
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
|
||||
# composite-type fields
|
||||
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
|
||||
# mapped-type fields
|
||||
@@ -97,11 +105,17 @@ class ColorStripSourceCreate(BaseModel):
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
|
||||
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||
# weather-type fields
|
||||
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
|
||||
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
@@ -123,11 +137,16 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
# color_cycle-type fields
|
||||
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
|
||||
# effect-type fields
|
||||
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
|
||||
effect_type: Optional[str] = Field(None, description="Effect algorithm")
|
||||
palette: Optional[str] = Field(None, description="Named palette")
|
||||
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
|
||||
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
|
||||
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
|
||||
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
|
||||
# gradient entity reference (effect, gradient, audio types)
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID (overrides palette/inline stops)")
|
||||
# gradient-type easing
|
||||
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
|
||||
# composite-type fields
|
||||
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
|
||||
# mapped-type fields
|
||||
@@ -156,11 +175,17 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
|
||||
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
|
||||
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
|
||||
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
|
||||
# weather-type fields
|
||||
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID (for weather type)")
|
||||
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength (0.0-1.0)", ge=0.0, le=1.0)
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -189,6 +214,10 @@ class ColorStripSourceResponse(BaseModel):
|
||||
intensity: Optional[float] = Field(None, description="Effect intensity")
|
||||
scale: Optional[float] = Field(None, description="Spatial scale")
|
||||
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
|
||||
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
|
||||
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
|
||||
# gradient-type easing
|
||||
easing: Optional[str] = Field(None, description="Gradient interpolation easing")
|
||||
# composite-type fields
|
||||
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
|
||||
# mapped-type fields
|
||||
@@ -217,11 +246,17 @@ class ColorStripSourceResponse(BaseModel):
|
||||
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
|
||||
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
|
||||
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
|
||||
longitude: Optional[float] = Field(None, description="Longitude for daylight timing")
|
||||
# candlelight-type fields
|
||||
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
|
||||
wind_strength: Optional[float] = Field(None, description="Wind simulation strength")
|
||||
candle_type: Optional[str] = Field(None, description="Candle type preset")
|
||||
# processed-type fields
|
||||
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
|
||||
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")
|
||||
# weather-type fields
|
||||
weather_source_id: Optional[str] = Field(None, description="Weather source entity ID")
|
||||
temperature_influence: Optional[float] = Field(None, description="Temperature color shift strength")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
51
server/src/wled_controller/api/schemas/gradients.py
Normal file
51
server/src/wled_controller/api/schemas/gradients.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Gradient schemas (CRUD)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GradientStopSchema(BaseModel):
|
||||
"""A single gradient color stop."""
|
||||
|
||||
position: float = Field(description="Position along gradient (0.0-1.0)", ge=0.0, le=1.0)
|
||||
color: List[int] = Field(description="RGB color [R, G, B]", min_length=3, max_length=3)
|
||||
|
||||
|
||||
class GradientCreate(BaseModel):
|
||||
"""Request to create a gradient."""
|
||||
|
||||
name: str = Field(description="Gradient name", min_length=1, max_length=100)
|
||||
stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class GradientUpdate(BaseModel):
|
||||
"""Request to update a gradient."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Gradient name", min_length=1, max_length=100)
|
||||
stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GradientResponse(BaseModel):
|
||||
"""Gradient response."""
|
||||
|
||||
id: str = Field(description="Gradient ID")
|
||||
name: str = Field(description="Gradient name")
|
||||
stops: List[GradientStopSchema] = Field(description="Color stops")
|
||||
is_builtin: bool = Field(description="Whether this is a built-in gradient")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class GradientListResponse(BaseModel):
|
||||
"""List of gradients."""
|
||||
|
||||
gradients: List[GradientResponse] = Field(description="List of gradients")
|
||||
count: int = Field(description="Number of gradients")
|
||||
@@ -13,6 +13,7 @@ class HealthResponse(BaseModel):
|
||||
timestamp: datetime = Field(description="Current server time")
|
||||
version: str = Field(description="Application version")
|
||||
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||
auth_required: bool = Field(default=True, description="Whether API key authentication is required")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
@@ -74,12 +75,9 @@ class PerformanceResponse(BaseModel):
|
||||
|
||||
|
||||
class RestoreResponse(BaseModel):
|
||||
"""Response after restoring configuration backup."""
|
||||
"""Response after restoring database backup."""
|
||||
|
||||
status: str = Field(description="Status of restore operation")
|
||||
stores_written: int = Field(description="Number of stores successfully written")
|
||||
stores_total: int = Field(description="Total number of known stores")
|
||||
missing_stores: List[str] = Field(default_factory=list, description="Store keys not found in backup")
|
||||
restart_scheduled: bool = Field(description="Whether server restart was scheduled")
|
||||
message: str = Field(description="Human-readable status message")
|
||||
|
||||
|
||||
44
server/src/wled_controller/api/schemas/update.py
Normal file
44
server/src/wled_controller/api/schemas/update.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Pydantic schemas for the update API."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UpdateReleaseInfo(BaseModel):
|
||||
version: str
|
||||
tag: str
|
||||
name: str
|
||||
body: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
|
||||
|
||||
class UpdateStatusResponse(BaseModel):
|
||||
current_version: str
|
||||
has_update: bool
|
||||
checking: bool
|
||||
last_check: float | None
|
||||
last_error: str | None
|
||||
releases_url: str
|
||||
install_type: str
|
||||
can_auto_update: bool
|
||||
downloading: bool
|
||||
download_progress: float
|
||||
applying: bool
|
||||
release: UpdateReleaseInfo | None
|
||||
dismissed_version: str
|
||||
|
||||
|
||||
class UpdateSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
check_interval_hours: float
|
||||
include_prerelease: bool
|
||||
|
||||
|
||||
class UpdateSettingsRequest(BaseModel):
|
||||
enabled: bool
|
||||
check_interval_hours: float = Field(ge=0.5, le=168)
|
||||
include_prerelease: bool
|
||||
|
||||
|
||||
class DismissRequest(BaseModel):
|
||||
version: str
|
||||
65
server/src/wled_controller/api/schemas/weather_sources.py
Normal file
65
server/src/wled_controller/api/schemas/weather_sources.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Weather source schemas (CRUD)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WeatherSourceCreate(BaseModel):
|
||||
"""Request to create a weather source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
provider: Literal["open_meteo"] = Field(default="open_meteo", description="Weather data provider")
|
||||
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
|
||||
latitude: float = Field(default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
longitude: float = Field(default=0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0)
|
||||
update_interval: int = Field(default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class WeatherSourceUpdate(BaseModel):
|
||||
"""Request to update a weather source."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
provider: Optional[Literal["open_meteo"]] = Field(None, description="Weather data provider")
|
||||
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
|
||||
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
|
||||
longitude: Optional[float] = Field(None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0)
|
||||
update_interval: Optional[int] = Field(None, description="API poll interval in seconds (60-3600)", ge=60, le=3600)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class WeatherSourceResponse(BaseModel):
|
||||
"""Weather source response."""
|
||||
|
||||
id: str = Field(description="Source ID")
|
||||
name: str = Field(description="Source name")
|
||||
provider: str = Field(description="Weather data provider")
|
||||
provider_config: Dict = Field(default_factory=dict, description="Provider-specific configuration")
|
||||
latitude: float = Field(description="Geographic latitude")
|
||||
longitude: float = Field(description="Geographic longitude")
|
||||
update_interval: int = Field(description="API poll interval in seconds")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class WeatherSourceListResponse(BaseModel):
|
||||
"""List of weather sources."""
|
||||
|
||||
sources: List[WeatherSourceResponse] = Field(description="List of weather sources")
|
||||
count: int = Field(description="Number of sources")
|
||||
|
||||
|
||||
class WeatherTestResponse(BaseModel):
|
||||
"""Weather test/fetch result."""
|
||||
|
||||
code: int = Field(description="WMO weather code")
|
||||
condition: str = Field(description="Human-readable condition name")
|
||||
temperature: float = Field(description="Temperature in Celsius")
|
||||
wind_speed: float = Field(description="Wind speed in km/h")
|
||||
cloud_cover: int = Field(description="Cloud cover percentage (0-100)")
|
||||
@@ -21,26 +21,13 @@ class ServerConfig(BaseSettings):
|
||||
class AuthConfig(BaseSettings):
|
||||
"""Authentication configuration."""
|
||||
|
||||
api_keys: dict[str, str] = {} # label: key mapping (required for security)
|
||||
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
||||
|
||||
|
||||
class StorageConfig(BaseSettings):
|
||||
"""Storage configuration."""
|
||||
|
||||
devices_file: str = "data/devices.json"
|
||||
templates_file: str = "data/capture_templates.json"
|
||||
postprocessing_templates_file: str = "data/postprocessing_templates.json"
|
||||
picture_sources_file: str = "data/picture_sources.json"
|
||||
output_targets_file: str = "data/output_targets.json"
|
||||
pattern_templates_file: str = "data/pattern_templates.json"
|
||||
color_strip_sources_file: str = "data/color_strip_sources.json"
|
||||
audio_sources_file: str = "data/audio_sources.json"
|
||||
audio_templates_file: str = "data/audio_templates.json"
|
||||
value_sources_file: str = "data/value_sources.json"
|
||||
automations_file: str = "data/automations.json"
|
||||
scene_presets_file: str = "data/scene_presets.json"
|
||||
color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json"
|
||||
sync_clocks_file: str = "data/sync_clocks.json"
|
||||
database_file: str = "data/ledgrab.db"
|
||||
|
||||
|
||||
class MQTTConfig(BaseSettings):
|
||||
|
||||
63
server/src/wled_controller/core/audio/band_filter.py
Normal file
63
server/src/wled_controller/core/audio/band_filter.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Frequency band filtering for audio spectrum data.
|
||||
|
||||
Computes masks that select specific frequency ranges from the 64-band
|
||||
log-spaced spectrum, and applies them to filter spectrum + RMS data.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS
|
||||
|
||||
|
||||
def compute_band_mask(freq_low: float, freq_high: float) -> np.ndarray:
|
||||
"""Compute a boolean-style float mask for the 64 log-spaced spectrum bands.
|
||||
|
||||
Each band's center frequency is computed using the same log-spacing as
|
||||
analysis._build_log_bands (20 Hz to 20 kHz). Bands whose center falls
|
||||
within [freq_low, freq_high] get mask=1.0, others get 0.0.
|
||||
|
||||
Returns:
|
||||
float32 array of shape (NUM_BANDS,) with 1.0/0.0 values.
|
||||
"""
|
||||
min_freq = 20.0
|
||||
max_freq = 20000.0
|
||||
log_min = math.log10(min_freq)
|
||||
log_max = math.log10(max_freq)
|
||||
|
||||
# Band edge frequencies (NUM_BANDS + 1 edges)
|
||||
edges = np.logspace(log_min, log_max, NUM_BANDS + 1)
|
||||
|
||||
mask = np.zeros(NUM_BANDS, dtype=np.float32)
|
||||
for i in range(NUM_BANDS):
|
||||
center = math.sqrt(edges[i] * edges[i + 1]) # geometric mean
|
||||
if freq_low <= center <= freq_high:
|
||||
mask[i] = 1.0
|
||||
return mask
|
||||
|
||||
|
||||
def apply_band_filter(
|
||||
spectrum: np.ndarray,
|
||||
rms: float,
|
||||
mask: np.ndarray,
|
||||
) -> Tuple[np.ndarray, float]:
|
||||
"""Apply a band mask to spectrum data, returning filtered spectrum and RMS.
|
||||
|
||||
Args:
|
||||
spectrum: float32 array of shape (NUM_BANDS,) — normalized 0-1 amplitudes.
|
||||
rms: Original RMS value from the full spectrum.
|
||||
mask: float32 array from compute_band_mask().
|
||||
|
||||
Returns:
|
||||
(filtered_spectrum, filtered_rms) — spectrum with out-of-band zeroed,
|
||||
RMS recomputed from in-band values only.
|
||||
"""
|
||||
filtered = spectrum * mask
|
||||
active = mask > 0
|
||||
if active.any():
|
||||
filtered_rms = float(np.sqrt(np.mean(filtered[active] ** 2)))
|
||||
else:
|
||||
filtered_rms = 0.0
|
||||
return filtered, filtered_rms
|
||||
@@ -205,21 +205,20 @@ class AutomationEngine:
|
||||
fullscreen_procs: Set[str],
|
||||
idle_seconds: Optional[float], display_state: Optional[str],
|
||||
) -> bool:
|
||||
if isinstance(condition, (AlwaysCondition, StartupCondition)):
|
||||
return True
|
||||
if isinstance(condition, ApplicationCondition):
|
||||
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
||||
if isinstance(condition, TimeOfDayCondition):
|
||||
return self._evaluate_time_of_day(condition)
|
||||
if isinstance(condition, SystemIdleCondition):
|
||||
return self._evaluate_idle(condition, idle_seconds)
|
||||
if isinstance(condition, DisplayStateCondition):
|
||||
return self._evaluate_display_state(condition, display_state)
|
||||
if isinstance(condition, MQTTCondition):
|
||||
return self._evaluate_mqtt(condition)
|
||||
if isinstance(condition, WebhookCondition):
|
||||
return self._webhook_states.get(condition.token, False)
|
||||
return False
|
||||
dispatch = {
|
||||
AlwaysCondition: lambda c: True,
|
||||
StartupCondition: lambda c: True,
|
||||
ApplicationCondition: lambda c: self._evaluate_app_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs),
|
||||
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
|
||||
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
|
||||
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
|
||||
MQTTCondition: lambda c: self._evaluate_mqtt(c),
|
||||
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
|
||||
}
|
||||
handler = dispatch.get(type(condition))
|
||||
if handler is None:
|
||||
return False
|
||||
return handler(condition)
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
|
||||
@@ -253,16 +252,18 @@ class AutomationEngine:
|
||||
value = self._mqtt_service.get_last_value(condition.topic)
|
||||
if value is None:
|
||||
return False
|
||||
if condition.match_mode == "exact":
|
||||
return value == condition.payload
|
||||
if condition.match_mode == "contains":
|
||||
return condition.payload in value
|
||||
if condition.match_mode == "regex":
|
||||
try:
|
||||
return bool(re.search(condition.payload, value))
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
matchers = {
|
||||
"exact": lambda: value == condition.payload,
|
||||
"contains": lambda: condition.payload in value,
|
||||
"regex": lambda: bool(re.search(condition.payload, value)),
|
||||
}
|
||||
matcher = matchers.get(condition.match_mode)
|
||||
if matcher is None:
|
||||
return False
|
||||
try:
|
||||
return matcher()
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
def _evaluate_app_condition(
|
||||
self,
|
||||
@@ -277,19 +278,21 @@ class AutomationEngine:
|
||||
|
||||
apps_lower = [a.lower() for a in condition.apps]
|
||||
|
||||
if condition.match_type == "fullscreen":
|
||||
return any(app in fullscreen_procs for app in apps_lower)
|
||||
|
||||
if condition.match_type == "topmost_fullscreen":
|
||||
if topmost_proc is None or not topmost_fullscreen:
|
||||
return False
|
||||
return any(app == topmost_proc for app in apps_lower)
|
||||
|
||||
if condition.match_type == "topmost":
|
||||
if topmost_proc is None:
|
||||
return False
|
||||
return any(app == topmost_proc for app in apps_lower)
|
||||
|
||||
match_handlers = {
|
||||
"fullscreen": lambda: any(app in fullscreen_procs for app in apps_lower),
|
||||
"topmost_fullscreen": lambda: (
|
||||
topmost_proc is not None
|
||||
and topmost_fullscreen
|
||||
and any(app == topmost_proc for app in apps_lower)
|
||||
),
|
||||
"topmost": lambda: (
|
||||
topmost_proc is not None
|
||||
and any(app == topmost_proc for app in apps_lower)
|
||||
),
|
||||
}
|
||||
handler = match_handlers.get(condition.match_type)
|
||||
if handler is not None:
|
||||
return handler()
|
||||
# Default: "running"
|
||||
return any(app in running_procs for app in apps_lower)
|
||||
|
||||
@@ -352,38 +355,10 @@ class AutomationEngine:
|
||||
deactivation_mode = automation.deactivation_mode if automation else "none"
|
||||
|
||||
if deactivation_mode == "revert":
|
||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||
if snapshot and self._target_store:
|
||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||
status, errors = await apply_scene_state(
|
||||
snapshot, self._target_store, self._manager,
|
||||
)
|
||||
if errors:
|
||||
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
|
||||
else:
|
||||
logger.warning(f"Automation {automation_id}: no snapshot available for revert")
|
||||
|
||||
await self._deactivate_revert(automation_id)
|
||||
elif deactivation_mode == "fallback_scene":
|
||||
fallback_id = automation.deactivation_scene_preset_id if automation else None
|
||||
if fallback_id and self._scene_preset_store and self._target_store:
|
||||
try:
|
||||
fallback = self._scene_preset_store.get_preset(fallback_id)
|
||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||
status, errors = await apply_scene_state(
|
||||
fallback, self._target_store, self._manager,
|
||||
)
|
||||
if errors:
|
||||
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
|
||||
except ValueError:
|
||||
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
||||
await self._deactivate_fallback(automation_id, automation)
|
||||
else:
|
||||
# "none" mode — just clear active state
|
||||
logger.info(f"Automation {automation_id} deactivated")
|
||||
|
||||
self._last_deactivated[automation_id] = datetime.now(timezone.utc)
|
||||
@@ -391,6 +366,40 @@ class AutomationEngine:
|
||||
# Clean up any leftover snapshot
|
||||
self._pre_activation_snapshots.pop(automation_id, None)
|
||||
|
||||
async def _deactivate_revert(self, automation_id: str) -> None:
|
||||
"""Revert to pre-activation snapshot."""
|
||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||
if snapshot and self._target_store:
|
||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||
status, errors = await apply_scene_state(
|
||||
snapshot, self._target_store, self._manager,
|
||||
)
|
||||
if errors:
|
||||
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
|
||||
else:
|
||||
logger.warning(f"Automation {automation_id}: no snapshot available for revert")
|
||||
|
||||
async def _deactivate_fallback(self, automation_id: str, automation) -> None:
|
||||
"""Activate fallback scene on deactivation."""
|
||||
fallback_id = automation.deactivation_scene_preset_id if automation else None
|
||||
if fallback_id and self._scene_preset_store and self._target_store:
|
||||
try:
|
||||
fallback = self._scene_preset_store.get_preset(fallback_id)
|
||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||
status, errors = await apply_scene_state(
|
||||
fallback, self._target_store, self._manager,
|
||||
)
|
||||
if errors:
|
||||
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
|
||||
except ValueError:
|
||||
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
||||
|
||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||
try:
|
||||
self._manager.fire_event({
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Auto-backup engine — periodic background backups of all configuration stores."""
|
||||
"""Auto-backup engine — periodic SQLite snapshot backups."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -18,21 +17,22 @@ DEFAULT_SETTINGS = {
|
||||
"max_backups": 10,
|
||||
}
|
||||
|
||||
# Skip the immediate-on-start backup if a recent backup exists within this window.
|
||||
_STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5)
|
||||
|
||||
_BACKUP_EXT = ".db"
|
||||
|
||||
|
||||
class AutoBackupEngine:
|
||||
"""Creates periodic backups of all configuration stores."""
|
||||
"""Creates periodic SQLite snapshot backups of the database."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings_path: Path,
|
||||
backup_dir: Path,
|
||||
store_map: Dict[str, str],
|
||||
storage_config: Any,
|
||||
db: Database,
|
||||
):
|
||||
self._settings_path = Path(settings_path)
|
||||
self._backup_dir = Path(backup_dir)
|
||||
self._store_map = store_map
|
||||
self._storage_config = storage_config
|
||||
self._db = db
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._last_backup_time: Optional[datetime] = None
|
||||
|
||||
@@ -42,17 +42,13 @@ class AutoBackupEngine:
|
||||
# ─── Settings persistence ──────────────────────────────────
|
||||
|
||||
def _load_settings(self) -> dict:
|
||||
if self._settings_path.exists():
|
||||
try:
|
||||
with open(self._settings_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return {**DEFAULT_SETTINGS, **data}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load auto-backup settings: {e}")
|
||||
data = self._db.get_setting("auto_backup")
|
||||
if data:
|
||||
return {**DEFAULT_SETTINGS, **data}
|
||||
return dict(DEFAULT_SETTINGS)
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
atomic_write_json(self._settings_path, {
|
||||
self._db.set_setting("auto_backup", {
|
||||
"enabled": self._settings["enabled"],
|
||||
"interval_hours": self._settings["interval_hours"],
|
||||
"max_backups": self._settings["max_backups"],
|
||||
@@ -83,11 +79,25 @@ class AutoBackupEngine:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
def _most_recent_backup_age(self) -> timedelta | None:
|
||||
"""Return the age of the newest backup file, or None if no backups exist."""
|
||||
files = list(self._backup_dir.glob(f"*{_BACKUP_EXT}"))
|
||||
if not files:
|
||||
return None
|
||||
newest = max(files, key=lambda p: p.stat().st_mtime)
|
||||
mtime = datetime.fromtimestamp(newest.stat().st_mtime, tz=timezone.utc)
|
||||
return datetime.now(timezone.utc) - mtime
|
||||
|
||||
async def _backup_loop(self) -> None:
|
||||
try:
|
||||
# Perform first backup immediately on start
|
||||
await self._perform_backup()
|
||||
self._prune_old_backups()
|
||||
age = self._most_recent_backup_age()
|
||||
if age is None or age > _STARTUP_BACKUP_COOLDOWN:
|
||||
await self._perform_backup()
|
||||
self._prune_old_backups()
|
||||
else:
|
||||
logger.info(
|
||||
f"Skipping startup backup — most recent is only {age.total_seconds():.0f}s old"
|
||||
)
|
||||
|
||||
interval_secs = self._settings["interval_hours"] * 3600
|
||||
while True:
|
||||
@@ -103,45 +113,22 @@ class AutoBackupEngine:
|
||||
# ─── Backup operations ─────────────────────────────────────
|
||||
|
||||
async def _perform_backup(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._perform_backup_sync)
|
||||
await asyncio.to_thread(self._perform_backup_sync)
|
||||
|
||||
def _perform_backup_sync(self) -> None:
|
||||
stores = {}
|
||||
for store_key, config_attr in self._store_map.items():
|
||||
file_path = Path(getattr(self._storage_config, config_attr))
|
||||
if file_path.exists():
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
stores[store_key] = json.load(f)
|
||||
else:
|
||||
stores[store_key] = {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
backup = {
|
||||
"meta": {
|
||||
"format": "ledgrab-backup",
|
||||
"format_version": 1,
|
||||
"app_version": __version__,
|
||||
"created_at": now.isoformat(),
|
||||
"store_count": len(stores),
|
||||
"auto_backup": True,
|
||||
},
|
||||
"stores": stores,
|
||||
}
|
||||
|
||||
timestamp = now.strftime("%Y-%m-%dT%H%M%S")
|
||||
filename = f"ledgrab-autobackup-{timestamp}.json"
|
||||
filename = f"ledgrab-backup-{timestamp}{_BACKUP_EXT}"
|
||||
file_path = self._backup_dir / filename
|
||||
|
||||
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
self._db.backup_to(file_path)
|
||||
|
||||
self._last_backup_time = now
|
||||
logger.info(f"Auto-backup created: {filename}")
|
||||
logger.info(f"Backup created: {filename}")
|
||||
|
||||
def _prune_old_backups(self) -> None:
|
||||
max_backups = self._settings["max_backups"]
|
||||
files = sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
||||
files = sorted(self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime)
|
||||
excess = len(files) - max_backups
|
||||
if excess > 0:
|
||||
for f in files[:excess]:
|
||||
@@ -156,7 +143,6 @@ class AutoBackupEngine:
|
||||
def get_settings(self) -> dict:
|
||||
next_backup = None
|
||||
if self._settings["enabled"] and self._last_backup_time:
|
||||
from datetime import timedelta
|
||||
next_backup = (
|
||||
self._last_backup_time + timedelta(hours=self._settings["interval_hours"])
|
||||
).isoformat()
|
||||
@@ -175,7 +161,6 @@ class AutoBackupEngine:
|
||||
self._settings["max_backups"] = max_backups
|
||||
self._save_settings()
|
||||
|
||||
# Restart or stop the loop
|
||||
if enabled:
|
||||
self._start_loop()
|
||||
logger.info(
|
||||
@@ -185,14 +170,12 @@ class AutoBackupEngine:
|
||||
self._cancel_loop()
|
||||
logger.info("Auto-backup disabled")
|
||||
|
||||
# Prune if max_backups was reduced
|
||||
self._prune_old_backups()
|
||||
|
||||
return self.get_settings()
|
||||
|
||||
def list_backups(self) -> List[dict]:
|
||||
backups = []
|
||||
for f in sorted(self._backup_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
for f in sorted(self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
stat = f.stat()
|
||||
backups.append({
|
||||
"filename": f.name,
|
||||
@@ -206,7 +189,6 @@ class AutoBackupEngine:
|
||||
if not filename or os.sep in filename or "/" in filename or ".." in filename:
|
||||
raise ValueError("Invalid filename")
|
||||
target = (self._backup_dir / filename).resolve()
|
||||
# Ensure resolved path is still inside the backup directory
|
||||
if not target.is_relative_to(self._backup_dir.resolve()):
|
||||
raise ValueError("Invalid filename")
|
||||
return target
|
||||
@@ -215,7 +197,6 @@ class AutoBackupEngine:
|
||||
"""Manually trigger a backup and prune old ones. Returns the created backup info."""
|
||||
await self._perform_backup()
|
||||
self._prune_old_backups()
|
||||
# Return the most recent backup entry
|
||||
backups = self.list_backups()
|
||||
return backups[0] if backups else {}
|
||||
|
||||
|
||||
@@ -668,14 +668,20 @@ def create_pixel_mapper(
|
||||
return PixelMapper(calibration, interpolation_mode)
|
||||
|
||||
|
||||
def create_default_calibration(led_count: int) -> CalibrationConfig:
|
||||
def create_default_calibration(
|
||||
led_count: int,
|
||||
aspect_width: int = 16,
|
||||
aspect_height: int = 9,
|
||||
) -> CalibrationConfig:
|
||||
"""Create a default calibration for a rectangular screen.
|
||||
|
||||
Assumes LEDs are evenly distributed around the screen edges in clockwise order
|
||||
starting from bottom-left.
|
||||
Distributes LEDs proportionally to the screen aspect ratio so that
|
||||
horizontal and vertical edges have equal LED density.
|
||||
|
||||
Args:
|
||||
led_count: Total number of LEDs
|
||||
aspect_width: Screen width component of the aspect ratio (default 16)
|
||||
aspect_height: Screen height component of the aspect ratio (default 9)
|
||||
|
||||
Returns:
|
||||
Default calibration configuration
|
||||
@@ -683,15 +689,48 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
|
||||
if led_count < 4:
|
||||
raise ValueError("Need at least 4 LEDs for default calibration")
|
||||
|
||||
# Distribute LEDs evenly across 4 edges
|
||||
leds_per_edge = led_count // 4
|
||||
remainder = led_count % 4
|
||||
# Distribute LEDs proportionally to aspect ratio (same density per edge)
|
||||
perimeter = 2 * (aspect_width + aspect_height)
|
||||
h_frac = aspect_width / perimeter # fraction for each horizontal edge
|
||||
v_frac = aspect_height / perimeter # fraction for each vertical edge
|
||||
|
||||
# Distribute remainder to longer edges (bottom and top)
|
||||
bottom_count = leds_per_edge + (1 if remainder > 0 else 0)
|
||||
right_count = leds_per_edge
|
||||
top_count = leds_per_edge + (1 if remainder > 1 else 0)
|
||||
left_count = leds_per_edge + (1 if remainder > 2 else 0)
|
||||
# Float counts, then round so total == led_count
|
||||
raw_h = led_count * h_frac
|
||||
raw_v = led_count * v_frac
|
||||
bottom_count = round(raw_h)
|
||||
top_count = round(raw_h)
|
||||
right_count = round(raw_v)
|
||||
left_count = round(raw_v)
|
||||
|
||||
# Fix rounding error
|
||||
diff = led_count - (bottom_count + top_count + right_count + left_count)
|
||||
# Distribute remainder to horizontal edges first (longer edges)
|
||||
if diff > 0:
|
||||
bottom_count += 1
|
||||
diff -= 1
|
||||
if diff > 0:
|
||||
top_count += 1
|
||||
diff -= 1
|
||||
if diff > 0:
|
||||
right_count += 1
|
||||
diff -= 1
|
||||
if diff > 0:
|
||||
left_count += 1
|
||||
diff -= 1
|
||||
# If we over-counted, remove from shorter edges first
|
||||
if diff < 0:
|
||||
left_count += diff # diff is negative
|
||||
diff = 0
|
||||
if left_count < 0:
|
||||
diff = left_count
|
||||
left_count = 0
|
||||
right_count += diff
|
||||
|
||||
# Ensure each edge has at least 1 LED
|
||||
bottom_count = max(1, bottom_count)
|
||||
top_count = max(1, top_count)
|
||||
right_count = max(1, right_count)
|
||||
left_count = max(1, left_count)
|
||||
|
||||
config = CalibrationConfig(
|
||||
layout="clockwise",
|
||||
@@ -703,7 +742,8 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created default calibration for {led_count} LEDs: "
|
||||
f"Created default calibration for {led_count} LEDs "
|
||||
f"(aspect {aspect_width}:{aspect_height}): "
|
||||
f"bottom={bottom_count}, right={right_count}, "
|
||||
f"top={top_count}, left={left_count}"
|
||||
)
|
||||
|
||||
@@ -12,16 +12,23 @@ from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine, DXcam
|
||||
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
|
||||
from wled_controller.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
|
||||
from wled_controller.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
||||
from wled_controller.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
|
||||
from wled_controller.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
|
||||
|
||||
# Camera engine requires OpenCV — optional dependency
|
||||
try:
|
||||
from wled_controller.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
|
||||
_has_camera = True
|
||||
except ImportError:
|
||||
_has_camera = False
|
||||
|
||||
# Auto-register available engines
|
||||
EngineRegistry.register(MSSEngine)
|
||||
EngineRegistry.register(DXcamEngine)
|
||||
EngineRegistry.register(BetterCamEngine)
|
||||
EngineRegistry.register(WGCEngine)
|
||||
EngineRegistry.register(ScrcpyEngine)
|
||||
EngineRegistry.register(CameraEngine)
|
||||
if _has_camera:
|
||||
EngineRegistry.register(CameraEngine)
|
||||
EngineRegistry.register(DemoCaptureEngine)
|
||||
|
||||
__all__ = [
|
||||
@@ -40,8 +47,9 @@ __all__ = [
|
||||
"WGCCaptureStream",
|
||||
"ScrcpyEngine",
|
||||
"ScrcpyCaptureStream",
|
||||
"CameraEngine",
|
||||
"CameraCaptureStream",
|
||||
"DemoCaptureEngine",
|
||||
"DemoCaptureStream",
|
||||
]
|
||||
|
||||
if _has_camera:
|
||||
__all__ += ["CameraEngine", "CameraCaptureStream"]
|
||||
|
||||
@@ -12,7 +12,6 @@ Prerequisites (system binaries, NOT Python packages):
|
||||
- adb (bundled with scrcpy, or Android SDK Platform-Tools)
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -22,7 +21,8 @@ import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.utils.image_codec import load_image_bytes
|
||||
|
||||
from wled_controller.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
@@ -144,8 +144,7 @@ def _screencap_once(adb: str, serial: str) -> Optional[np.ndarray]:
|
||||
if result.returncode != 0 or len(result.stdout) < 100:
|
||||
return None
|
||||
|
||||
img = Image.open(io.BytesIO(result.stdout))
|
||||
return np.asarray(img.convert("RGB"))
|
||||
return load_image_bytes(result.stdout)
|
||||
except Exception as e:
|
||||
logger.debug(f"screencap failed for {serial}: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Seed data generator for demo mode.
|
||||
|
||||
Populates the demo data directory with sample entities on first run,
|
||||
Populates the demo SQLite database with sample entities on first run,
|
||||
giving new users a realistic out-of-the-box experience without needing
|
||||
real hardware.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.config import StorageConfig
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -50,63 +49,48 @@ _SCENE_ID = "scene_demo0001"
|
||||
_NOW = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _write_store(path: Path, json_key: str, items: dict) -> None:
|
||||
"""Write a store JSON file with version wrapper."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
json_key: items,
|
||||
}
|
||||
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
logger.info(f"Seeded {len(items)} {json_key} -> {path}")
|
||||
def _insert_entities(db: Database, table: str, items: dict) -> None:
|
||||
"""Insert entity dicts into a SQLite table."""
|
||||
rows = []
|
||||
for entity_id, entity_data in items.items():
|
||||
name = entity_data.get("name", "")
|
||||
data_json = json.dumps(entity_data, ensure_ascii=False)
|
||||
rows.append((entity_id, name, data_json))
|
||||
if rows:
|
||||
db.bulk_insert(table, rows)
|
||||
logger.info(f"Seeded {len(rows)} entities into {table}")
|
||||
|
||||
|
||||
def _has_data(storage_config: StorageConfig) -> bool:
|
||||
"""Check if any demo store file already has entities."""
|
||||
for field_name in storage_config.model_fields:
|
||||
value = getattr(storage_config, field_name)
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
p = Path(value)
|
||||
if p.exists() and p.stat().st_size > 20:
|
||||
# File exists and is non-trivial — check if it has entities
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
for key, val in raw.items():
|
||||
if key != "version" and isinstance(val, dict) and val:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
def seed_demo_data(db: Database) -> None:
|
||||
"""Populate demo database with sample entities.
|
||||
|
||||
|
||||
def seed_demo_data(storage_config: StorageConfig) -> None:
|
||||
"""Populate demo data directory with sample entities.
|
||||
|
||||
Only runs when the demo data directory is empty (no existing entities).
|
||||
Only runs when the database has no entities in any table.
|
||||
Must be called BEFORE store constructors run so they load the seeded data.
|
||||
"""
|
||||
if _has_data(storage_config):
|
||||
logger.info("Demo data already exists — skipping seed")
|
||||
return
|
||||
# Check if any table already has data
|
||||
for table in ["devices", "output_targets", "color_strip_sources",
|
||||
"picture_sources", "audio_sources", "scene_presets"]:
|
||||
if db.table_exists_with_data(table):
|
||||
logger.info("Demo data already exists — skipping seed")
|
||||
return
|
||||
|
||||
logger.info("Seeding demo data for first-run experience")
|
||||
|
||||
_seed_devices(Path(storage_config.devices_file))
|
||||
_seed_capture_templates(Path(storage_config.templates_file))
|
||||
_seed_output_targets(Path(storage_config.output_targets_file))
|
||||
_seed_picture_sources(Path(storage_config.picture_sources_file))
|
||||
_seed_color_strip_sources(Path(storage_config.color_strip_sources_file))
|
||||
_seed_audio_sources(Path(storage_config.audio_sources_file))
|
||||
_seed_scene_presets(Path(storage_config.scene_presets_file))
|
||||
_insert_entities(db, "devices", _build_devices())
|
||||
_insert_entities(db, "capture_templates", _build_capture_templates())
|
||||
_insert_entities(db, "output_targets", _build_output_targets())
|
||||
_insert_entities(db, "picture_sources", _build_picture_sources())
|
||||
_insert_entities(db, "color_strip_sources", _build_color_strip_sources())
|
||||
_insert_entities(db, "audio_sources", _build_audio_sources())
|
||||
_insert_entities(db, "scene_presets", _build_scene_presets())
|
||||
|
||||
logger.info("Demo seed data complete")
|
||||
|
||||
|
||||
# ── Devices ────────────────────────────────────────────────────────
|
||||
|
||||
def _seed_devices(path: Path) -> None:
|
||||
devices = {
|
||||
def _build_devices() -> dict:
|
||||
return {
|
||||
_DEVICE_IDS["strip"]: {
|
||||
"id": _DEVICE_IDS["strip"],
|
||||
"name": "Demo LED Strip",
|
||||
@@ -138,13 +122,12 @@ def _seed_devices(path: Path) -> None:
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "devices", devices)
|
||||
|
||||
|
||||
# ── Capture Templates ──────────────────────────────────────────────
|
||||
|
||||
def _seed_capture_templates(path: Path) -> None:
|
||||
templates = {
|
||||
def _build_capture_templates() -> dict:
|
||||
return {
|
||||
_TPL_ID: {
|
||||
"id": _TPL_ID,
|
||||
"name": "Demo Capture",
|
||||
@@ -156,13 +139,12 @@ def _seed_capture_templates(path: Path) -> None:
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "templates", templates)
|
||||
|
||||
|
||||
# ── Output Targets ─────────────────────────────────────────────────
|
||||
|
||||
def _seed_output_targets(path: Path) -> None:
|
||||
targets = {
|
||||
def _build_output_targets() -> dict:
|
||||
return {
|
||||
_TARGET_IDS["strip"]: {
|
||||
"id": _TARGET_IDS["strip"],
|
||||
"name": "Strip — Gradient",
|
||||
@@ -200,13 +182,12 @@ def _seed_output_targets(path: Path) -> None:
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "output_targets", targets)
|
||||
|
||||
|
||||
# ── Picture Sources ────────────────────────────────────────────────
|
||||
|
||||
def _seed_picture_sources(path: Path) -> None:
|
||||
sources = {
|
||||
def _build_picture_sources() -> dict:
|
||||
return {
|
||||
_PS_IDS["main"]: {
|
||||
"id": _PS_IDS["main"],
|
||||
"name": "Demo Display 1080p",
|
||||
@@ -218,7 +199,6 @@ def _seed_picture_sources(path: Path) -> None:
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
# Nulls for non-applicable subclass fields
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
@@ -253,13 +233,12 @@ def _seed_picture_sources(path: Path) -> None:
|
||||
"clock_id": None,
|
||||
},
|
||||
}
|
||||
_write_store(path, "picture_sources", sources)
|
||||
|
||||
|
||||
# ── Color Strip Sources ────────────────────────────────────────────
|
||||
|
||||
def _seed_color_strip_sources(path: Path) -> None:
|
||||
sources = {
|
||||
def _build_color_strip_sources() -> dict:
|
||||
return {
|
||||
_CSS_IDS["gradient"]: {
|
||||
"id": _CSS_IDS["gradient"],
|
||||
"name": "Rainbow Gradient",
|
||||
@@ -338,13 +317,12 @@ def _seed_color_strip_sources(path: Path) -> None:
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "color_strip_sources", sources)
|
||||
|
||||
|
||||
# ── Audio Sources ──────────────────────────────────────────────────
|
||||
|
||||
def _seed_audio_sources(path: Path) -> None:
|
||||
sources = {
|
||||
def _build_audio_sources() -> dict:
|
||||
return {
|
||||
_AS_IDS["system"]: {
|
||||
"id": _AS_IDS["system"],
|
||||
"name": "Demo System Audio",
|
||||
@@ -356,7 +334,6 @@ def _seed_audio_sources(path: Path) -> None:
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
# Forward-compat null fields
|
||||
"audio_source_id": None,
|
||||
"channel": None,
|
||||
},
|
||||
@@ -370,19 +347,17 @@ def _seed_audio_sources(path: Path) -> None:
|
||||
"tags": ["demo"],
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
# Forward-compat null fields
|
||||
"device_index": None,
|
||||
"is_loopback": None,
|
||||
"audio_template_id": None,
|
||||
},
|
||||
}
|
||||
_write_store(path, "audio_sources", sources)
|
||||
|
||||
|
||||
# ── Scene Presets ──────────────────────────────────────────────────
|
||||
|
||||
def _seed_scene_presets(path: Path) -> None:
|
||||
presets = {
|
||||
def _build_scene_presets() -> dict:
|
||||
return {
|
||||
_SCENE_ID: {
|
||||
"id": _SCENE_ID,
|
||||
"name": "Demo Ambient",
|
||||
@@ -409,4 +384,3 @@ def _seed_scene_presets(path: Path) -> None:
|
||||
"updated_at": _NOW,
|
||||
},
|
||||
}
|
||||
_write_store(path, "scene_presets", presets)
|
||||
|
||||
@@ -24,6 +24,9 @@ import wled_controller.core.filters.css_filter_template # noqa: F401
|
||||
import wled_controller.core.filters.noise_gate # noqa: F401
|
||||
import wled_controller.core.filters.palette_quantization # noqa: F401
|
||||
import wled_controller.core.filters.reverse # noqa: F401
|
||||
import wled_controller.core.filters.hsl_shift # noqa: F401
|
||||
import wled_controller.core.filters.contrast # noqa: F401
|
||||
import wled_controller.core.filters.temporal_blur # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"FilterOptionDef",
|
||||
|
||||
49
server/src/wled_controller/core/filters/contrast.py
Normal file
49
server/src/wled_controller/core/filters/contrast.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Contrast postprocessing filter."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class ContrastFilter(PostprocessingFilter):
|
||||
"""Adjusts contrast around mid-gray (128) using a lookup table.
|
||||
|
||||
value < 1.0 = reduced contrast (washed out)
|
||||
value = 1.0 = unchanged
|
||||
value > 1.0 = increased contrast (punchier)
|
||||
"""
|
||||
|
||||
filter_id = "contrast"
|
||||
filter_name = "Contrast"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
value = self.options["value"]
|
||||
# LUT: output = clamp(128 + (input - 128) * value, 0, 255)
|
||||
lut = np.clip(128.0 + (np.arange(256, dtype=np.float32) - 128.0) * value, 0, 255)
|
||||
self._lut = lut.astype(np.uint8)
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="value",
|
||||
label="Contrast",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=3.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
image[:] = self._lut[image]
|
||||
return None
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
@@ -44,8 +44,7 @@ class DownscalerFilter(PostprocessingFilter):
|
||||
if new_h == h and new_w == w:
|
||||
return None
|
||||
|
||||
pil_img = Image.fromarray(image)
|
||||
downscaled = np.array(pil_img.resize((new_w, new_h), Image.LANCZOS))
|
||||
downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
|
||||
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
|
||||
np.copyto(result, downscaled)
|
||||
|
||||
146
server/src/wled_controller/core/filters/hsl_shift.py
Normal file
146
server/src/wled_controller/core/filters/hsl_shift.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""HSL shift postprocessing filter — hue rotation and lightness adjustment."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class HslShiftFilter(PostprocessingFilter):
|
||||
"""Shifts hue and lightness of all pixels via integer math.
|
||||
|
||||
Hue is rotated by a fixed offset (0-360 degrees).
|
||||
Lightness is scaled by a multiplier (0.0 = black, 1.0 = unchanged, 2.0 = bright).
|
||||
"""
|
||||
|
||||
filter_id = "hsl_shift"
|
||||
filter_name = "HSL Shift"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._f32_buf: Optional[np.ndarray] = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="hue",
|
||||
label="Hue Shift",
|
||||
option_type="int",
|
||||
default=0,
|
||||
min_value=0,
|
||||
max_value=359,
|
||||
step=1,
|
||||
),
|
||||
FilterOptionDef(
|
||||
key="lightness",
|
||||
label="Lightness",
|
||||
option_type="float",
|
||||
default=1.0,
|
||||
min_value=0.0,
|
||||
max_value=2.0,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
hue_shift = self.options["hue"]
|
||||
lightness = self.options["lightness"]
|
||||
if hue_shift == 0 and lightness == 1.0:
|
||||
return None
|
||||
|
||||
h, w, c = image.shape
|
||||
n = h * w
|
||||
|
||||
# Flatten to (N, 3) float32
|
||||
flat = image.reshape(n, c)
|
||||
if self._f32_buf is None or self._f32_buf.shape[0] != n:
|
||||
self._f32_buf = np.empty((n, 3), dtype=np.float32)
|
||||
buf = self._f32_buf
|
||||
np.copyto(buf, flat, casting="unsafe")
|
||||
buf *= (1.0 / 255.0)
|
||||
|
||||
r = buf[:, 0]
|
||||
g = buf[:, 1]
|
||||
b = buf[:, 2]
|
||||
|
||||
# RGB -> HSL (vectorized)
|
||||
cmax = np.maximum(np.maximum(r, g), b)
|
||||
cmin = np.minimum(np.minimum(r, g), b)
|
||||
delta = cmax - cmin
|
||||
light = (cmax + cmin) * 0.5
|
||||
|
||||
# Hue calculation
|
||||
hue = np.zeros(n, dtype=np.float32)
|
||||
mask_nonzero = delta > 1e-6
|
||||
if np.any(mask_nonzero):
|
||||
d = delta[mask_nonzero]
|
||||
rm = r[mask_nonzero]
|
||||
gm = g[mask_nonzero]
|
||||
bm = b[mask_nonzero]
|
||||
cm = cmax[mask_nonzero]
|
||||
|
||||
h_val = np.zeros_like(d)
|
||||
mr = cm == rm
|
||||
mg = (~mr) & (cm == gm)
|
||||
mb = (~mr) & (~mg)
|
||||
|
||||
h_val[mr] = ((gm[mr] - bm[mr]) / d[mr]) % 6.0
|
||||
h_val[mg] = (bm[mg] - rm[mg]) / d[mg] + 2.0
|
||||
h_val[mb] = (rm[mb] - gm[mb]) / d[mb] + 4.0
|
||||
h_val *= 60.0
|
||||
h_val[h_val < 0] += 360.0
|
||||
hue[mask_nonzero] = h_val
|
||||
|
||||
# Saturation
|
||||
sat = np.zeros(n, dtype=np.float32)
|
||||
mask_sat = mask_nonzero & (light > 1e-6) & (light < 1.0 - 1e-6)
|
||||
if np.any(mask_sat):
|
||||
sat[mask_sat] = delta[mask_sat] / (1.0 - np.abs(2.0 * light[mask_sat] - 1.0))
|
||||
|
||||
# Apply shifts
|
||||
if hue_shift != 0:
|
||||
hue = (hue + hue_shift) % 360.0
|
||||
if lightness != 1.0:
|
||||
light = np.clip(light * lightness, 0.0, 1.0)
|
||||
|
||||
# HSL -> RGB (vectorized)
|
||||
c_val = (1.0 - np.abs(2.0 * light - 1.0)) * sat
|
||||
x_val = c_val * (1.0 - np.abs((hue / 60.0) % 2.0 - 1.0))
|
||||
m_val = light - c_val * 0.5
|
||||
|
||||
sector = (hue / 60.0).astype(np.int32) % 6
|
||||
|
||||
ro = np.empty(n, dtype=np.float32)
|
||||
go = np.empty(n, dtype=np.float32)
|
||||
bo = np.empty(n, dtype=np.float32)
|
||||
|
||||
for s, rv, gv, bv in (
|
||||
(0, c_val, x_val, 0.0),
|
||||
(1, x_val, c_val, 0.0),
|
||||
(2, 0.0, c_val, x_val),
|
||||
(3, 0.0, x_val, c_val),
|
||||
(4, x_val, 0.0, c_val),
|
||||
(5, c_val, 0.0, x_val),
|
||||
):
|
||||
mask_s = sector == s
|
||||
if not np.any(mask_s):
|
||||
continue
|
||||
rv_arr = rv[mask_s] if not isinstance(rv, float) else rv
|
||||
gv_arr = gv[mask_s] if not isinstance(gv, float) else gv
|
||||
bv_arr = bv[mask_s] if not isinstance(bv, float) else bv
|
||||
ro[mask_s] = rv_arr + m_val[mask_s]
|
||||
go[mask_s] = gv_arr + m_val[mask_s]
|
||||
bo[mask_s] = bv_arr + m_val[mask_s]
|
||||
|
||||
buf[:, 0] = ro
|
||||
buf[:, 1] = go
|
||||
buf[:, 2] = bo
|
||||
np.clip(buf, 0.0, 1.0, out=buf)
|
||||
buf *= 255.0
|
||||
np.copyto(flat, buf, casting="unsafe")
|
||||
return None
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
@@ -42,9 +42,8 @@ class PixelateFilter(PostprocessingFilter):
|
||||
# vectorized C++ instead of per-block Python loop
|
||||
small_w = max(1, w // block_size)
|
||||
small_h = max(1, h // block_size)
|
||||
pil_img = Image.fromarray(image)
|
||||
small = pil_img.resize((small_w, small_h), Image.LANCZOS)
|
||||
pixelated = np.array(small.resize((w, h), Image.NEAREST))
|
||||
small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA)
|
||||
pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
|
||||
np.copyto(image, pixelated)
|
||||
|
||||
return None
|
||||
|
||||
77
server/src/wled_controller/core/filters/temporal_blur.py
Normal file
77
server/src/wled_controller/core/filters/temporal_blur.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Temporal blur postprocessing filter — blends current frame with history."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||
from wled_controller.core.filters.image_pool import ImagePool
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
|
||||
|
||||
@FilterRegistry.register
|
||||
class TemporalBlurFilter(PostprocessingFilter):
|
||||
"""Blends each frame with a running accumulator for motion smoothing.
|
||||
|
||||
Uses exponential moving average: acc = (1 - strength) * frame + strength * acc
|
||||
Higher strength = more blur / longer trails.
|
||||
"""
|
||||
|
||||
filter_id = "temporal_blur"
|
||||
filter_name = "Temporal Blur"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._acc: Optional[np.ndarray] = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
FilterOptionDef(
|
||||
key="strength",
|
||||
label="Strength",
|
||||
option_type="float",
|
||||
default=0.5,
|
||||
min_value=0.0,
|
||||
max_value=0.95,
|
||||
step=0.05,
|
||||
),
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
strength = self.options["strength"]
|
||||
if strength == 0.0:
|
||||
self._acc = None
|
||||
return None
|
||||
|
||||
h, w, c = image.shape
|
||||
shape = (h, w, c)
|
||||
|
||||
if self._acc is None or self._acc.shape != shape:
|
||||
self._acc = image.astype(np.float32)
|
||||
return None
|
||||
|
||||
# EMA: acc = strength * acc + (1 - strength) * current
|
||||
new_weight = 1.0 - strength
|
||||
self._acc *= strength
|
||||
self._acc += new_weight * image
|
||||
np.copyto(image, self._acc, casting="unsafe")
|
||||
return None
|
||||
|
||||
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Optimized strip path — avoids reshape overhead."""
|
||||
strength = self.options["strength"]
|
||||
if strength == 0.0:
|
||||
self._acc = None
|
||||
return None
|
||||
|
||||
shape = strip.shape
|
||||
if self._acc is None or self._acc.shape != shape:
|
||||
self._acc = strip.astype(np.float32)
|
||||
return None
|
||||
|
||||
new_weight = 1.0 - strength
|
||||
self._acc *= strength
|
||||
self._acc += new_weight * strip
|
||||
np.copyto(strip, self._acc, casting="unsafe")
|
||||
return None
|
||||
@@ -17,6 +17,7 @@ import numpy as np
|
||||
|
||||
from wled_controller.core.audio.analysis import NUM_BANDS
|
||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||
from wled_controller.core.audio.band_filter import apply_band_filter, compute_band_mask
|
||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||
from wled_controller.core.processing.effect_stream import _build_palette_lut
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -58,14 +59,32 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._prev_spectrum: Optional[np.ndarray] = None
|
||||
self._prev_rms = 0.0
|
||||
|
||||
self._gradient_store = None # injected by stream manager
|
||||
self._update_from_source(source)
|
||||
|
||||
def set_gradient_store(self, gradient_store) -> None:
|
||||
"""Inject gradient store for palette resolution."""
|
||||
self._gradient_store = gradient_store
|
||||
self._resolve_palette_lut()
|
||||
|
||||
def _resolve_palette_lut(self) -> None:
|
||||
"""Build palette LUT from gradient_id or legacy palette name."""
|
||||
gradient_id = self._gradient_id
|
||||
if gradient_id and self._gradient_store:
|
||||
stops = self._gradient_store.resolve_stops(gradient_id)
|
||||
if stops:
|
||||
custom = [[s["position"], *s["color"]] for s in stops]
|
||||
self._palette_lut = _build_palette_lut("custom", custom)
|
||||
return
|
||||
self._palette_lut = _build_palette_lut(self._palette_name)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
self._visualization_mode = getattr(source, "visualization_mode", "spectrum")
|
||||
self._sensitivity = float(getattr(source, "sensitivity", 1.0))
|
||||
self._smoothing = float(getattr(source, "smoothing", 0.3))
|
||||
self._gradient_id = getattr(source, "gradient_id", None)
|
||||
self._palette_name = getattr(source, "palette", "rainbow")
|
||||
self._palette_lut = _build_palette_lut(self._palette_name)
|
||||
self._resolve_palette_lut()
|
||||
color = getattr(source, "color", None)
|
||||
self._color = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0]
|
||||
color_peak = getattr(source, "color_peak", None)
|
||||
@@ -82,17 +101,18 @@ class AudioColorStripStream(ColorStripStream):
|
||||
self._audio_source_id = audio_source_id
|
||||
self._audio_engine_type = None
|
||||
self._audio_engine_config = None
|
||||
self._band_mask = None # precomputed band filter mask (None = full range)
|
||||
if audio_source_id and self._audio_source_store:
|
||||
try:
|
||||
device_index, is_loopback, channel, template_id = (
|
||||
self._audio_source_store.resolve_audio_source(audio_source_id)
|
||||
)
|
||||
self._audio_device_index = device_index
|
||||
self._audio_loopback = is_loopback
|
||||
self._audio_channel = channel
|
||||
if template_id and self._audio_template_store:
|
||||
resolved = self._audio_source_store.resolve_audio_source(audio_source_id)
|
||||
self._audio_device_index = resolved.device_index
|
||||
self._audio_loopback = resolved.is_loopback
|
||||
self._audio_channel = resolved.channel
|
||||
if resolved.freq_low is not None and resolved.freq_high is not None:
|
||||
self._band_mask = compute_band_mask(resolved.freq_low, resolved.freq_high)
|
||||
if resolved.audio_template_id and self._audio_template_store:
|
||||
try:
|
||||
tpl = self._audio_template_store.get_template(template_id)
|
||||
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
|
||||
self._audio_engine_type = tpl.engine_type
|
||||
self._audio_engine_config = tpl.engine_config
|
||||
except ValueError:
|
||||
@@ -302,12 +322,16 @@ class AudioColorStripStream(ColorStripStream):
|
||||
# ── Channel selection ─────────────────────────────────────────
|
||||
|
||||
def _pick_channel(self, analysis):
|
||||
"""Return (spectrum, rms) for the configured audio channel."""
|
||||
"""Return (spectrum, rms) for the configured audio channel, with band filtering."""
|
||||
if self._audio_channel == "left":
|
||||
return analysis.left_spectrum, analysis.left_rms
|
||||
spectrum, rms = analysis.left_spectrum, analysis.left_rms
|
||||
elif self._audio_channel == "right":
|
||||
return analysis.right_spectrum, analysis.right_rms
|
||||
return analysis.spectrum, analysis.rms
|
||||
spectrum, rms = analysis.right_spectrum, analysis.right_rms
|
||||
else:
|
||||
spectrum, rms = analysis.spectrum, analysis.rms
|
||||
if self._band_mask is not None:
|
||||
spectrum, rms = apply_band_filter(spectrum, rms, self._band_mask)
|
||||
return spectrum, rms
|
||||
|
||||
# ── Spectrum Analyzer ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,12 +4,17 @@ Implements CandlelightColorStripStream which produces warm, organic
|
||||
flickering across all LEDs using layered sine waves and value noise.
|
||||
Each "candle" is an independent flicker source that illuminates
|
||||
nearby LEDs with smooth falloff.
|
||||
|
||||
Features:
|
||||
- Wind simulation: correlated brightness drops across all candles
|
||||
- Candle type presets: taper / votive / bonfire
|
||||
- Wax drip effect: localized brightness dips that recover over ~0.5 s
|
||||
"""
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -38,6 +43,18 @@ def _noise1d(x: np.ndarray) -> np.ndarray:
|
||||
return a + u * (b - a)
|
||||
|
||||
|
||||
# ── Candle type preset multipliers ──────────────────────────────────
|
||||
# (flicker_amplitude_mul, speed_mul, sigma_mul, warm_bonus)
|
||||
_CANDLE_PRESETS: dict = {
|
||||
"default": (1.0, 1.0, 1.0, 0.0),
|
||||
"taper": (0.5, 1.3, 0.8, 0.0), # tall, steady
|
||||
"votive": (1.5, 1.0, 0.7, 0.0), # small, flickery
|
||||
"bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift
|
||||
}
|
||||
|
||||
_VALID_CANDLE_TYPES = frozenset(_CANDLE_PRESETS)
|
||||
|
||||
|
||||
class CandlelightColorStripStream(ColorStripStream):
|
||||
"""Color strip stream simulating realistic candle flickering.
|
||||
|
||||
@@ -59,7 +76,12 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
self._s_bright: Optional[np.ndarray] = None
|
||||
self._s_noise: Optional[np.ndarray] = None
|
||||
self._s_x: Optional[np.ndarray] = None
|
||||
self._s_drip: Optional[np.ndarray] = None
|
||||
self._pool_n = 0
|
||||
# Wax drip events: [pos, brightness, phase(0=dim,1=recover)]
|
||||
self._drip_events: List[List[float]] = []
|
||||
self._drip_rng = np.random.RandomState(seed=42)
|
||||
self._last_drip_t = 0.0
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
@@ -68,6 +90,9 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
self._intensity = float(getattr(source, "intensity", 1.0))
|
||||
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
|
||||
self._speed = float(getattr(source, "speed", 1.0))
|
||||
self._wind_strength = float(getattr(source, "wind_strength", 0.0))
|
||||
raw_type = getattr(source, "candle_type", "default")
|
||||
self._candle_type = raw_type if raw_type in _VALID_CANDLE_TYPES else "default"
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||
@@ -161,11 +186,13 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
self._s_bright = np.empty(n, dtype=np.float32)
|
||||
self._s_noise = np.empty(n, dtype=np.float32)
|
||||
self._s_x = np.arange(n, dtype=np.float32)
|
||||
self._s_drip = np.ones(n, dtype=np.float32)
|
||||
|
||||
buf = _buf_a if _use_a else _buf_b
|
||||
_use_a = not _use_a
|
||||
|
||||
self._render_candlelight(buf, n, t, speed)
|
||||
self._update_drip_events(n, wall_start, frame_time)
|
||||
self._render_candlelight(buf, n, t, speed, wall_start)
|
||||
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
@@ -179,26 +206,68 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float) -> None:
|
||||
"""Render candle flickering into buf (n, 3) uint8.
|
||||
# ── Drip management ─────────────────────────────────────────────
|
||||
|
||||
Algorithm:
|
||||
- Place num_candles evenly along the strip
|
||||
- Each candle has independent layered-sine flicker
|
||||
- Spatial falloff: LEDs near a candle are brighter
|
||||
- Per-LED noise adds individual variation
|
||||
- Final brightness modulates the base warm color
|
||||
"""
|
||||
# Scale speed so that speed=1 gives a gentle ~1.3 Hz dominant flicker
|
||||
speed = speed * 0.35
|
||||
def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None:
|
||||
"""Spawn new wax drip events and advance existing ones."""
|
||||
intensity = self._intensity
|
||||
spawn_interval = max(0.3, 1.0 / max(intensity, 0.01))
|
||||
if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5:
|
||||
self._last_drip_t = wall_t
|
||||
pos = float(self._drip_rng.randint(0, max(n, 1)))
|
||||
self._drip_events.append([pos, 1.0, 0])
|
||||
|
||||
surviving = []
|
||||
for drip in self._drip_events:
|
||||
pos, bright, phase = drip
|
||||
if phase == 0:
|
||||
bright -= dt / 0.2 * 0.7
|
||||
if bright <= 0.3:
|
||||
bright = 0.3
|
||||
drip[2] = 1
|
||||
else:
|
||||
bright += dt / 0.5 * 0.7
|
||||
if bright >= 1.0:
|
||||
continue # drip complete
|
||||
drip[1] = bright
|
||||
surviving.append(drip)
|
||||
self._drip_events = surviving
|
||||
|
||||
# Build per-LED drip factor
|
||||
drip_arr = self._s_drip
|
||||
drip_arr[:n] = 1.0
|
||||
x = self._s_x[:n]
|
||||
for drip in self._drip_events:
|
||||
pos, bright, _phase = drip
|
||||
dist = x - pos
|
||||
falloff = np.exp(-0.5 * dist * dist / 4.0)
|
||||
drip_arr[:n] *= 1.0 - (1.0 - bright) * falloff
|
||||
|
||||
# ── Render ──────────────────────────────────────────────────────
|
||||
|
||||
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float, wall_t: float) -> None:
|
||||
"""Render candle flickering into buf (n, 3) uint8."""
|
||||
amp_mul, spd_mul, sigma_mul, warm_bonus = _CANDLE_PRESETS[self._candle_type]
|
||||
|
||||
eff_speed = speed * 0.35 * spd_mul
|
||||
intensity = self._intensity
|
||||
num_candles = self._num_candles
|
||||
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
|
||||
|
||||
bright = self._s_bright
|
||||
bright[:] = 0.0
|
||||
# Wind modulation
|
||||
wind_strength = self._wind_strength
|
||||
if wind_strength > 0.0:
|
||||
wind_raw = (
|
||||
0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t)
|
||||
+ 0.4 * math.sin(2.0 * math.pi * 0.27 * wall_t + 1.1)
|
||||
)
|
||||
wind_mod = max(0.0, wind_raw)
|
||||
else:
|
||||
wind_mod = 0.0
|
||||
|
||||
bright = self._s_bright
|
||||
bright[:n] = 0.0
|
||||
|
||||
# Candle positions: evenly distributed
|
||||
if num_candles == 1:
|
||||
positions = [n / 2.0]
|
||||
else:
|
||||
@@ -207,42 +276,42 @@ class CandlelightColorStripStream(ColorStripStream):
|
||||
x = self._s_x[:n]
|
||||
|
||||
for ci, pos in enumerate(positions):
|
||||
# Independent flicker for this candle: layered sines at different frequencies
|
||||
# Use candle index as phase offset for independence
|
||||
offset = ci * 137.5 # golden-angle offset for non-repeating
|
||||
offset = ci * 137.5
|
||||
flicker = (
|
||||
0.40 * math.sin(2 * math.pi * speed * t * 3.7 + offset)
|
||||
+ 0.25 * math.sin(2 * math.pi * speed * t * 7.3 + offset * 0.7)
|
||||
+ 0.15 * math.sin(2 * math.pi * speed * t * 13.1 + offset * 1.3)
|
||||
+ 0.10 * math.sin(2 * math.pi * speed * t * 1.9 + offset * 0.3)
|
||||
0.40 * math.sin(2.0 * math.pi * eff_speed * t * 3.7 + offset)
|
||||
+ 0.25 * math.sin(2.0 * math.pi * eff_speed * t * 7.3 + offset * 0.7)
|
||||
+ 0.15 * math.sin(2.0 * math.pi * eff_speed * t * 13.1 + offset * 1.3)
|
||||
+ 0.10 * math.sin(2.0 * math.pi * eff_speed * t * 1.9 + offset * 0.3)
|
||||
)
|
||||
# Normalize flicker to [0.3, 1.0] range (candles never fully go dark)
|
||||
candle_brightness = 0.65 + 0.35 * flicker * intensity
|
||||
|
||||
# Spatial falloff: Gaussian centered on candle position
|
||||
# sigma proportional to strip length / num_candles
|
||||
sigma = max(n / (num_candles * 2.0), 2.0)
|
||||
candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul
|
||||
|
||||
if wind_strength > 0.0:
|
||||
candle_brightness *= (1.0 - wind_strength * wind_mod * 0.4)
|
||||
|
||||
candle_brightness = max(0.1, candle_brightness)
|
||||
|
||||
sigma = max(n / (num_candles * 2.0), 2.0) * sigma_mul
|
||||
dist = x - pos
|
||||
falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma))
|
||||
|
||||
bright += candle_brightness * falloff
|
||||
bright[:n] += candle_brightness * falloff
|
||||
|
||||
# Per-LED noise for individual variation
|
||||
noise_x = x * 0.3 + t * speed * 5.0
|
||||
# Per-LED noise
|
||||
noise_x = x * 0.3 + t * eff_speed * 5.0
|
||||
noise = _noise1d(noise_x)
|
||||
# Modulate brightness with noise (±15%)
|
||||
bright *= (0.85 + 0.30 * noise)
|
||||
bright[:n] *= (0.85 + 0.30 * noise)
|
||||
|
||||
# Clamp to [0, 1]
|
||||
np.clip(bright, 0.0, 1.0, out=bright)
|
||||
# Wax drip factor
|
||||
bright[:n] *= self._s_drip[:n]
|
||||
|
||||
# Apply base color with brightness modulation
|
||||
# Candles emit warmer (more red, less blue) at lower brightness
|
||||
# Add slight color variation: dimmer = warmer
|
||||
warm_shift = (1.0 - bright) * 0.3
|
||||
r = bright * base_r
|
||||
g = bright * base_g * (1.0 - warm_shift * 0.5)
|
||||
b = bright * base_b * (1.0 - warm_shift)
|
||||
np.clip(bright[:n], 0.0, 1.0, out=bright[:n])
|
||||
|
||||
# Colour mapping: dimmer = warmer
|
||||
warm_shift = (1.0 - bright[:n]) * (0.3 + warm_bonus)
|
||||
r = bright[:n] * base_r
|
||||
g = bright[:n] * base_g * (1.0 - warm_shift * 0.5)
|
||||
b = bright[:n] * base_b * (1.0 - warm_shift)
|
||||
|
||||
buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8)
|
||||
buf[:, 1] = np.clip(g, 0, 255).astype(np.uint8)
|
||||
|
||||
@@ -24,6 +24,27 @@ from wled_controller.core.capture.screen_capture import extract_border_pixels
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils.timer import high_resolution_timer
|
||||
|
||||
|
||||
class _SimpleNoise1D:
|
||||
"""Minimal 1-D value noise for gradient perturbation (avoids circular import)."""
|
||||
|
||||
def __init__(self, seed: int = 99):
|
||||
rng = np.random.RandomState(seed)
|
||||
self._table = rng.random(512).astype(np.float32)
|
||||
|
||||
def noise(self, x: np.ndarray) -> np.ndarray:
|
||||
size = len(self._table)
|
||||
xi = np.floor(x).astype(np.int64)
|
||||
frac = x - np.floor(x)
|
||||
t = frac * frac * (3.0 - 2.0 * frac)
|
||||
a = self._table[xi % size]
|
||||
b = self._table[(xi + 1) % size]
|
||||
return a + t * (b - a)
|
||||
|
||||
|
||||
# Module-level noise for gradient perturbation
|
||||
_gradient_noise = _SimpleNoise1D(seed=99)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -348,7 +369,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
self._running = False
|
||||
|
||||
|
||||
def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
||||
def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear") -> np.ndarray:
|
||||
"""Compute an (led_count, 3) uint8 array from gradient color stops.
|
||||
|
||||
Each stop: {"position": float 0–1, "color": [R,G,B], "color_right": [R,G,B] | absent}
|
||||
@@ -361,7 +382,7 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
||||
left_color = A["color_right"] if present, else A["color"]
|
||||
right_color = B["color"]
|
||||
t = (p - A.pos) / (B.pos - A.pos)
|
||||
color = lerp(left_color, right_color, t)
|
||||
color = lerp(left_color, right_color, eased(t))
|
||||
"""
|
||||
if led_count <= 0:
|
||||
led_count = 1
|
||||
@@ -412,6 +433,15 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
||||
span = b_pos - a_pos
|
||||
t = np.where(span > 0, (between_pos - a_pos) / span, 0.0)
|
||||
|
||||
# Apply easing to interpolation parameter
|
||||
if easing == "ease_in_out":
|
||||
t = t * t * (3.0 - 2.0 * t)
|
||||
elif easing == "cubic":
|
||||
t = np.where(t < 0.5, 4.0 * t * t * t, 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0)
|
||||
elif easing == "step":
|
||||
steps = float(max(2, n_stops))
|
||||
t = np.round(t * steps) / steps
|
||||
|
||||
a_colors = right_colors[idx] # A's right color
|
||||
b_colors = left_colors[idx + 1] # B's left color
|
||||
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
|
||||
@@ -821,19 +851,38 @@ class GradientColorStripStream(ColorStripStream):
|
||||
self._fps = 30
|
||||
self._frame_time = 1.0 / 30
|
||||
self._clock = None # optional SyncClockRuntime
|
||||
self._gradient_store = None # injected by stream manager
|
||||
self._update_from_source(source)
|
||||
|
||||
def set_gradient_store(self, gradient_store) -> None:
|
||||
"""Inject gradient store for resolving gradient_id to stops."""
|
||||
self._gradient_store = gradient_store
|
||||
# Re-resolve stops if gradient_id is set
|
||||
gradient_id = getattr(self, "_gradient_id", None)
|
||||
if gradient_id and self._gradient_store:
|
||||
stops = self._gradient_store.resolve_stops(gradient_id)
|
||||
if stops:
|
||||
self._stops = stops
|
||||
self._rebuild_colors()
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
self._gradient_id = getattr(source, "gradient_id", None)
|
||||
self._stops = list(source.stops) if source.stops else []
|
||||
# Override inline stops with gradient entity if set
|
||||
if self._gradient_id and self._gradient_store:
|
||||
resolved = self._gradient_store.resolve_stops(self._gradient_id)
|
||||
if resolved:
|
||||
self._stops = resolved
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
led_count = _lc if _lc and _lc > 0 else 1
|
||||
self._led_count = led_count
|
||||
self._animation = source.animation # dict or None; read atomically by _animate_loop
|
||||
self._easing = getattr(source, "easing", "linear") or "linear"
|
||||
self._rebuild_colors()
|
||||
|
||||
def _rebuild_colors(self) -> None:
|
||||
colors = _compute_gradient_colors(self._stops, self._led_count)
|
||||
colors = _compute_gradient_colors(self._stops, self._led_count, self._easing)
|
||||
with self._colors_lock:
|
||||
self._colors = colors
|
||||
|
||||
@@ -919,6 +968,7 @@ class GradientColorStripStream(ColorStripStream):
|
||||
_cached_base: Optional[np.ndarray] = None
|
||||
_cached_n: int = 0
|
||||
_cached_stops: Optional[list] = None
|
||||
_cached_easing: str = ""
|
||||
# Double-buffer pool + uint16 scratch for brightness math
|
||||
_pool_n = 0
|
||||
_buf_a = _buf_b = _scratch_u16 = None
|
||||
@@ -950,11 +1000,13 @@ class GradientColorStripStream(ColorStripStream):
|
||||
stops = self._stops
|
||||
colors = None
|
||||
|
||||
# Recompute base gradient only when stops or led_count change
|
||||
if _cached_base is None or _cached_n != n or _cached_stops is not stops:
|
||||
_cached_base = _compute_gradient_colors(stops, n)
|
||||
# Recompute base gradient only when stops, led_count, or easing change
|
||||
easing = self._easing
|
||||
if _cached_base is None or _cached_n != n or _cached_stops is not stops or _cached_easing != easing:
|
||||
_cached_base = _compute_gradient_colors(stops, n, easing)
|
||||
_cached_n = n
|
||||
_cached_stops = stops
|
||||
_cached_easing = easing
|
||||
base = _cached_base
|
||||
|
||||
# Re-allocate pool only when LED count changes
|
||||
@@ -1099,6 +1151,70 @@ class GradientColorStripStream(ColorStripStream):
|
||||
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
|
||||
colors = buf
|
||||
|
||||
elif atype == "noise_perturb":
|
||||
# Perturb gradient stop positions with value noise
|
||||
perturbed = []
|
||||
for si, s in enumerate(stops):
|
||||
noise_val = _gradient_noise.noise(
|
||||
np.array([si * 10.0 + t * speed], dtype=np.float32)
|
||||
)[0]
|
||||
new_pos = min(1.0, max(0.0,
|
||||
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2
|
||||
))
|
||||
perturbed.append(dict(s, position=new_pos))
|
||||
buf[:] = _compute_gradient_colors(perturbed, n, easing)
|
||||
colors = buf
|
||||
|
||||
elif atype == "hue_rotate":
|
||||
# Rotate hue while preserving original S/V
|
||||
h_shift = (speed * t * 0.1) % 1.0
|
||||
rgb_f = base.astype(np.float32) * (1.0 / 255.0)
|
||||
r_f = rgb_f[:, 0]
|
||||
g_f = rgb_f[:, 1]
|
||||
b_f = rgb_f[:, 2]
|
||||
cmax = np.maximum(np.maximum(r_f, g_f), b_f)
|
||||
cmin = np.minimum(np.minimum(r_f, g_f), b_f)
|
||||
delta = cmax - cmin
|
||||
# Hue
|
||||
h_arr = np.zeros(n, dtype=np.float32)
|
||||
mask_r = (delta > 0) & (cmax == r_f)
|
||||
mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
|
||||
mask_b = (delta > 0) & ~mask_r & ~mask_g
|
||||
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
|
||||
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
|
||||
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
|
||||
h_arr *= (1.0 / 6.0)
|
||||
h_arr %= 1.0
|
||||
# S and V — preserve original values (no clamping)
|
||||
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
|
||||
v_arr = cmax
|
||||
# Shift hue
|
||||
h_arr += h_shift
|
||||
h_arr %= 1.0
|
||||
# HSV->RGB
|
||||
h6 = h_arr * 6.0
|
||||
hi = h6.astype(np.int32) % 6
|
||||
f_arr = h6 - np.floor(h6)
|
||||
p = v_arr * (1.0 - s_arr)
|
||||
q = v_arr * (1.0 - s_arr * f_arr)
|
||||
tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
|
||||
ro = np.empty(n, dtype=np.float32)
|
||||
go = np.empty(n, dtype=np.float32)
|
||||
bo = np.empty(n, dtype=np.float32)
|
||||
for sxt, rv, gv, bv in (
|
||||
(0, v_arr, tt, p), (1, q, v_arr, p),
|
||||
(2, p, v_arr, tt), (3, p, q, v_arr),
|
||||
(4, tt, p, v_arr), (5, v_arr, p, q),
|
||||
):
|
||||
m = hi == sxt
|
||||
ro[m] = rv[m]
|
||||
go[m] = gv[m]
|
||||
bo[m] = bv[m]
|
||||
buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
|
||||
buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
|
||||
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
|
||||
colors = buf
|
||||
|
||||
if colors is not None:
|
||||
with self._colors_lock:
|
||||
self._colors = colors
|
||||
|
||||
@@ -69,7 +69,7 @@ class ColorStripStreamManager:
|
||||
keyed by ``{css_id}:{consumer_id}``.
|
||||
"""
|
||||
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None):
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None):
|
||||
"""
|
||||
Args:
|
||||
color_strip_store: ColorStripStore for resolving source configs
|
||||
@@ -79,6 +79,7 @@ class ColorStripStreamManager:
|
||||
sync_clock_manager: SyncClockManager for acquiring clock runtimes
|
||||
value_stream_manager: ValueStreamManager for per-layer brightness sources
|
||||
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
|
||||
gradient_store: GradientStore for resolving gradient entity references
|
||||
"""
|
||||
self._color_strip_store = color_strip_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
@@ -88,6 +89,8 @@ class ColorStripStreamManager:
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._value_stream_manager = value_stream_manager
|
||||
self._cspt_store = cspt_store
|
||||
self._gradient_store = gradient_store
|
||||
self._weather_manager = weather_manager
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _inject_clock(self, css_stream, source) -> Optional[str]:
|
||||
@@ -170,6 +173,9 @@ class ColorStripStreamManager:
|
||||
css_stream = MappedColorStripStream(source, self)
|
||||
elif source.source_type == "processed":
|
||||
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
|
||||
elif source.source_type == "weather":
|
||||
from wled_controller.core.processing.weather_stream import WeatherColorStripStream
|
||||
css_stream = WeatherColorStripStream(source, self._weather_manager)
|
||||
else:
|
||||
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||
if not stream_cls:
|
||||
@@ -177,6 +183,9 @@ class ColorStripStreamManager:
|
||||
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
|
||||
)
|
||||
css_stream = stream_cls(source)
|
||||
# Inject gradient store for palette resolution
|
||||
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
|
||||
css_stream.set_gradient_store(self._gradient_store)
|
||||
# Inject sync clock runtime if source references a clock
|
||||
acquired_clock_id = self._inject_clock(css_stream, source)
|
||||
css_stream.start()
|
||||
|
||||
@@ -17,6 +17,11 @@ _BLEND_ADD = "add"
|
||||
_BLEND_MULTIPLY = "multiply"
|
||||
_BLEND_SCREEN = "screen"
|
||||
_BLEND_OVERRIDE = "override"
|
||||
_BLEND_OVERLAY = "overlay"
|
||||
_BLEND_SOFT_LIGHT = "soft_light"
|
||||
_BLEND_HARD_LIGHT = "hard_light"
|
||||
_BLEND_DIFFERENCE = "difference"
|
||||
_BLEND_EXCLUSION = "exclusion"
|
||||
|
||||
|
||||
class CompositeColorStripStream(ColorStripStream):
|
||||
@@ -155,9 +160,13 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
def update_source(self, source) -> None:
|
||||
"""Hot-update: rebuild sub-streams if layer config changed."""
|
||||
new_layers = list(source.layers)
|
||||
old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), layer.get("enabled"), layer.get("brightness_source_id"))
|
||||
old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"),
|
||||
layer.get("enabled"), layer.get("brightness_source_id"),
|
||||
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False))
|
||||
for layer in self._layers]
|
||||
new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), layer.get("enabled"), layer.get("brightness_source_id"))
|
||||
new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"),
|
||||
layer.get("enabled"), layer.get("brightness_source_id"),
|
||||
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False))
|
||||
for layer in new_layers]
|
||||
|
||||
self._layers = new_layers
|
||||
@@ -187,7 +196,15 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
try:
|
||||
stream = self._css_manager.acquire(src_id, consumer_id)
|
||||
if hasattr(stream, "configure") and self._led_count > 0:
|
||||
stream.configure(self._led_count)
|
||||
# Configure with zone length if layer has a range, else full strip
|
||||
layer_start = layer.get("start", 0)
|
||||
layer_end = layer.get("end", 0)
|
||||
if layer_start > 0 or layer_end > 0:
|
||||
eff_end = layer_end if layer_end > 0 else self._led_count
|
||||
zone_len = max(0, eff_end - layer_start)
|
||||
stream.configure(zone_len if zone_len > 0 else self._led_count)
|
||||
else:
|
||||
stream.configure(self._led_count)
|
||||
self._sub_streams[i] = (src_id, consumer_id, stream)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -336,12 +353,125 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
def _blend_overlay(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
||||
out: np.ndarray) -> None:
|
||||
"""Overlay blend: multiply darks, screen lights, then lerp with alpha.
|
||||
|
||||
if bottom < 128: blended = 2*bottom*top >> 8
|
||||
else: blended = 255 - 2*(255-bottom)*(255-top) >> 8
|
||||
"""
|
||||
u16a, u16b = self._u16_a, self._u16_b
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
np.copyto(u16b, top, casting="unsafe")
|
||||
# Multiply path: 2*b*t >> 8
|
||||
mul = (u16a * u16b) >> 7 # * 2 >> 8 == >> 7
|
||||
# Screen path: 255 - 2*(255-b)*(255-t) >> 8
|
||||
scr = 255 - (((255 - u16a) * (255 - u16b)) >> 7)
|
||||
# Select based on bottom < 128
|
||||
mask = u16a < 128
|
||||
blended = np.where(mask, mul, scr)
|
||||
np.clip(blended, 0, 255, out=blended)
|
||||
# Lerp: result = (bottom * (256-a) + blended * a) >> 8
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
u16a *= (256 - alpha)
|
||||
blended *= alpha
|
||||
u16a += blended
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
def _blend_soft_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
||||
out: np.ndarray) -> None:
|
||||
"""Soft light blend (Pegtop formula), then lerp with alpha.
|
||||
|
||||
blended = (1 - 2*t/255) * b*b/255 + 2*t*b/255
|
||||
"""
|
||||
u16a, u16b = self._u16_a, self._u16_b
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
np.copyto(u16b, top, casting="unsafe")
|
||||
# term1 = (255 - 2*t) * b * b / (255*255)
|
||||
# term2 = 2 * t * b / 255
|
||||
# Use intermediate 32-bit to avoid overflow
|
||||
b32 = u16a.astype(np.uint32)
|
||||
t32 = u16b.astype(np.uint32)
|
||||
blended = ((255 - 2 * t32) * b32 * b32 + 2 * t32 * b32 * 255) // (255 * 255)
|
||||
np.clip(blended, 0, 255, out=blended)
|
||||
# Lerp
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
u16a *= (256 - alpha)
|
||||
blended_u16 = blended.astype(np.uint16)
|
||||
blended_u16 *= alpha
|
||||
u16a += blended_u16
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
def _blend_hard_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
||||
out: np.ndarray) -> None:
|
||||
"""Hard light blend: overlay with top/bottom roles swapped.
|
||||
|
||||
if top < 128: blended = 2*bottom*top >> 8
|
||||
else: blended = 255 - 2*(255-bottom)*(255-top) >> 8
|
||||
"""
|
||||
u16a, u16b = self._u16_a, self._u16_b
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
np.copyto(u16b, top, casting="unsafe")
|
||||
mul = (u16a * u16b) >> 7
|
||||
scr = 255 - (((255 - u16a) * (255 - u16b)) >> 7)
|
||||
# Select based on top < 128 (differs from overlay)
|
||||
mask = u16b < 128
|
||||
blended = np.where(mask, mul, scr)
|
||||
np.clip(blended, 0, 255, out=blended)
|
||||
# Lerp
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
u16a *= (256 - alpha)
|
||||
blended *= alpha
|
||||
u16a += blended
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
def _blend_difference(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
||||
out: np.ndarray) -> None:
|
||||
"""Difference blend: |bottom - top|, then lerp with alpha."""
|
||||
u16a, u16b = self._u16_a, self._u16_b
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
np.copyto(u16b, top, casting="unsafe")
|
||||
# abs diff using signed subtraction
|
||||
blended = np.abs(u16a.astype(np.int16) - u16b.astype(np.int16)).astype(np.uint16)
|
||||
# Lerp
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
u16a *= (256 - alpha)
|
||||
blended *= alpha
|
||||
u16a += blended
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
def _blend_exclusion(self, bottom: np.ndarray, top: np.ndarray, alpha: int,
|
||||
out: np.ndarray) -> None:
|
||||
"""Exclusion blend: bottom + top - 2*bottom*top/255, then lerp with alpha."""
|
||||
u16a, u16b = self._u16_a, self._u16_b
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
np.copyto(u16b, top, casting="unsafe")
|
||||
# blended = b + t - 2*b*t/255
|
||||
blended = u16a + u16b - ((u16a * u16b) >> 7)
|
||||
np.clip(blended, 0, 255, out=blended)
|
||||
# Lerp
|
||||
np.copyto(u16a, bottom, casting="unsafe")
|
||||
u16a *= (256 - alpha)
|
||||
blended *= alpha
|
||||
u16a += blended
|
||||
u16a >>= 8
|
||||
np.copyto(out, u16a, casting="unsafe")
|
||||
|
||||
_BLEND_DISPATCH = {
|
||||
_BLEND_NORMAL: "_blend_normal",
|
||||
_BLEND_ADD: "_blend_add",
|
||||
_BLEND_MULTIPLY: "_blend_multiply",
|
||||
_BLEND_SCREEN: "_blend_screen",
|
||||
_BLEND_OVERRIDE: "_blend_override",
|
||||
_BLEND_OVERLAY: "_blend_overlay",
|
||||
_BLEND_SOFT_LIGHT: "_blend_soft_light",
|
||||
_BLEND_HARD_LIGHT: "_blend_hard_light",
|
||||
_BLEND_DIFFERENCE: "_blend_difference",
|
||||
_BLEND_EXCLUSION: "_blend_exclusion",
|
||||
}
|
||||
|
||||
# ── Processing loop ─────────────────────────────────────────
|
||||
@@ -416,9 +546,34 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
if _result is not None:
|
||||
colors = _result
|
||||
|
||||
# Resize to target LED count if needed
|
||||
if len(colors) != target_n:
|
||||
colors = self._resize_to_target(colors, target_n)
|
||||
# Determine layer range
|
||||
layer_start = layer.get("start", 0)
|
||||
layer_end = layer.get("end", 0)
|
||||
has_range = layer_start > 0 or layer_end > 0
|
||||
|
||||
if has_range:
|
||||
# Clamp range to strip bounds
|
||||
eff_start = max(0, min(layer_start, target_n))
|
||||
eff_end = max(eff_start, min(layer_end if layer_end > 0 else target_n, target_n))
|
||||
zone_len = eff_end - eff_start
|
||||
if zone_len <= 0:
|
||||
continue
|
||||
# Resize to zone length
|
||||
if len(colors) != zone_len:
|
||||
src_x = np.linspace(0, 1, len(colors))
|
||||
dst_x = np.linspace(0, 1, zone_len)
|
||||
resized = np.empty((zone_len, 3), dtype=np.uint8)
|
||||
for ch in range(3):
|
||||
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
|
||||
colors = resized
|
||||
else:
|
||||
# Full-strip layer: resize to target LED count
|
||||
if len(colors) != target_n:
|
||||
colors = self._resize_to_target(colors, target_n)
|
||||
|
||||
# Reverse if requested
|
||||
if layer.get("reverse", False):
|
||||
colors = colors[::-1].copy()
|
||||
|
||||
# Apply per-layer brightness from value source
|
||||
if i in self._brightness_streams:
|
||||
@@ -437,14 +592,20 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
alpha = max(0, min(256, alpha))
|
||||
|
||||
if not has_result:
|
||||
# First layer: copy directly (or blend with black if opacity < 1)
|
||||
if alpha >= 256 and blend_mode == _BLEND_NORMAL:
|
||||
result_buf[:] = colors
|
||||
else:
|
||||
result_buf[:] = 0
|
||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||
blend_fn(result_buf, colors, alpha, result_buf)
|
||||
result_buf[:] = 0
|
||||
has_result = True
|
||||
|
||||
if has_range:
|
||||
# Blend only into the target range — use scratch sub-slices
|
||||
rng = result_buf[eff_start:eff_end]
|
||||
u16a_rng = self._u16_a[:zone_len]
|
||||
u16b_rng = self._u16_b[:zone_len]
|
||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||
# Temporarily swap scratch buffers for the range size
|
||||
orig_u16a, orig_u16b = self._u16_a, self._u16_b
|
||||
self._u16_a, self._u16_b = u16a_rng, u16b_rng
|
||||
blend_fn(rng, colors, alpha, rng)
|
||||
self._u16_a, self._u16_b = orig_u16a, orig_u16b
|
||||
else:
|
||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||
blend_fn(result_buf, colors, alpha, result_buf)
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
Implements DaylightColorStripStream which produces a uniform LED color array
|
||||
that transitions through dawn, daylight, sunset, and night over a continuous
|
||||
24-hour cycle. Can use real wall-clock time or a configurable simulation speed.
|
||||
|
||||
When latitude and longitude are provided, sunrise/sunset times are computed
|
||||
via simplified NOAA solar equations so the daylight curve automatically adapts
|
||||
to the user's location and the current season.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
@@ -19,9 +25,9 @@ logger = get_logger(__name__)
|
||||
|
||||
# ── Daylight color table ────────────────────────────────────────────────
|
||||
#
|
||||
# Maps hour-of-day (0–24) to RGB color. Interpolated linearly between
|
||||
# control points. Colors approximate natural daylight color temperature
|
||||
# from warm sunrise tones through cool midday to warm sunset and dim night.
|
||||
# Canonical hour control points (0–24) → RGB. Designed for a default
|
||||
# sunrise of 6 h and sunset of 19 h. At render time the curve is remapped
|
||||
# to the actual solar times for the location.
|
||||
#
|
||||
# Format: (hour, R, G, B)
|
||||
_DAYLIGHT_CURVE = [
|
||||
@@ -44,66 +50,171 @@ _DAYLIGHT_CURVE = [
|
||||
(24.0, 10, 10, 30), # midnight (wrap)
|
||||
]
|
||||
|
||||
# Pre-build a (1440, 3) uint8 LUT — one entry per minute of the day
|
||||
# Reference solar times the canonical curve was designed around
|
||||
_DEFAULT_SUNRISE = 6.0
|
||||
_DEFAULT_SUNSET = 19.0
|
||||
|
||||
# Global cache of the default static LUT (lazy-built once)
|
||||
_daylight_lut: Optional[np.ndarray] = None
|
||||
|
||||
|
||||
def _get_daylight_lut() -> np.ndarray:
|
||||
global _daylight_lut
|
||||
if _daylight_lut is not None:
|
||||
return _daylight_lut
|
||||
# ── Solar position helpers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _compute_solar_times(
|
||||
latitude: float, longitude: float, day_of_year: int
|
||||
) -> tuple:
|
||||
"""Return (sunrise_hour, sunset_hour) in local solar time.
|
||||
|
||||
Uses simplified NOAA solar equations:
|
||||
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
|
||||
- hour angle: cos(ha) = -tan(lat) * tan(decl)
|
||||
- sunrise/sunset: 12 ∓ ha/15, shifted by longitude
|
||||
|
||||
Polar day and polar night are clamped to visible ranges.
|
||||
"""
|
||||
deg2rad = math.pi / 180.0
|
||||
|
||||
decl_deg = 23.45 * math.sin(2.0 * math.pi * (284 + day_of_year) / 365.0)
|
||||
decl_rad = decl_deg * deg2rad
|
||||
lat_rad = latitude * deg2rad
|
||||
|
||||
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
|
||||
|
||||
if cos_ha <= -1.0:
|
||||
# Polar day — sun never sets
|
||||
sunrise = 3.0
|
||||
sunset = 21.0
|
||||
elif cos_ha >= 1.0:
|
||||
# Polar night — sun never rises
|
||||
sunrise = 12.0
|
||||
sunset = 12.0
|
||||
else:
|
||||
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
|
||||
lon_offset = longitude / 15.0
|
||||
solar_noon = 12.0 - lon_offset
|
||||
sunrise = solar_noon - ha_hours
|
||||
sunset = solar_noon + ha_hours
|
||||
|
||||
# Clamp to sane ranges
|
||||
sunrise = max(3.0, min(10.0, sunrise))
|
||||
sunset = max(14.0, min(21.0, sunset))
|
||||
return sunrise, sunset
|
||||
|
||||
|
||||
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
|
||||
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
|
||||
|
||||
The canonical _DAYLIGHT_CURVE is remapped so that:
|
||||
- Night before dawn: 0 h → sunrise maps to 0 h → _DEFAULT_SUNRISE
|
||||
- Daylight window: sunrise → sunset maps to _DEFAULT_SUNRISE → _DEFAULT_SUNSET
|
||||
- Night after dusk: sunset → 24 h maps to _DEFAULT_SUNSET → 24 h
|
||||
"""
|
||||
default_night_before = _DEFAULT_SUNRISE
|
||||
default_day_len = _DEFAULT_SUNSET - _DEFAULT_SUNRISE
|
||||
default_night_after = 24.0 - _DEFAULT_SUNSET
|
||||
|
||||
actual_night_before = max(sunrise, 0.01)
|
||||
actual_day_len = max(sunset - sunrise, 0.25)
|
||||
actual_night_after = max(24.0 - sunset, 0.01)
|
||||
|
||||
lut = np.zeros((1440, 3), dtype=np.uint8)
|
||||
for minute in range(1440):
|
||||
hour = minute / 60.0
|
||||
# Find surrounding control points
|
||||
|
||||
if hour < sunrise:
|
||||
frac = hour / actual_night_before
|
||||
canon_hour = frac * default_night_before
|
||||
elif hour < sunset:
|
||||
frac = (hour - sunrise) / actual_day_len
|
||||
canon_hour = _DEFAULT_SUNRISE + frac * default_day_len
|
||||
else:
|
||||
frac = (hour - sunset) / actual_night_after
|
||||
canon_hour = _DEFAULT_SUNSET + frac * default_night_after
|
||||
|
||||
canon_hour = max(0.0, min(23.99, canon_hour))
|
||||
|
||||
# Locate surrounding curve control points
|
||||
prev = _DAYLIGHT_CURVE[0]
|
||||
nxt = _DAYLIGHT_CURVE[-1]
|
||||
for i in range(len(_DAYLIGHT_CURVE) - 1):
|
||||
if _DAYLIGHT_CURVE[i][0] <= hour <= _DAYLIGHT_CURVE[i + 1][0]:
|
||||
if _DAYLIGHT_CURVE[i][0] <= canon_hour <= _DAYLIGHT_CURVE[i + 1][0]:
|
||||
prev = _DAYLIGHT_CURVE[i]
|
||||
nxt = _DAYLIGHT_CURVE[i + 1]
|
||||
break
|
||||
span = nxt[0] - prev[0]
|
||||
t = (hour - prev[0]) / span if span > 0 else 0.0
|
||||
# Smooth interpolation (smoothstep)
|
||||
t = t * t * (3 - 2 * t)
|
||||
for ch in range(3):
|
||||
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
|
||||
|
||||
_daylight_lut = lut
|
||||
span = nxt[0] - prev[0]
|
||||
t = (canon_hour - prev[0]) / span if span > 0 else 0.0
|
||||
t = t * t * (3.0 - 2.0 * t) # smoothstep
|
||||
|
||||
for ch in range(3):
|
||||
lut[minute, ch] = int(
|
||||
prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5
|
||||
)
|
||||
|
||||
return lut
|
||||
|
||||
|
||||
def _get_daylight_lut() -> np.ndarray:
|
||||
"""Return the static default LUT (built once, cached globally)."""
|
||||
global _daylight_lut
|
||||
if _daylight_lut is None:
|
||||
_daylight_lut = _build_lut_for_solar_times(_DEFAULT_SUNRISE, _DEFAULT_SUNSET)
|
||||
return _daylight_lut
|
||||
|
||||
|
||||
# ── Stream class ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DaylightColorStripStream(ColorStripStream):
|
||||
"""Color strip stream simulating a 24-hour daylight cycle.
|
||||
|
||||
All LEDs display the same color at any moment. The color smoothly
|
||||
transitions through a pre-defined daylight curve.
|
||||
transitions through a pre-defined daylight curve whose sunrise/sunset
|
||||
times are computed from latitude, longitude, and day of year.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
self._colors_lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 10 # low FPS — transitions are slow
|
||||
self._fps = 10
|
||||
self._frame_time = 1.0 / 10
|
||||
self._clock = None
|
||||
self._led_count = 1
|
||||
self._auto_size = True
|
||||
self._lut = _get_daylight_lut()
|
||||
# Per-instance LUT cache: {(sr_min, ss_min): np.ndarray}
|
||||
self._lut_cache: dict = {}
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
self._speed = float(getattr(source, "speed", 1.0))
|
||||
self._use_real_time = bool(getattr(source, "use_real_time", False))
|
||||
self._latitude = float(getattr(source, "latitude", 50.0))
|
||||
self._longitude = float(getattr(source, "longitude", 0.0))
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||
self._lut_cache = {}
|
||||
with self._colors_lock:
|
||||
self._colors: Optional[np.ndarray] = None
|
||||
|
||||
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
|
||||
"""Return a solar-time-aware LUT for the given day (cached)."""
|
||||
sunrise, sunset = _compute_solar_times(
|
||||
self._latitude, self._longitude, day_of_year
|
||||
)
|
||||
sr_key = int(round(sunrise * 60))
|
||||
ss_key = int(round(sunset * 60))
|
||||
cache_key = (sr_key, ss_key)
|
||||
lut = self._lut_cache.get(cache_key)
|
||||
if lut is None:
|
||||
lut = _build_lut_for_solar_times(sunrise, sunset)
|
||||
if len(self._lut_cache) > 8:
|
||||
self._lut_cache.clear()
|
||||
self._lut_cache[cache_key] = lut
|
||||
return lut
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
if self._auto_size and device_led_count > 0:
|
||||
new_count = max(self._led_count, device_led_count)
|
||||
@@ -193,18 +304,20 @@ class DaylightColorStripStream(ColorStripStream):
|
||||
_use_a = not _use_a
|
||||
|
||||
if self._use_real_time:
|
||||
# Use actual wall-clock time
|
||||
import datetime
|
||||
now = datetime.datetime.now()
|
||||
day_of_year = now.timetuple().tm_yday
|
||||
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
|
||||
else:
|
||||
# Simulated cycle: speed=1.0 → full 24h in ~240s (4 min)
|
||||
# Simulated: speed=1.0 → full 24h in 240s.
|
||||
# Use summer solstice (day 172) for maximum day length.
|
||||
day_of_year = 172
|
||||
cycle_seconds = 240.0 / max(speed, 0.01)
|
||||
phase = (t % cycle_seconds) / cycle_seconds # 0..1
|
||||
phase = (t % cycle_seconds) / cycle_seconds
|
||||
minute_of_day = phase * 1440.0
|
||||
|
||||
lut = self._get_lut_for_day(day_of_year)
|
||||
idx = int(minute_of_day) % 1440
|
||||
color = self._lut[idx]
|
||||
color = lut[idx]
|
||||
buf[:] = color
|
||||
|
||||
with self._colors_lock:
|
||||
|
||||
@@ -42,11 +42,21 @@ _PALETTE_DEFS: Dict[str, list] = {
|
||||
_palette_cache: Dict[str, np.ndarray] = {}
|
||||
|
||||
|
||||
def _build_palette_lut(name: str) -> np.ndarray:
|
||||
"""Build a (256, 3) uint8 lookup table for the named palette."""
|
||||
if name in _palette_cache:
|
||||
return _palette_cache[name]
|
||||
points = _PALETTE_DEFS.get(name, _PALETTE_DEFS["fire"])
|
||||
def _build_palette_lut(name: str, custom_stops: list = None) -> np.ndarray:
|
||||
"""Build a (256, 3) uint8 lookup table for the named palette.
|
||||
|
||||
When name == "custom" and custom_stops is provided, builds from those
|
||||
stops without caching (each source gets its own LUT).
|
||||
"""
|
||||
if custom_stops and name == "custom":
|
||||
# Convert [[pos,R,G,B], ...] to [(pos,R,G,B), ...]
|
||||
points = [(s[0], s[1], s[2], s[3]) for s in custom_stops if len(s) >= 4]
|
||||
if not points:
|
||||
points = _PALETTE_DEFS["fire"]
|
||||
else:
|
||||
if name in _palette_cache:
|
||||
return _palette_cache[name]
|
||||
points = _PALETTE_DEFS.get(name, _PALETTE_DEFS["fire"])
|
||||
lut = np.zeros((256, 3), dtype=np.uint8)
|
||||
for i in range(256):
|
||||
t = i / 255.0
|
||||
@@ -67,7 +77,8 @@ def _build_palette_lut(name: str) -> np.ndarray:
|
||||
int(ag + (bg - ag) * frac),
|
||||
int(ab + (bb - ab) * frac),
|
||||
)
|
||||
_palette_cache[name] = lut
|
||||
if name != "custom":
|
||||
_palette_cache[name] = lut
|
||||
return lut
|
||||
|
||||
|
||||
@@ -164,6 +175,13 @@ _EFFECT_DEFAULT_PALETTE = {
|
||||
"plasma": "rainbow",
|
||||
"noise": "rainbow",
|
||||
"aurora": "aurora",
|
||||
"rain": "ocean",
|
||||
"comet": "fire",
|
||||
"bouncing_ball": "rainbow",
|
||||
"fireworks": "rainbow",
|
||||
"sparkle_rain": "ice",
|
||||
"lava_lamp": "lava",
|
||||
"wave_interference": "rainbow",
|
||||
}
|
||||
|
||||
|
||||
@@ -200,15 +218,47 @@ class EffectColorStripStream(ColorStripStream):
|
||||
self._s_layer2: Optional[np.ndarray] = None
|
||||
self._plasma_key = (0, 0.0)
|
||||
self._plasma_x: Optional[np.ndarray] = None
|
||||
# Bouncing ball state
|
||||
self._ball_positions: Optional[np.ndarray] = None
|
||||
self._ball_velocities: Optional[np.ndarray] = None
|
||||
self._ball_last_t = 0.0
|
||||
# Fireworks state
|
||||
self._fw_particles: list = [] # active particles
|
||||
self._fw_rockets: list = [] # active rockets
|
||||
self._fw_last_launch = 0.0
|
||||
# Sparkle rain state
|
||||
self._sparkle_state: Optional[np.ndarray] = None # per-LED brightness 0..1
|
||||
self._gradient_store = None # injected by stream manager
|
||||
self._update_from_source(source)
|
||||
|
||||
def set_gradient_store(self, gradient_store) -> None:
|
||||
"""Inject gradient store for palette resolution. Called by stream manager."""
|
||||
self._gradient_store = gradient_store
|
||||
# Re-resolve palette now that store is available
|
||||
self._resolve_palette_lut()
|
||||
|
||||
def _resolve_palette_lut(self) -> None:
|
||||
"""Build palette LUT from gradient_id or legacy palette name."""
|
||||
gradient_id = self._gradient_id
|
||||
if gradient_id and self._gradient_store:
|
||||
stops = self._gradient_store.resolve_stops(gradient_id)
|
||||
if stops:
|
||||
# Convert gradient entity stops to palette LUT stops
|
||||
custom = [[s["position"], *s["color"]] for s in stops]
|
||||
self._palette_lut = _build_palette_lut("custom", custom)
|
||||
return
|
||||
# Fallback: legacy palette name or custom_palette
|
||||
self._palette_lut = _build_palette_lut(self._palette_name, self._custom_palette)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
self._effect_type = getattr(source, "effect_type", "fire")
|
||||
_lc = getattr(source, "led_count", 0)
|
||||
self._auto_size = not _lc
|
||||
self._led_count = _lc if _lc and _lc > 0 else 1
|
||||
self._gradient_id = getattr(source, "gradient_id", None)
|
||||
self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(self._effect_type, "fire")
|
||||
self._palette_lut = _build_palette_lut(self._palette_name)
|
||||
self._custom_palette = getattr(source, "custom_palette", None)
|
||||
self._resolve_palette_lut()
|
||||
color = getattr(source, "color", None)
|
||||
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
|
||||
self._intensity = float(getattr(source, "intensity", 1.0))
|
||||
@@ -290,6 +340,13 @@ class EffectColorStripStream(ColorStripStream):
|
||||
"plasma": self._render_plasma,
|
||||
"noise": self._render_noise,
|
||||
"aurora": self._render_aurora,
|
||||
"rain": self._render_rain,
|
||||
"comet": self._render_comet,
|
||||
"bouncing_ball": self._render_bouncing_ball,
|
||||
"fireworks": self._render_fireworks,
|
||||
"sparkle_rain": self._render_sparkle_rain,
|
||||
"lava_lamp": self._render_lava_lamp,
|
||||
"wave_interference": self._render_wave_interference,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -555,3 +612,329 @@ class EffectColorStripStream(ColorStripStream):
|
||||
self._s_f32_rgb *= bright[:, np.newaxis]
|
||||
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
|
||||
np.copyto(buf, self._s_f32_rgb, casting='unsafe')
|
||||
|
||||
# ── Rain ──────────────────────────────────────────────────────────
|
||||
|
||||
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Raindrops falling down the strip with trailing tails."""
|
||||
speed = self._effective_speed
|
||||
intensity = self._intensity
|
||||
scale = self._scale
|
||||
lut = self._palette_lut
|
||||
|
||||
# Multiple rain "lanes" at different speeds for depth
|
||||
bright = self._s_f32_a
|
||||
bright[:] = 0.0
|
||||
indices = self._s_arange
|
||||
|
||||
num_drops = max(3, int(8 * intensity))
|
||||
for d in range(num_drops):
|
||||
drop_speed = speed * (6.0 + d * 2.3) * scale
|
||||
phase_offset = d * 31.7 # prime-ish offset for independence
|
||||
# Drop position wraps around the strip
|
||||
pos = (t * drop_speed + phase_offset) % n
|
||||
# Tail: exponential falloff behind drop (drop falls downward = decreasing index)
|
||||
dist = (pos - indices) % n
|
||||
tail_len = max(3.0, n * 0.08)
|
||||
trail = np.exp(-dist / tail_len)
|
||||
# Head brightness boost
|
||||
head_mask = dist < 2.0
|
||||
trail[head_mask] = 1.0
|
||||
bright += trail * (0.3 / max(num_drops * 0.3, 1.0))
|
||||
|
||||
np.clip(bright, 0.0, 1.0, out=bright)
|
||||
np.multiply(bright, 255, out=self._s_f32_b)
|
||||
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
|
||||
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
||||
buf[:] = lut[self._s_i32]
|
||||
|
||||
# ── Comet ─────────────────────────────────────────────────────────
|
||||
|
||||
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Multiple comets with curved, pulsing tails."""
|
||||
speed = self._effective_speed
|
||||
intensity = self._intensity
|
||||
color = self._color
|
||||
mirror = self._mirror
|
||||
|
||||
indices = self._s_arange
|
||||
buf[:] = 0
|
||||
|
||||
num_comets = 3
|
||||
for c in range(num_comets):
|
||||
travel_speed = speed * (5.0 + c * 3.0)
|
||||
phase = c * 137.5
|
||||
|
||||
if mirror:
|
||||
cycle = 2 * (n - 1) if n > 1 else 1
|
||||
raw_pos = (t * travel_speed + phase) % cycle
|
||||
pos = raw_pos if raw_pos < n else cycle - raw_pos
|
||||
else:
|
||||
pos = (t * travel_speed + phase) % n
|
||||
|
||||
# Tail with pulsing brightness
|
||||
dist = self._s_f32_a
|
||||
np.subtract(pos, indices, out=dist)
|
||||
dist %= n
|
||||
|
||||
decay = 0.04 + 0.20 * (1.0 - min(1.0, intensity))
|
||||
np.multiply(dist, -decay, out=self._s_f32_b)
|
||||
np.exp(self._s_f32_b, out=self._s_f32_b)
|
||||
# Pulse modulation on tail
|
||||
pulse = 0.7 + 0.3 * math.sin(t * speed * 4.0 + c * 2.1)
|
||||
self._s_f32_b *= pulse
|
||||
|
||||
r, g, b = color
|
||||
for ch_idx, ch_val in enumerate((r, g, b)):
|
||||
np.multiply(self._s_f32_b, ch_val, out=self._s_f32_c)
|
||||
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
|
||||
# Additive blend
|
||||
self._s_f32_a[:] = buf[:, ch_idx]
|
||||
self._s_f32_a += self._s_f32_c
|
||||
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
||||
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')
|
||||
|
||||
# ── Bouncing Ball ─────────────────────────────────────────────────
|
||||
|
||||
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Physics-simulated bouncing balls with gravity."""
|
||||
speed = self._effective_speed
|
||||
intensity = self._intensity
|
||||
color = self._color
|
||||
|
||||
num_balls = 3
|
||||
# Initialize ball state on first call or LED count change
|
||||
if self._ball_positions is None or len(self._ball_positions) != num_balls:
|
||||
self._ball_positions = np.array([n * 0.3, n * 0.5, n * 0.8], dtype=np.float64)
|
||||
self._ball_velocities = np.array([0.0, 0.0, 0.0], dtype=np.float64)
|
||||
self._ball_last_t = t
|
||||
|
||||
dt = min(t - self._ball_last_t, 0.1) # cap delta to avoid explosion
|
||||
self._ball_last_t = t
|
||||
if dt <= 0:
|
||||
dt = 1.0 / 30
|
||||
|
||||
gravity = 50.0 * intensity * speed
|
||||
damping = 0.85
|
||||
|
||||
for i in range(num_balls):
|
||||
self._ball_velocities[i] += gravity * dt
|
||||
self._ball_positions[i] += self._ball_velocities[i] * dt
|
||||
# Bounce off bottom (index n-1)
|
||||
if self._ball_positions[i] >= n - 1:
|
||||
self._ball_positions[i] = n - 1
|
||||
self._ball_velocities[i] = -abs(self._ball_velocities[i]) * damping
|
||||
# Re-launch if nearly stopped
|
||||
if abs(self._ball_velocities[i]) < 2.0:
|
||||
self._ball_velocities[i] = -30.0 * speed * (0.8 + 0.4 * (i / num_balls))
|
||||
# Bounce off top
|
||||
if self._ball_positions[i] < 0:
|
||||
self._ball_positions[i] = 0
|
||||
self._ball_velocities[i] = abs(self._ball_velocities[i]) * damping
|
||||
|
||||
# Render balls with glow radius
|
||||
buf[:] = 0
|
||||
indices = self._s_arange
|
||||
r, g, b = color
|
||||
for i in range(num_balls):
|
||||
pos = self._ball_positions[i]
|
||||
dist = self._s_f32_a
|
||||
np.subtract(indices, pos, out=dist)
|
||||
np.abs(dist, out=dist)
|
||||
# Gaussian glow, radius ~3 LEDs
|
||||
np.multiply(dist, dist, out=self._s_f32_b)
|
||||
self._s_f32_b *= -0.5
|
||||
np.exp(self._s_f32_b, out=self._s_f32_b)
|
||||
# Hue offset per ball
|
||||
hue_shift = i / num_balls
|
||||
br = r * (1 - hue_shift * 0.5)
|
||||
bg = g * (0.5 + hue_shift * 0.5)
|
||||
bb = b * (0.3 + hue_shift * 0.7)
|
||||
for ch_idx, ch_val in enumerate((br, bg, bb)):
|
||||
np.multiply(self._s_f32_b, ch_val, out=self._s_f32_c)
|
||||
self._s_f32_a[:] = buf[:, ch_idx]
|
||||
self._s_f32_a += self._s_f32_c
|
||||
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
||||
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')
|
||||
|
||||
# ── Fireworks ─────────────────────────────────────────────────────
|
||||
|
||||
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Rockets launch and explode into colorful particle bursts."""
|
||||
speed = self._effective_speed
|
||||
intensity = self._intensity
|
||||
lut = self._palette_lut
|
||||
|
||||
dt = 1.0 / max(self._fps, 1)
|
||||
|
||||
# Launch rockets periodically
|
||||
launch_interval = max(0.3, 1.5 / max(intensity, 0.1))
|
||||
if t - self._fw_last_launch > launch_interval:
|
||||
self._fw_last_launch = t
|
||||
# Rocket: [position, velocity, palette_idx]
|
||||
target = 0.2 + np.random.random() * 0.5 # explode at 20-70% height
|
||||
rocket_speed = n * (0.8 + np.random.random() * 0.4) * speed
|
||||
self._fw_rockets.append([float(n - 1), -rocket_speed, target * n])
|
||||
|
||||
# Update rockets
|
||||
new_rockets = []
|
||||
for rocket in self._fw_rockets:
|
||||
rocket[0] += rocket[1] * dt
|
||||
if rocket[0] <= rocket[2]:
|
||||
# Explode: create particles
|
||||
num_particles = int(8 + 8 * intensity)
|
||||
palette_idx = np.random.randint(0, 256)
|
||||
for _ in range(num_particles):
|
||||
vel = (np.random.random() - 0.5) * n * 0.5 * speed
|
||||
# [position, velocity, brightness, palette_idx]
|
||||
self._fw_particles.append([rocket[0], vel, 1.0, palette_idx])
|
||||
else:
|
||||
new_rockets.append(rocket)
|
||||
self._fw_rockets = new_rockets
|
||||
|
||||
# Update particles
|
||||
new_particles = []
|
||||
for p in self._fw_particles:
|
||||
p[0] += p[1] * dt
|
||||
p[1] *= 0.97 # drag
|
||||
p[2] -= dt * 1.5 # fade
|
||||
if p[2] > 0.02 and 0 <= p[0] < n:
|
||||
new_particles.append(p)
|
||||
self._fw_particles = new_particles
|
||||
|
||||
# Cap active particles
|
||||
if len(self._fw_particles) > 200:
|
||||
self._fw_particles = self._fw_particles[-200:]
|
||||
|
||||
# Render
|
||||
buf[:] = 0
|
||||
for p in self._fw_particles:
|
||||
pos, _vel, bright, pal_idx = p
|
||||
idx = int(pos)
|
||||
if 0 <= idx < n:
|
||||
color = lut[int(pal_idx) % 256]
|
||||
for ch in range(3):
|
||||
val = int(buf[idx, ch] + color[ch] * bright)
|
||||
buf[idx, ch] = min(255, val)
|
||||
# Spread to neighbors
|
||||
for offset in (-1, 1):
|
||||
ni = idx + offset
|
||||
if 0 <= ni < n:
|
||||
for ch in range(3):
|
||||
val = int(buf[ni, ch] + color[ch] * bright * 0.4)
|
||||
buf[ni, ch] = min(255, val)
|
||||
|
||||
# Render rockets as bright white dots
|
||||
for rocket in self._fw_rockets:
|
||||
idx = int(rocket[0])
|
||||
if 0 <= idx < n:
|
||||
buf[idx] = (255, 255, 255)
|
||||
|
||||
# ── Sparkle Rain ──────────────────────────────────────────────────
|
||||
|
||||
def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Twinkling star field with smooth fade-in/fade-out."""
|
||||
speed = self._effective_speed
|
||||
intensity = self._intensity
|
||||
lut = self._palette_lut
|
||||
|
||||
# Initialize/resize sparkle state
|
||||
if self._sparkle_state is None or len(self._sparkle_state) != n:
|
||||
self._sparkle_state = np.zeros(n, dtype=np.float32)
|
||||
|
||||
dt = 1.0 / max(self._fps, 1)
|
||||
state = self._sparkle_state
|
||||
|
||||
# Fade existing sparkles
|
||||
fade_rate = 1.5 * speed
|
||||
state -= fade_rate * dt
|
||||
np.clip(state, 0.0, 1.0, out=state)
|
||||
|
||||
# Spawn new sparkles
|
||||
spawn_prob = 0.05 * intensity * speed
|
||||
rng = np.random.random(n)
|
||||
new_mask = (rng < spawn_prob) & (state < 0.1)
|
||||
state[new_mask] = 1.0
|
||||
|
||||
# Map sparkle brightness to palette
|
||||
np.multiply(state, 255, out=self._s_f32_a)
|
||||
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
|
||||
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
||||
self._s_f32_rgb[:] = lut[self._s_i32]
|
||||
# Apply brightness
|
||||
self._s_f32_rgb *= state[:, np.newaxis]
|
||||
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
|
||||
np.copyto(buf, self._s_f32_rgb, casting='unsafe')
|
||||
|
||||
# ── Lava Lamp ─────────────────────────────────────────────────────
|
||||
|
||||
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Slow-moving colored blobs that merge and separate."""
|
||||
speed = self._effective_speed
|
||||
scale = self._scale
|
||||
lut = self._palette_lut
|
||||
|
||||
# Use noise at very low frequency for blob movement
|
||||
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
|
||||
|
||||
# Two blob layers at different speeds for organic movement
|
||||
self._s_f32_a += t * speed * 0.1
|
||||
layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()
|
||||
|
||||
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
|
||||
self._s_f32_a += t * speed * 0.07 + 100.0
|
||||
layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()
|
||||
|
||||
# Combine: create blob-like shapes with soft edges
|
||||
combined = self._s_f32_a
|
||||
np.multiply(layer1, 0.6, out=combined)
|
||||
combined += layer2 * 0.4
|
||||
# Sharpen to create distinct blobs (sigmoid-like)
|
||||
combined -= 0.45
|
||||
combined *= 6.0
|
||||
# Soft clamp via tanh approximation: x / (1 + |x|)
|
||||
np.abs(combined, out=self._s_f32_b)
|
||||
self._s_f32_b += 1.0
|
||||
np.divide(combined, self._s_f32_b, out=combined)
|
||||
# Map from [-1,1] to [0,1]
|
||||
combined += 1.0
|
||||
combined *= 0.5
|
||||
np.clip(combined, 0.0, 1.0, out=combined)
|
||||
|
||||
# Map to palette
|
||||
np.multiply(combined, 255, out=self._s_f32_b)
|
||||
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
|
||||
np.clip(self._s_i32, 0, 255, out=self._s_i32)
|
||||
buf[:] = lut[self._s_i32]
|
||||
|
||||
# ── Wave Interference ─────────────────────────────────────────────
|
||||
|
||||
def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Two counter-propagating sine waves creating interference patterns."""
|
||||
speed = self._effective_speed
|
||||
scale = self._scale
|
||||
lut = self._palette_lut
|
||||
|
||||
# Wave parameters
|
||||
k = 2.0 * math.pi * scale / max(n, 1) # wavenumber
|
||||
omega = speed * 2.0 # angular frequency
|
||||
|
||||
# Wave 1: right-propagating
|
||||
indices = self._s_arange
|
||||
np.multiply(indices, k, out=self._s_f32_a)
|
||||
self._s_f32_a -= omega * t
|
||||
np.sin(self._s_f32_a, out=self._s_f32_a)
|
||||
|
||||
# Wave 2: left-propagating at slightly different frequency
|
||||
np.multiply(indices, k * 1.1, out=self._s_f32_b)
|
||||
self._s_f32_b += omega * t * 0.9
|
||||
np.sin(self._s_f32_b, out=self._s_f32_b)
|
||||
|
||||
# Interference: sum and normalize to [0, 255]
|
||||
self._s_f32_a += self._s_f32_b
|
||||
# Range is [-2, 2], map to [0, 255]
|
||||
self._s_f32_a += 2.0
|
||||
self._s_f32_a *= (255.0 / 4.0)
|
||||
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
|
||||
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
|
||||
buf[:] = lut[self._s_i32]
|
||||
|
||||
@@ -9,8 +9,8 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from wled_controller.core.processing.live_stream import LiveStream
|
||||
from wled_controller.core.capture.screen_capture import (
|
||||
@@ -46,8 +46,7 @@ def _process_kc_frame(capture, rect_names, rect_bounds, calc_fn, prev_colors_arr
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Downsample to working resolution — 144x fewer pixels at 1080p
|
||||
pil_img = Image.fromarray(capture.image)
|
||||
small = np.array(pil_img.resize(KC_WORK_SIZE, Image.LANCZOS))
|
||||
small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
|
||||
|
||||
# Extract colors for each rectangle from the small image
|
||||
n = len(rect_names)
|
||||
|
||||
@@ -22,7 +22,11 @@ from wled_controller.core.processing.live_stream import (
|
||||
ScreenCaptureLiveStream,
|
||||
StaticImageLiveStream,
|
||||
)
|
||||
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
|
||||
try:
|
||||
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
|
||||
_has_video = True
|
||||
except ImportError:
|
||||
_has_video = False
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -264,8 +268,13 @@ class LiveStreamManager:
|
||||
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
||||
return resolved
|
||||
|
||||
def _create_video_live_stream(self, config) -> VideoCaptureLiveStream:
|
||||
def _create_video_live_stream(self, config):
|
||||
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
|
||||
if not _has_video:
|
||||
raise ImportError(
|
||||
"OpenCV is required for video stream support. "
|
||||
"Install it with: pip install opencv-python-headless"
|
||||
)
|
||||
stream = VideoCaptureLiveStream(
|
||||
url=config.url,
|
||||
loop=config.loop,
|
||||
@@ -302,20 +311,16 @@ class LiveStreamManager:
|
||||
This is acceptable because acquire() (the only caller chain) is always
|
||||
invoked from background worker threads, never from the async event loop.
|
||||
"""
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
|
||||
|
||||
if image_source.startswith(("http://", "https://")):
|
||||
response = httpx.get(image_source, timeout=15.0, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(BytesIO(response.content))
|
||||
return load_image_bytes(response.content)
|
||||
else:
|
||||
path = Path(image_source)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Image file not found: {image_source}")
|
||||
pil_image = Image.open(path)
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
return np.array(pil_image)
|
||||
return load_image_file(path)
|
||||
|
||||
@@ -106,7 +106,9 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
color = _hex_to_rgb(self._default_color)
|
||||
|
||||
# Push event to queue (thread-safe deque.append)
|
||||
self._event_queue.append({"color": color, "start": time.monotonic()})
|
||||
# Priority: 0 = normal, 1 = high (high interrupts current effect)
|
||||
priority = 1 if color_override else 0
|
||||
self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})
|
||||
return True
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
@@ -190,11 +192,12 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
frame_time = self._frame_time
|
||||
|
||||
try:
|
||||
# Check for new events
|
||||
# Check for new events — high priority interrupts current
|
||||
while self._event_queue:
|
||||
try:
|
||||
event = self._event_queue.popleft()
|
||||
self._active_effect = event
|
||||
if self._active_effect is None or event.get("priority", 0) >= self._active_effect.get("priority", 0):
|
||||
self._active_effect = event
|
||||
except IndexError:
|
||||
break
|
||||
|
||||
@@ -247,6 +250,10 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
self._render_pulse(buf, n, color, progress)
|
||||
elif effect == "sweep":
|
||||
self._render_sweep(buf, n, color, progress)
|
||||
elif effect == "chase":
|
||||
self._render_chase(buf, n, color, progress)
|
||||
elif effect == "gradient_flash":
|
||||
self._render_gradient_flash(buf, n, color, progress)
|
||||
else:
|
||||
# Default: flash
|
||||
self._render_flash(buf, n, color, progress)
|
||||
@@ -296,3 +303,62 @@ class NotificationColorStripStream(ColorStripStream):
|
||||
buf[:, 0] = r
|
||||
buf[:, 1] = g
|
||||
buf[:, 2] = b
|
||||
|
||||
def _render_chase(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
|
||||
"""Chase effect: light travels across strip and bounces back.
|
||||
|
||||
First half: light moves left-to-right with a glowing tail.
|
||||
Second half: light moves right-to-left back to start.
|
||||
Overall brightness fades as progress approaches 1.0.
|
||||
"""
|
||||
buf[:] = 0
|
||||
if n <= 0:
|
||||
return
|
||||
|
||||
# Position: bounce (0→n→0)
|
||||
if progress < 0.5:
|
||||
pos = progress * 2.0 * (n - 1)
|
||||
else:
|
||||
pos = (1.0 - (progress - 0.5) * 2.0) * (n - 1)
|
||||
|
||||
# Overall fade
|
||||
fade = max(0.0, 1.0 - progress * 0.6)
|
||||
|
||||
# Glow radius: ~5% of strip, minimum 2 LEDs
|
||||
radius = max(2.0, n * 0.05)
|
||||
|
||||
for i in range(n):
|
||||
dist = abs(i - pos)
|
||||
if dist < radius * 3:
|
||||
glow = math.exp(-0.5 * (dist / radius) ** 2) * fade
|
||||
buf[i, 0] = min(255, int(color[0] * glow))
|
||||
buf[i, 1] = min(255, int(color[1] * glow))
|
||||
buf[i, 2] = min(255, int(color[2] * glow))
|
||||
|
||||
def _render_gradient_flash(self, buf: np.ndarray, n: int, color: tuple, progress: float) -> None:
|
||||
"""Gradient flash: bright center fades to edges, then all fades out.
|
||||
|
||||
Creates a gradient from the notification color at center to darker
|
||||
edges, with overall brightness fading over the duration.
|
||||
"""
|
||||
if n <= 0:
|
||||
return
|
||||
|
||||
# Overall brightness envelope: quick attack, exponential decay
|
||||
if progress < 0.1:
|
||||
brightness = progress / 0.1
|
||||
else:
|
||||
brightness = math.exp(-3.0 * (progress - 0.1))
|
||||
|
||||
# Center-to-edge gradient
|
||||
center = n / 2.0
|
||||
max_dist = center if center > 0 else 1.0
|
||||
|
||||
for i in range(n):
|
||||
dist = abs(i - center) / max_dist
|
||||
# Smooth falloff from center
|
||||
edge_factor = 1.0 - dist * 0.6
|
||||
b_final = brightness * edge_factor
|
||||
buf[i, 0] = min(255, int(color[0] * b_final))
|
||||
buf[i, 1] = min(255, int(color[1] * b_final))
|
||||
buf[i, 2] = min(255, int(color[2] * b_final))
|
||||
|
||||
@@ -55,6 +55,8 @@ class ProcessorDependencies:
|
||||
value_source_store: object = None
|
||||
sync_clock_manager: object = None
|
||||
cspt_store: object = None
|
||||
gradient_store: object = None
|
||||
weather_manager: object = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -129,6 +131,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
||||
audio_template_store=deps.audio_template_store,
|
||||
sync_clock_manager=deps.sync_clock_manager,
|
||||
cspt_store=deps.cspt_store,
|
||||
gradient_store=deps.gradient_store,
|
||||
weather_manager=deps.weather_manager,
|
||||
)
|
||||
self._value_stream_manager = ValueStreamManager(
|
||||
value_source_store=deps.value_source_store,
|
||||
|
||||
@@ -9,9 +9,14 @@ import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import cv2
|
||||
_has_cv2 = True
|
||||
except ImportError:
|
||||
_has_cv2 = False
|
||||
|
||||
from wled_controller.core.capture_engines.base import ScreenCapture
|
||||
from wled_controller.core.processing.live_stream import LiveStream
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -67,12 +72,22 @@ def resolve_youtube_url(url: str, resolution_limit: Optional[int] = None) -> str
|
||||
return stream_url
|
||||
|
||||
|
||||
def _require_cv2():
|
||||
"""Raise a clear error if OpenCV is not installed."""
|
||||
if not _has_cv2:
|
||||
raise ImportError(
|
||||
"OpenCV is required for camera and video support. "
|
||||
"Install it with: pip install opencv-python-headless"
|
||||
)
|
||||
|
||||
|
||||
def extract_thumbnail(url: str, resolution_limit: Optional[int] = None) -> Optional[np.ndarray]:
|
||||
"""Extract the first frame of a video as a thumbnail (RGB numpy array).
|
||||
|
||||
For YouTube URLs, resolves via yt-dlp first.
|
||||
Returns None on failure.
|
||||
"""
|
||||
_require_cv2()
|
||||
try:
|
||||
actual_url = url
|
||||
if is_youtube_url(url):
|
||||
@@ -127,6 +142,7 @@ class VideoCaptureLiveStream(LiveStream):
|
||||
resolution_limit: Optional[int] = None,
|
||||
target_fps: int = 30,
|
||||
):
|
||||
_require_cv2()
|
||||
self._original_url = url
|
||||
self._resolved_url: Optional[str] = None
|
||||
self._loop = loop
|
||||
@@ -136,7 +152,7 @@ class VideoCaptureLiveStream(LiveStream):
|
||||
self._resolution_limit = resolution_limit
|
||||
self._target_fps = target_fps
|
||||
|
||||
self._cap: Optional[cv2.VideoCapture] = None
|
||||
self._cap = None # Optional[cv2.VideoCapture]
|
||||
self._video_fps: float = 30.0
|
||||
self._total_frames: int = 0
|
||||
self._video_duration: float = 0.0
|
||||
|
||||
282
server/src/wled_controller/core/processing/weather_stream.py
Normal file
282
server/src/wled_controller/core/processing/weather_stream.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Weather-reactive color strip stream — maps weather conditions to ambient LED colors."""
|
||||
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.processing.color_strip_stream import ColorStripStream
|
||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.core.weather.weather_provider import DEFAULT_WEATHER, WeatherData
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ── WMO code → palette mapping ──────────────────────────────────────────
|
||||
# Each entry: (start_rgb, end_rgb) as (R, G, B) tuples.
|
||||
# Codes are matched by range.
|
||||
|
||||
_PALETTES = {
|
||||
# Clear sky / mainly clear
|
||||
(0, 1): ((255, 220, 100), (255, 180, 80)),
|
||||
# Partly cloudy / overcast
|
||||
(2, 3): ((150, 180, 220), (240, 235, 220)),
|
||||
# Fog
|
||||
(45, 48): ((180, 190, 200), (210, 215, 220)),
|
||||
# Drizzle (light, moderate, dense, freezing)
|
||||
(51, 53, 55, 56, 57): ((100, 160, 220), (80, 180, 190)),
|
||||
# Rain (slight, moderate, heavy, freezing)
|
||||
(61, 63, 65, 66, 67): ((40, 80, 180), (60, 140, 170)),
|
||||
# Snow (slight, moderate, heavy, grains)
|
||||
(71, 73, 75, 77): ((200, 210, 240), (180, 200, 255)),
|
||||
# Rain showers
|
||||
(80, 81, 82): ((30, 60, 160), (80, 70, 170)),
|
||||
# Snow showers
|
||||
(85, 86): ((220, 225, 240), (190, 185, 220)),
|
||||
# Thunderstorm
|
||||
(95, 96, 99): ((60, 20, 120), (40, 60, 200)),
|
||||
}
|
||||
|
||||
# Default palette (partly cloudy)
|
||||
_DEFAULT_PALETTE = ((150, 180, 220), (240, 235, 220))
|
||||
|
||||
|
||||
def _resolve_palette(code: int) -> tuple:
|
||||
"""Map a WMO weather code to a (start_rgb, end_rgb) palette."""
|
||||
for codes, palette in _PALETTES.items():
|
||||
if code in codes:
|
||||
return palette
|
||||
return _DEFAULT_PALETTE
|
||||
|
||||
|
||||
def _apply_temperature_shift(color: np.ndarray, temperature: float, influence: float) -> np.ndarray:
|
||||
"""Shift color array warm/cool based on temperature.
|
||||
|
||||
> 25°C: shift toward warm (add red, reduce blue)
|
||||
< 5°C: shift toward cool (add blue, reduce red)
|
||||
Between 5-25°C: linear interpolation (no shift at 15°C midpoint)
|
||||
"""
|
||||
if influence <= 0.0:
|
||||
return color
|
||||
|
||||
# Normalize temperature to -1..+1 range (cold..hot)
|
||||
t = (temperature - 15.0) / 10.0 # -1 at 5°C, 0 at 15°C, +1 at 25°C
|
||||
t = max(-1.0, min(1.0, t))
|
||||
shift = t * influence * 30.0 # max ±30 RGB units
|
||||
|
||||
result = color.astype(np.int16)
|
||||
result[:, 0] += int(shift) # red
|
||||
result[:, 2] -= int(shift) # blue
|
||||
np.clip(result, 0, 255, out=result)
|
||||
return result.astype(np.uint8)
|
||||
|
||||
|
||||
def _smoothstep(x: float) -> float:
|
||||
"""Hermite smoothstep: smooth cubic interpolation."""
|
||||
x = max(0.0, min(1.0, x))
|
||||
return x * x * (3.0 - 2.0 * x)
|
||||
|
||||
|
||||
class WeatherColorStripStream(ColorStripStream):
|
||||
"""Generates ambient LED colors based on real-time weather data.
|
||||
|
||||
Fetches weather data from a WeatherManager (which polls the API),
|
||||
maps the WMO condition code to a color palette, applies temperature
|
||||
influence, and animates a slow gradient drift across the strip.
|
||||
"""
|
||||
|
||||
def __init__(self, source, weather_manager: WeatherManager):
|
||||
self._source_id = source.id
|
||||
self._weather_source_id: str = source.weather_source_id
|
||||
self._speed: float = source.speed
|
||||
self._temperature_influence: float = source.temperature_influence
|
||||
self._clock_id: Optional[str] = source.clock_id
|
||||
self._weather_manager = weather_manager
|
||||
|
||||
self._led_count: int = 0 # auto-size from device
|
||||
self._fps: int = 30
|
||||
self._frame_time: float = 1.0 / 30
|
||||
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._latest_colors: Optional[np.ndarray] = None
|
||||
self._colors_lock = threading.Lock()
|
||||
|
||||
# Pre-allocated buffers
|
||||
self._buf_a: Optional[np.ndarray] = None
|
||||
self._buf_b: Optional[np.ndarray] = None
|
||||
self._use_a = True
|
||||
self._pool_n = 0
|
||||
|
||||
# Thunderstorm flash state
|
||||
self._flash_remaining = 0
|
||||
|
||||
# ── ColorStripStream interface ──────────────────────────────────
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return self._fps
|
||||
|
||||
def set_capture_fps(self, fps: int) -> None:
|
||||
self._fps = max(1, min(60, fps))
|
||||
self._frame_time = 1.0 / self._fps
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
return self._led_count
|
||||
|
||||
@property
|
||||
def is_animated(self) -> bool:
|
||||
return True
|
||||
|
||||
def start(self) -> None:
|
||||
# Acquire weather runtime (increments ref count)
|
||||
if self._weather_source_id:
|
||||
try:
|
||||
self._weather_manager.acquire(self._weather_source_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Weather stream {self._source_id}: failed to acquire weather source: {e}")
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._animate_loop, daemon=True,
|
||||
name=f"WeatherCSS-{self._source_id[:12]}",
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(f"WeatherColorStripStream started: {self._source_id}")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5.0)
|
||||
self._thread = None
|
||||
|
||||
# Release weather runtime
|
||||
if self._weather_source_id:
|
||||
try:
|
||||
self._weather_manager.release(self._weather_source_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Weather stream {self._source_id}: failed to release weather source: {e}")
|
||||
|
||||
logger.info(f"WeatherColorStripStream stopped: {self._source_id}")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
with self._colors_lock:
|
||||
return self._latest_colors
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
if device_led_count > 0 and device_led_count != self._led_count:
|
||||
self._led_count = device_led_count
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
self._speed = source.speed
|
||||
self._temperature_influence = source.temperature_influence
|
||||
self._clock_id = source.clock_id
|
||||
|
||||
# If weather source changed, release old + acquire new
|
||||
new_ws_id = source.weather_source_id
|
||||
if new_ws_id != self._weather_source_id:
|
||||
if self._weather_source_id:
|
||||
try:
|
||||
self._weather_manager.release(self._weather_source_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._weather_source_id = new_ws_id
|
||||
if new_ws_id:
|
||||
try:
|
||||
self._weather_manager.acquire(new_ws_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Weather stream: failed to acquire new source {new_ws_id}: {e}")
|
||||
|
||||
# ── Animation loop ──────────────────────────────────────────────
|
||||
|
||||
def _ensure_pool(self, n: int) -> None:
|
||||
if n == self._pool_n or n <= 0:
|
||||
return
|
||||
self._pool_n = n
|
||||
self._buf_a = np.zeros((n, 3), dtype=np.uint8)
|
||||
self._buf_b = np.zeros((n, 3), dtype=np.uint8)
|
||||
|
||||
def _get_clock_time(self) -> Optional[float]:
|
||||
"""Get time from sync clock if configured."""
|
||||
if not self._clock_id:
|
||||
return None
|
||||
try:
|
||||
# Access via weather manager's store isn't ideal, but clocks
|
||||
# are looked up at the ProcessorManager level when the stream
|
||||
# is created. For now, return None and use wall time.
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
n = self._led_count
|
||||
if n <= 0:
|
||||
time.sleep(self._frame_time)
|
||||
continue
|
||||
|
||||
self._ensure_pool(n)
|
||||
buf = self._buf_a if self._use_a else self._buf_b
|
||||
self._use_a = not self._use_a
|
||||
|
||||
# Get weather data
|
||||
weather = self._get_weather()
|
||||
palette_start, palette_end = _resolve_palette(weather.code)
|
||||
|
||||
# Convert to arrays
|
||||
c0 = np.array(palette_start, dtype=np.float32)
|
||||
c1 = np.array(palette_end, dtype=np.float32)
|
||||
|
||||
# Compute animation phase
|
||||
t = time.perf_counter() - start_time
|
||||
phase = (t * self._speed * 0.1) % 1.0
|
||||
|
||||
# Generate gradient with drift
|
||||
for i in range(n):
|
||||
frac = ((i / max(n - 1, 1)) + phase) % 1.0
|
||||
s = _smoothstep(frac)
|
||||
buf[i] = (c0 * (1.0 - s) + c1 * s).astype(np.uint8)
|
||||
|
||||
# Apply temperature shift
|
||||
if self._temperature_influence > 0.0:
|
||||
buf[:] = _apply_temperature_shift(buf, weather.temperature, self._temperature_influence)
|
||||
|
||||
# Thunderstorm flash effect
|
||||
is_thunderstorm = weather.code in (95, 96, 99)
|
||||
if is_thunderstorm:
|
||||
if self._flash_remaining > 0:
|
||||
buf[:] = 255
|
||||
self._flash_remaining -= 1
|
||||
elif random.random() < 0.015: # ~1.5% chance per frame
|
||||
self._flash_remaining = random.randint(2, 5)
|
||||
|
||||
with self._colors_lock:
|
||||
self._latest_colors = buf
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WeatherColorStripStream error: {e}", exc_info=True)
|
||||
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
time.sleep(max(self._frame_time - elapsed, 0.001))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal WeatherColorStripStream error: {e}", exc_info=True)
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def _get_weather(self) -> WeatherData:
|
||||
"""Get current weather data from the manager."""
|
||||
if not self._weather_source_id:
|
||||
return DEFAULT_WEATHER
|
||||
try:
|
||||
return self._weather_manager.get_data(self._weather_source_id)
|
||||
except Exception:
|
||||
return DEFAULT_WEATHER
|
||||
1
server/src/wled_controller/core/update/__init__.py
Normal file
1
server/src/wled_controller/core/update/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Auto-update — periodic release checking and notification."""
|
||||
54
server/src/wled_controller/core/update/gitea_provider.py
Normal file
54
server/src/wled_controller/core/update/gitea_provider.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Gitea release provider — fetches releases from a Gitea instance."""
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller.core.update.release_provider import AssetInfo, ReleaseInfo, ReleaseProvider
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GiteaReleaseProvider(ReleaseProvider):
|
||||
"""Fetch releases from a Gitea repository via its REST API."""
|
||||
|
||||
def __init__(self, base_url: str, repo: str, token: str = "") -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._repo = repo
|
||||
self._token = token
|
||||
|
||||
async def get_releases(self, limit: int = 10) -> list[ReleaseInfo]:
|
||||
url = f"{self._base_url}/api/v1/repos/{self._repo}/releases"
|
||||
headers: dict[str, str] = {}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"token {self._token}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, params={"limit": limit}, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
releases: list[ReleaseInfo] = []
|
||||
for item in data:
|
||||
tag = item.get("tag_name", "")
|
||||
version = tag.lstrip("v")
|
||||
assets = tuple(
|
||||
AssetInfo(
|
||||
name=a["name"],
|
||||
size=a.get("size", 0),
|
||||
download_url=a["browser_download_url"],
|
||||
)
|
||||
for a in item.get("assets", [])
|
||||
)
|
||||
releases.append(ReleaseInfo(
|
||||
tag=tag,
|
||||
version=version,
|
||||
name=item.get("name", tag),
|
||||
body=item.get("body", ""),
|
||||
prerelease=item.get("prerelease", False),
|
||||
published_at=item.get("published_at", ""),
|
||||
assets=assets,
|
||||
))
|
||||
return releases
|
||||
|
||||
def get_releases_page_url(self) -> str:
|
||||
return f"{self._base_url}/{self._repo}/releases"
|
||||
73
server/src/wled_controller/core/update/install_type.py
Normal file
73
server/src/wled_controller/core/update/install_type.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Detect how the application was installed.
|
||||
|
||||
The install type determines which update strategy is available:
|
||||
- installer: NSIS `.exe` installed to AppData — can run new installer silently
|
||||
- portable: Extracted ZIP with embedded Python — can replace app/ + python/ dirs
|
||||
- docker: Running inside a Docker container — no auto-update, show instructions
|
||||
- dev: Running from source (pip install -e) — no auto-update, link to releases
|
||||
"""
|
||||
|
||||
import sys
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InstallType(str, Enum):
|
||||
INSTALLER = "installer"
|
||||
PORTABLE = "portable"
|
||||
DOCKER = "docker"
|
||||
DEV = "dev"
|
||||
|
||||
|
||||
def detect_install_type() -> InstallType:
|
||||
"""Detect the current install type once at startup."""
|
||||
# Docker: /.dockerenv file or cgroup hints
|
||||
if Path("/.dockerenv").exists():
|
||||
logger.info("Install type: docker")
|
||||
return InstallType.DOCKER
|
||||
|
||||
# Windows installed/portable: look for embedded Python dir
|
||||
app_root = Path.cwd()
|
||||
has_uninstaller = (app_root / "uninstall.exe").exists()
|
||||
has_embedded_python = (app_root / "python" / "python.exe").exists()
|
||||
|
||||
if has_uninstaller:
|
||||
logger.info("Install type: installer (uninstall.exe found at %s)", app_root)
|
||||
return InstallType.INSTALLER
|
||||
|
||||
if has_embedded_python:
|
||||
logger.info("Install type: portable (embedded python/ found at %s)", app_root)
|
||||
return InstallType.PORTABLE
|
||||
|
||||
# Linux portable: look for venv/ + run.sh
|
||||
if (app_root / "venv").is_dir() and (app_root / "run.sh").exists():
|
||||
logger.info("Install type: portable (Linux venv layout at %s)", app_root)
|
||||
return InstallType.PORTABLE
|
||||
|
||||
logger.info("Install type: dev (no distribution markers found)")
|
||||
return InstallType.DEV
|
||||
|
||||
|
||||
def get_platform_asset_pattern(install_type: InstallType) -> str | None:
|
||||
"""Return a substring that the matching release asset name must contain.
|
||||
|
||||
Returns None if auto-update is not supported for this install type.
|
||||
"""
|
||||
if install_type == InstallType.DOCKER:
|
||||
return None
|
||||
if install_type == InstallType.DEV:
|
||||
return None
|
||||
|
||||
if sys.platform == "win32":
|
||||
if install_type == InstallType.INSTALLER:
|
||||
return "-setup.exe"
|
||||
return "-win-x64.zip"
|
||||
|
||||
if sys.platform == "linux":
|
||||
return "-linux-x64.tar.gz"
|
||||
|
||||
return None
|
||||
41
server/src/wled_controller/core/update/release_provider.py
Normal file
41
server/src/wled_controller/core/update/release_provider.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Abstract release provider and data models."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetInfo:
|
||||
"""A single downloadable asset attached to a release."""
|
||||
|
||||
name: str
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
"""A single release from the hosting platform."""
|
||||
|
||||
tag: str
|
||||
version: str
|
||||
name: str
|
||||
body: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
assets: tuple[AssetInfo, ...]
|
||||
|
||||
|
||||
class ReleaseProvider(ABC):
|
||||
"""Platform-agnostic interface for querying releases.
|
||||
|
||||
Implement this for Gitea, GitHub, GitLab, etc.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_releases(self, limit: int = 10) -> list[ReleaseInfo]:
|
||||
"""Fetch recent releases, newest first."""
|
||||
|
||||
@abstractmethod
|
||||
def get_releases_page_url(self) -> str:
|
||||
"""Return the user-facing URL of the releases page."""
|
||||
518
server/src/wled_controller/core/update/update_service.py
Normal file
518
server/src/wled_controller/core/update/update_service.py
Normal file
@@ -0,0 +1,518 @@
|
||||
"""Background service that periodically checks for new releases."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.core.update.install_type import InstallType, detect_install_type, get_platform_asset_pattern
|
||||
from wled_controller.core.update.release_provider import AssetInfo, ReleaseInfo, ReleaseProvider
|
||||
from wled_controller.core.update.version_check import is_newer, normalize_version
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"check_interval_hours": 24.0,
|
||||
"include_prerelease": False,
|
||||
}
|
||||
|
||||
_STARTUP_DELAY_S = 30
|
||||
_MANUAL_CHECK_DEBOUNCE_S = 60
|
||||
|
||||
|
||||
class UpdateService:
|
||||
"""Periodically polls a ReleaseProvider and fires WebSocket events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: ReleaseProvider,
|
||||
db: Database,
|
||||
fire_event: Any = None,
|
||||
update_dir: Path | None = None,
|
||||
) -> None:
|
||||
self._provider = provider
|
||||
self._db = db
|
||||
self._fire_event = fire_event
|
||||
|
||||
self._settings = self._load_settings()
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
# Install type (detected once)
|
||||
self._install_type = detect_install_type()
|
||||
self._asset_pattern = get_platform_asset_pattern(self._install_type)
|
||||
|
||||
# Download directory
|
||||
self._update_dir = update_dir or Path("data/updates")
|
||||
self._update_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Runtime state
|
||||
self._available_release: ReleaseInfo | None = None
|
||||
self._last_check: float = 0.0
|
||||
self._checking = False
|
||||
self._last_error: str | None = None
|
||||
|
||||
# Download/apply state
|
||||
self._download_progress: float = 0.0 # 0..1
|
||||
self._downloading = False
|
||||
self._downloaded_file: Path | None = None
|
||||
self._applying = False
|
||||
|
||||
# Load persisted state
|
||||
persisted = self._db.get_setting("update_state") or {}
|
||||
self._dismissed_version: str = persisted.get("dismissed_version", "")
|
||||
|
||||
# ── Settings persistence ───────────────────────────────────
|
||||
|
||||
def _load_settings(self) -> dict:
|
||||
data = self._db.get_setting("auto_update")
|
||||
if data:
|
||||
return {**DEFAULT_SETTINGS, **data}
|
||||
return dict(DEFAULT_SETTINGS)
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
self._db.set_setting("auto_update", {
|
||||
"enabled": self._settings["enabled"],
|
||||
"check_interval_hours": self._settings["check_interval_hours"],
|
||||
"include_prerelease": self._settings["include_prerelease"],
|
||||
})
|
||||
|
||||
def _save_state(self) -> None:
|
||||
self._db.set_setting("update_state", {
|
||||
"dismissed_version": self._dismissed_version,
|
||||
})
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._settings["enabled"]:
|
||||
self._start_loop()
|
||||
logger.info(
|
||||
"Update checker started (every %.1fh, prerelease=%s, install=%s)",
|
||||
self._settings["check_interval_hours"],
|
||||
self._settings["include_prerelease"],
|
||||
self._install_type.value,
|
||||
)
|
||||
else:
|
||||
logger.info("Update checker initialized (disabled, install=%s)", self._install_type.value)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._cancel_loop()
|
||||
logger.info("Update checker stopped")
|
||||
|
||||
def _start_loop(self) -> None:
|
||||
self._cancel_loop()
|
||||
self._task = asyncio.create_task(self._check_loop())
|
||||
|
||||
def _cancel_loop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
async def _check_loop(self) -> None:
|
||||
try:
|
||||
await asyncio.sleep(_STARTUP_DELAY_S)
|
||||
await self._perform_check()
|
||||
|
||||
interval_s = self._settings["check_interval_hours"] * 3600
|
||||
while True:
|
||||
await asyncio.sleep(interval_s)
|
||||
try:
|
||||
await self._perform_check()
|
||||
except Exception as exc:
|
||||
logger.error("Update check failed: %s", exc, exc_info=True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ── Core check logic ───────────────────────────────────────
|
||||
|
||||
async def _perform_check(self) -> None:
|
||||
self._checking = True
|
||||
self._last_error = None
|
||||
try:
|
||||
releases = await self._provider.get_releases(limit=10)
|
||||
best = self._find_best_release(releases)
|
||||
self._available_release = best
|
||||
self._last_check = time.time()
|
||||
|
||||
if best and self._fire_event:
|
||||
self._fire_event({
|
||||
"type": "update_available",
|
||||
"version": best.version,
|
||||
"tag": best.tag,
|
||||
"name": best.name,
|
||||
"prerelease": best.prerelease,
|
||||
"dismissed": best.version == self._dismissed_version,
|
||||
"can_auto_update": self._can_auto_update(best),
|
||||
})
|
||||
logger.info(
|
||||
"Update check complete — %s",
|
||||
f"v{best.version} available" if best else "up to date",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
raise
|
||||
finally:
|
||||
self._checking = False
|
||||
|
||||
def _find_best_release(self, releases: list[ReleaseInfo]) -> ReleaseInfo | None:
|
||||
"""Find the newest release that is newer than the current version."""
|
||||
include_pre = self._settings["include_prerelease"]
|
||||
for release in releases:
|
||||
if release.prerelease and not include_pre:
|
||||
continue
|
||||
try:
|
||||
normalize_version(release.version)
|
||||
except Exception:
|
||||
continue
|
||||
if is_newer(release.version, __version__):
|
||||
return release
|
||||
return None
|
||||
|
||||
def _find_asset(self, release: ReleaseInfo) -> AssetInfo | None:
|
||||
"""Find the matching asset for this platform + install type."""
|
||||
if not self._asset_pattern:
|
||||
return None
|
||||
for asset in release.assets:
|
||||
if self._asset_pattern in asset.name:
|
||||
return asset
|
||||
return None
|
||||
|
||||
def _can_auto_update(self, release: ReleaseInfo) -> bool:
|
||||
"""Check if auto-update is possible for the given release."""
|
||||
return self._find_asset(release) is not None
|
||||
|
||||
# ── Download ───────────────────────────────────────────────
|
||||
|
||||
async def download_update(self) -> Path:
|
||||
"""Download the update asset. Returns path to downloaded file."""
|
||||
release = self._available_release
|
||||
if not release:
|
||||
raise RuntimeError("No update available")
|
||||
|
||||
asset = self._find_asset(release)
|
||||
if not asset:
|
||||
raise RuntimeError(
|
||||
f"No matching asset for {self._install_type.value} "
|
||||
f"on {sys.platform}"
|
||||
)
|
||||
|
||||
dest = self._update_dir / asset.name
|
||||
# Skip re-download if file exists and size matches
|
||||
if dest.exists() and dest.stat().st_size == asset.size:
|
||||
logger.info("Update already downloaded: %s", dest.name)
|
||||
self._downloaded_file = dest
|
||||
self._download_progress = 1.0
|
||||
return dest
|
||||
|
||||
self._downloading = True
|
||||
self._download_progress = 0.0
|
||||
try:
|
||||
await self._stream_download(asset.download_url, dest, asset.size)
|
||||
self._downloaded_file = dest
|
||||
logger.info("Downloaded update: %s (%d bytes)", dest.name, dest.stat().st_size)
|
||||
return dest
|
||||
except Exception:
|
||||
# Clean up partial download
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
self._downloaded_file = None
|
||||
raise
|
||||
finally:
|
||||
self._downloading = False
|
||||
|
||||
async def _stream_download(self, url: str, dest: Path, total_size: int) -> None:
|
||||
"""Stream-download a file, updating progress as we go."""
|
||||
tmp = dest.with_suffix(dest.suffix + ".tmp")
|
||||
received = 0
|
||||
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(tmp, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
received += len(chunk)
|
||||
if total_size > 0:
|
||||
self._download_progress = received / total_size
|
||||
if self._fire_event:
|
||||
self._fire_event({
|
||||
"type": "update_download_progress",
|
||||
"progress": round(self._download_progress, 3),
|
||||
})
|
||||
# Atomic rename
|
||||
tmp.replace(dest)
|
||||
self._download_progress = 1.0
|
||||
|
||||
# ── Apply ──────────────────────────────────────────────────
|
||||
|
||||
async def apply_update(self) -> None:
|
||||
"""Download (if needed) and apply the update, then shut down."""
|
||||
if self._applying:
|
||||
raise RuntimeError("Update already in progress")
|
||||
self._applying = True
|
||||
try:
|
||||
if not self._downloaded_file or not self._downloaded_file.exists():
|
||||
await self.download_update()
|
||||
|
||||
assert self._downloaded_file is not None
|
||||
file_path = self._downloaded_file
|
||||
|
||||
if self._install_type == InstallType.INSTALLER:
|
||||
await self._apply_installer(file_path)
|
||||
elif self._install_type == InstallType.PORTABLE:
|
||||
if file_path.suffix == ".zip":
|
||||
await self._apply_portable_zip(file_path)
|
||||
elif file_path.name.endswith(".tar.gz"):
|
||||
await self._apply_portable_tarball(file_path)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown portable format: {file_path.name}")
|
||||
else:
|
||||
raise RuntimeError(f"Auto-update not supported for install type: {self._install_type.value}")
|
||||
finally:
|
||||
self._applying = False
|
||||
|
||||
async def _apply_installer(self, exe_path: Path) -> None:
|
||||
"""Launch the NSIS installer silently and shut down."""
|
||||
install_dir = str(Path.cwd())
|
||||
logger.info("Launching silent installer: %s /S /D=%s", exe_path, install_dir)
|
||||
|
||||
# Fire event so frontend shows restart overlay
|
||||
if self._fire_event:
|
||||
self._fire_event({"type": "server_restarting"})
|
||||
|
||||
# Launch installer detached — it will wait for python.exe to exit,
|
||||
# then install and the VBS launcher / service will restart the app.
|
||||
subprocess.Popen(
|
||||
[str(exe_path), "/S", f"/D={install_dir}"],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
if sys.platform == "win32" else 0,
|
||||
)
|
||||
|
||||
# Give the installer a moment to start, then shut down
|
||||
await asyncio.sleep(1)
|
||||
from wled_controller.server_ref import request_shutdown
|
||||
request_shutdown()
|
||||
|
||||
async def _apply_portable_zip(self, zip_path: Path) -> None:
|
||||
"""Extract ZIP over the current installation, then shut down."""
|
||||
import zipfile
|
||||
|
||||
app_root = Path.cwd()
|
||||
staging = self._update_dir / "_staging"
|
||||
|
||||
logger.info("Extracting portable update: %s", zip_path.name)
|
||||
|
||||
# Extract to staging dir in a thread (I/O bound)
|
||||
def _extract():
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
staging.mkdir(parents=True)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
zf.extractall(staging)
|
||||
|
||||
await asyncio.to_thread(_extract)
|
||||
|
||||
# The ZIP contains a top-level LedGrab/ dir — find it
|
||||
inner = _find_single_child_dir(staging)
|
||||
|
||||
# Write a post-update script that swaps the dirs after shutdown.
|
||||
# On Windows, python.exe locks files, so we need a bat script
|
||||
# that waits for the process to exit, then does the swap.
|
||||
script = self._update_dir / "_apply_update.bat"
|
||||
script.write_text(
|
||||
_build_swap_script(inner, app_root, ["app", "python", "scripts"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logger.info("Launching post-update script and shutting down")
|
||||
if self._fire_event:
|
||||
self._fire_event({"type": "server_restarting"})
|
||||
|
||||
subprocess.Popen(
|
||||
["cmd.exe", "/c", str(script)],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
if sys.platform == "win32" else 0,
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
from wled_controller.server_ref import request_shutdown
|
||||
request_shutdown()
|
||||
|
||||
async def _apply_portable_tarball(self, tar_path: Path) -> None:
|
||||
"""Extract tarball over the current installation, then shut down."""
|
||||
import tarfile
|
||||
|
||||
app_root = Path.cwd()
|
||||
staging = self._update_dir / "_staging"
|
||||
|
||||
logger.info("Extracting portable update: %s", tar_path.name)
|
||||
|
||||
def _extract():
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
staging.mkdir(parents=True)
|
||||
with tarfile.open(tar_path, "r:gz") as tf:
|
||||
tf.extractall(staging, filter="data")
|
||||
|
||||
await asyncio.to_thread(_extract)
|
||||
|
||||
inner = _find_single_child_dir(staging)
|
||||
|
||||
# On Linux, write a shell script that replaces dirs after shutdown
|
||||
script = self._update_dir / "_apply_update.sh"
|
||||
dirs_to_swap = ["app", "venv"]
|
||||
lines = [
|
||||
"#!/bin/bash",
|
||||
"# Auto-generated update script — replaces app dirs and restarts",
|
||||
f'APP_ROOT="{app_root}"',
|
||||
f'STAGING="{inner}"',
|
||||
"sleep 3 # wait for server to exit",
|
||||
]
|
||||
for d in dirs_to_swap:
|
||||
lines.append(f'[ -d "$STAGING/{d}" ] && rm -rf "$APP_ROOT/{d}" && mv "$STAGING/{d}" "$APP_ROOT/{d}"')
|
||||
# Copy scripts/ and run.sh if present
|
||||
lines.append('[ -f "$STAGING/run.sh" ] && cp "$STAGING/run.sh" "$APP_ROOT/run.sh"')
|
||||
lines.append(f'rm -rf "{staging}"')
|
||||
lines.append(f'rm -f "{script}"')
|
||||
lines.append('echo "Update applied. Restarting..."')
|
||||
lines.append('cd "$APP_ROOT" && exec ./run.sh')
|
||||
script.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
os.chmod(script, 0o755)
|
||||
|
||||
logger.info("Launching post-update script and shutting down")
|
||||
if self._fire_event:
|
||||
self._fire_event({"type": "server_restarting"})
|
||||
|
||||
subprocess.Popen(
|
||||
["/bin/bash", str(script)],
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
from wled_controller.server_ref import request_shutdown
|
||||
request_shutdown()
|
||||
|
||||
# ── Public API (called from routes) ────────────────────────
|
||||
|
||||
async def check_now(self) -> dict:
|
||||
"""Trigger an immediate check (with debounce)."""
|
||||
elapsed = time.time() - self._last_check
|
||||
if elapsed < _MANUAL_CHECK_DEBOUNCE_S and self._available_release is not None:
|
||||
return self.get_status()
|
||||
await self._perform_check()
|
||||
return self.get_status()
|
||||
|
||||
def dismiss(self, version: str) -> None:
|
||||
"""Dismiss the notification for *version*."""
|
||||
self._dismissed_version = version
|
||||
self._save_state()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
rel = self._available_release
|
||||
can_auto = rel is not None and self._can_auto_update(rel)
|
||||
return {
|
||||
"current_version": __version__,
|
||||
"has_update": rel is not None,
|
||||
"checking": self._checking,
|
||||
"last_check": self._last_check if self._last_check else None,
|
||||
"last_error": self._last_error,
|
||||
"releases_url": self._provider.get_releases_page_url(),
|
||||
"install_type": self._install_type.value,
|
||||
"can_auto_update": can_auto,
|
||||
"downloading": self._downloading,
|
||||
"download_progress": round(self._download_progress, 3),
|
||||
"applying": self._applying,
|
||||
"release": {
|
||||
"version": rel.version,
|
||||
"tag": rel.tag,
|
||||
"name": rel.name,
|
||||
"body": rel.body,
|
||||
"prerelease": rel.prerelease,
|
||||
"published_at": rel.published_at,
|
||||
} if rel else None,
|
||||
"dismissed_version": self._dismissed_version,
|
||||
}
|
||||
|
||||
def get_settings(self) -> dict:
|
||||
return {
|
||||
"enabled": self._settings["enabled"],
|
||||
"check_interval_hours": self._settings["check_interval_hours"],
|
||||
"include_prerelease": self._settings["include_prerelease"],
|
||||
}
|
||||
|
||||
async def update_settings(
|
||||
self,
|
||||
enabled: bool,
|
||||
check_interval_hours: float,
|
||||
include_prerelease: bool,
|
||||
) -> dict:
|
||||
self._settings["enabled"] = enabled
|
||||
self._settings["check_interval_hours"] = check_interval_hours
|
||||
self._settings["include_prerelease"] = include_prerelease
|
||||
self._save_settings()
|
||||
|
||||
if enabled:
|
||||
self._start_loop()
|
||||
logger.info(
|
||||
"Update checker enabled (every %.1fh, prerelease=%s)",
|
||||
check_interval_hours,
|
||||
include_prerelease,
|
||||
)
|
||||
else:
|
||||
self._cancel_loop()
|
||||
logger.info("Update checker disabled")
|
||||
|
||||
return self.get_settings()
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _find_single_child_dir(parent: Path) -> Path:
|
||||
"""Return the single subdirectory inside *parent* (e.g. LedGrab/)."""
|
||||
children = [c for c in parent.iterdir() if c.is_dir()]
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
return parent
|
||||
|
||||
|
||||
def _build_swap_script(staging: Path, app_root: Path, dirs: list[str]) -> str:
|
||||
"""Build a Windows batch script that replaces dirs after the server exits."""
|
||||
lines = [
|
||||
"@echo off",
|
||||
"REM Auto-generated update script — replaces app dirs and restarts",
|
||||
"echo Waiting for server to exit...",
|
||||
"timeout /t 5 /nobreak >nul",
|
||||
]
|
||||
for d in dirs:
|
||||
src = staging / d
|
||||
dst = app_root / d
|
||||
lines.append(f'if exist "{src}" (')
|
||||
lines.append(f' rmdir /s /q "{dst}" 2>nul')
|
||||
lines.append(f' move /y "{src}" "{dst}"')
|
||||
lines.append(")")
|
||||
# Copy LedGrab.bat if present
|
||||
bat = staging / "LedGrab.bat"
|
||||
lines.append(f'if exist "{bat}" copy /y "{bat}" "{app_root / "LedGrab.bat"}"')
|
||||
# Cleanup
|
||||
lines.append(f'rmdir /s /q "{staging.parent}" 2>nul')
|
||||
lines.append('del /f /q "%~f0" 2>nul')
|
||||
lines.append('echo Update complete. Restarting...')
|
||||
# Restart via VBS launcher or bat
|
||||
vbs = app_root / "scripts" / "start-hidden.vbs"
|
||||
bat_launcher = app_root / "LedGrab.bat"
|
||||
lines.append(f'if exist "{vbs}" (')
|
||||
lines.append(f' start "" wscript.exe "{vbs}"')
|
||||
lines.append(") else (")
|
||||
lines.append(f' start "" "{bat_launcher}"')
|
||||
lines.append(")")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
45
server/src/wled_controller/core/update/version_check.py
Normal file
45
server/src/wled_controller/core/update/version_check.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Version comparison utilities.
|
||||
|
||||
Normalizes Gitea-style tags (v0.3.0-alpha.1) to PEP 440 (0.3.0a1)
|
||||
so that ``packaging.version.Version`` can compare them correctly.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
|
||||
_PRE_MAP = {
|
||||
"alpha": "a",
|
||||
"beta": "b",
|
||||
"rc": "rc",
|
||||
}
|
||||
|
||||
_PRE_PATTERN = re.compile(
|
||||
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def normalize_version(raw: str) -> Version:
|
||||
"""Convert a tag like ``v0.3.0-alpha.1`` to a PEP 440 ``Version``.
|
||||
|
||||
Raises ``InvalidVersion`` if the string cannot be parsed.
|
||||
"""
|
||||
cleaned = raw.lstrip("v").strip()
|
||||
m = _PRE_PATTERN.match(cleaned)
|
||||
if m:
|
||||
base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3)
|
||||
pep_label = _PRE_MAP.get(pre_label, pre_label)
|
||||
cleaned = f"{base}{pep_label}{pre_num}"
|
||||
return Version(cleaned)
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str) -> bool:
|
||||
"""Return True if *candidate* is strictly newer than *current*.
|
||||
|
||||
Returns False if either version string is unparseable.
|
||||
"""
|
||||
try:
|
||||
return normalize_version(candidate) > normalize_version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
0
server/src/wled_controller/core/weather/__init__.py
Normal file
0
server/src/wled_controller/core/weather/__init__.py
Normal file
183
server/src/wled_controller/core/weather/weather_manager.py
Normal file
183
server/src/wled_controller/core/weather/weather_manager.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Weather source runtime manager — polls APIs and caches WeatherData.
|
||||
|
||||
Ref-counted pool: multiple CSS streams sharing the same weather source
|
||||
share one polling loop. Lazy-creates runtimes on first acquire().
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from wled_controller.core.weather.weather_provider import (
|
||||
DEFAULT_WEATHER,
|
||||
WeatherData,
|
||||
WeatherProvider,
|
||||
create_provider,
|
||||
)
|
||||
from wled_controller.storage.weather_source import WeatherSource
|
||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _WeatherRuntime:
|
||||
"""Polls a weather provider on a timer and caches the latest result."""
|
||||
|
||||
def __init__(self, source: WeatherSource, provider: WeatherProvider) -> None:
|
||||
self._source_id = source.id
|
||||
self._provider = provider
|
||||
self._latitude = source.latitude
|
||||
self._longitude = source.longitude
|
||||
self._interval = max(60, source.update_interval)
|
||||
|
||||
self._data: WeatherData = DEFAULT_WEATHER
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
@property
|
||||
def data(self) -> WeatherData:
|
||||
with self._lock:
|
||||
return self._data
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop, daemon=True,
|
||||
name=f"Weather-{self._source_id[:12]}",
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(f"Weather runtime started: {self._source_id}")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=10.0)
|
||||
self._thread = None
|
||||
logger.info(f"Weather runtime stopped: {self._source_id}")
|
||||
|
||||
def update_config(self, source: WeatherSource) -> None:
|
||||
self._latitude = source.latitude
|
||||
self._longitude = source.longitude
|
||||
self._interval = max(60, source.update_interval)
|
||||
|
||||
def fetch_now(self) -> WeatherData:
|
||||
"""Force an immediate fetch (used by test endpoint)."""
|
||||
result = self._provider.fetch(self._latitude, self._longitude)
|
||||
with self._lock:
|
||||
self._data = result
|
||||
return result
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
# Fetch immediately on start
|
||||
self._do_fetch()
|
||||
last_fetch = time.monotonic()
|
||||
|
||||
while self._running:
|
||||
time.sleep(1.0)
|
||||
if time.monotonic() - last_fetch >= self._interval:
|
||||
self._do_fetch()
|
||||
last_fetch = time.monotonic()
|
||||
|
||||
def _do_fetch(self) -> None:
|
||||
result = self._provider.fetch(self._latitude, self._longitude)
|
||||
with self._lock:
|
||||
self._data = result
|
||||
logger.debug(
|
||||
f"Weather {self._source_id}: code={result.code} "
|
||||
f"temp={result.temperature:.1f}C wind={result.wind_speed:.0f}km/h"
|
||||
)
|
||||
|
||||
|
||||
class WeatherManager:
|
||||
"""Ref-counted pool of weather runtimes."""
|
||||
|
||||
def __init__(self, store: WeatherSourceStore) -> None:
|
||||
self._store = store
|
||||
# source_id -> (runtime, ref_count)
|
||||
self._runtimes: Dict[str, tuple] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def acquire(self, source_id: str) -> _WeatherRuntime:
|
||||
"""Get or create a runtime for the given weather source. Increments ref count."""
|
||||
with self._lock:
|
||||
if source_id in self._runtimes:
|
||||
runtime, count = self._runtimes[source_id]
|
||||
self._runtimes[source_id] = (runtime, count + 1)
|
||||
return runtime
|
||||
|
||||
source = self._store.get(source_id)
|
||||
provider = create_provider(source.provider, source.provider_config)
|
||||
runtime = _WeatherRuntime(source, provider)
|
||||
runtime.start()
|
||||
self._runtimes[source_id] = (runtime, 1)
|
||||
return runtime
|
||||
|
||||
def release(self, source_id: str) -> None:
|
||||
"""Decrement ref count; stop runtime when it reaches zero."""
|
||||
with self._lock:
|
||||
if source_id not in self._runtimes:
|
||||
return
|
||||
runtime, count = self._runtimes[source_id]
|
||||
if count <= 1:
|
||||
runtime.stop()
|
||||
del self._runtimes[source_id]
|
||||
else:
|
||||
self._runtimes[source_id] = (runtime, count - 1)
|
||||
|
||||
def get_data(self, source_id: str) -> WeatherData:
|
||||
"""Get cached weather data for a source (creates runtime if needed)."""
|
||||
with self._lock:
|
||||
if source_id in self._runtimes:
|
||||
runtime, _count = self._runtimes[source_id]
|
||||
return runtime.data
|
||||
# No active runtime — do a one-off fetch via ensure_runtime
|
||||
runtime = self._ensure_runtime(source_id)
|
||||
return runtime.data
|
||||
|
||||
def fetch_now(self, source_id: str) -> WeatherData:
|
||||
"""Force an immediate fetch for the test endpoint."""
|
||||
runtime = self._ensure_runtime(source_id)
|
||||
return runtime.fetch_now()
|
||||
|
||||
def update_source(self, source_id: str) -> None:
|
||||
"""Hot-update runtime config when the source is edited."""
|
||||
with self._lock:
|
||||
if source_id not in self._runtimes:
|
||||
return
|
||||
runtime, count = self._runtimes[source_id]
|
||||
try:
|
||||
source = self._store.get(source_id)
|
||||
runtime.update_config(source)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update weather runtime {source_id}: {e}")
|
||||
|
||||
def _ensure_runtime(self, source_id: str) -> _WeatherRuntime:
|
||||
"""Get or create a runtime (for API control of idle sources)."""
|
||||
with self._lock:
|
||||
if source_id in self._runtimes:
|
||||
runtime, count = self._runtimes[source_id]
|
||||
return runtime
|
||||
|
||||
source = self._store.get(source_id)
|
||||
provider = create_provider(source.provider, source.provider_config)
|
||||
runtime = _WeatherRuntime(source, provider)
|
||||
runtime.start()
|
||||
with self._lock:
|
||||
if source_id not in self._runtimes:
|
||||
self._runtimes[source_id] = (runtime, 0)
|
||||
else:
|
||||
runtime.stop()
|
||||
runtime, _count = self._runtimes[source_id]
|
||||
return runtime
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop all runtimes."""
|
||||
with self._lock:
|
||||
for source_id, (runtime, _count) in list(self._runtimes.items()):
|
||||
runtime.stop()
|
||||
self._runtimes.clear()
|
||||
logger.info("Weather manager shut down")
|
||||
123
server/src/wled_controller/core/weather/weather_provider.py
Normal file
123
server/src/wled_controller/core/weather/weather_provider.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Weather data providers with pluggable backend support.
|
||||
|
||||
Each provider fetches current weather data and returns a standardized
|
||||
WeatherData result. Only Open-Meteo is supported in v1 (free, no API key).
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Type
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_HTTP_TIMEOUT = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeatherData:
|
||||
"""Immutable weather observation."""
|
||||
|
||||
code: int # WMO weather code (0-99)
|
||||
temperature: float # Celsius
|
||||
wind_speed: float # km/h
|
||||
cloud_cover: int # 0-100 %
|
||||
fetched_at: float # time.monotonic() timestamp
|
||||
|
||||
|
||||
# Default fallback when no data has been fetched yet
|
||||
DEFAULT_WEATHER = WeatherData(code=2, temperature=20.0, wind_speed=5.0, cloud_cover=50, fetched_at=0.0)
|
||||
|
||||
|
||||
class WeatherProvider(ABC):
|
||||
"""Abstract weather data provider."""
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self, latitude: float, longitude: float) -> WeatherData:
|
||||
"""Fetch current weather for the given location.
|
||||
|
||||
Must not raise — returns DEFAULT_WEATHER on failure.
|
||||
"""
|
||||
|
||||
|
||||
class OpenMeteoProvider(WeatherProvider):
|
||||
"""Open-Meteo API provider (free, no API key required)."""
|
||||
|
||||
_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = httpx.Client(timeout=_HTTP_TIMEOUT)
|
||||
|
||||
def fetch(self, latitude: float, longitude: float) -> WeatherData:
|
||||
try:
|
||||
resp = self._client.get(
|
||||
self._URL,
|
||||
params={
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"current": "temperature_2m,weather_code,wind_speed_10m,cloud_cover",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
current = data["current"]
|
||||
return WeatherData(
|
||||
code=int(current["weather_code"]),
|
||||
temperature=float(current["temperature_2m"]),
|
||||
wind_speed=float(current.get("wind_speed_10m", 0.0)),
|
||||
cloud_cover=int(current.get("cloud_cover", 50)),
|
||||
fetched_at=time.monotonic(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Open-Meteo fetch failed: {e}")
|
||||
return DEFAULT_WEATHER
|
||||
|
||||
|
||||
PROVIDER_REGISTRY: Dict[str, Type[WeatherProvider]] = {
|
||||
"open_meteo": OpenMeteoProvider,
|
||||
}
|
||||
|
||||
|
||||
def create_provider(provider_name: str, provider_config: dict) -> WeatherProvider:
|
||||
"""Create a provider instance from registry."""
|
||||
cls = PROVIDER_REGISTRY.get(provider_name)
|
||||
if cls is None:
|
||||
raise ValueError(f"Unknown weather provider: {provider_name}")
|
||||
return cls()
|
||||
|
||||
|
||||
# WMO Weather interpretation codes (WMO 4677)
|
||||
WMO_CONDITION_NAMES: Dict[int, str] = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Moderate drizzle",
|
||||
55: "Dense drizzle",
|
||||
56: "Light freezing drizzle",
|
||||
57: "Dense freezing drizzle",
|
||||
61: "Slight rain",
|
||||
63: "Moderate rain",
|
||||
65: "Heavy rain",
|
||||
66: "Light freezing rain",
|
||||
67: "Heavy freezing rain",
|
||||
71: "Slight snowfall",
|
||||
73: "Moderate snowfall",
|
||||
75: "Heavy snowfall",
|
||||
77: "Snow grains",
|
||||
80: "Slight rain showers",
|
||||
81: "Moderate rain showers",
|
||||
82: "Violent rain showers",
|
||||
85: "Slight snow showers",
|
||||
86: "Heavy snow showers",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm with slight hail",
|
||||
99: "Thunderstorm with heavy hail",
|
||||
}
|
||||
@@ -32,13 +32,18 @@ from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.os_notification_listener import OsNotificationListener
|
||||
from wled_controller.api.routes.system import STORE_MAP
|
||||
from wled_controller.core.update.update_service import UpdateService
|
||||
from wled_controller.core.update.gitea_provider import GiteaReleaseProvider
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import setup_logging, get_logger, install_broadcast_handler
|
||||
|
||||
# Initialize logging
|
||||
@@ -49,27 +54,34 @@ logger = get_logger(__name__)
|
||||
# Get configuration
|
||||
config = get_config()
|
||||
|
||||
# Seed demo data before stores are loaded (first-run only)
|
||||
# Initialize SQLite database
|
||||
db = Database(config.storage.database_file)
|
||||
|
||||
# Seed demo data after DB is ready (first-run only)
|
||||
if config.demo:
|
||||
from wled_controller.core.demo_seed import seed_demo_data
|
||||
seed_demo_data(config.storage)
|
||||
seed_demo_data(db)
|
||||
|
||||
# Initialize storage and processing
|
||||
device_store = DeviceStore(config.storage.devices_file)
|
||||
template_store = TemplateStore(config.storage.templates_file)
|
||||
pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_templates_file)
|
||||
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
||||
output_target_store = OutputTargetStore(config.storage.output_targets_file)
|
||||
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
|
||||
color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
|
||||
audio_source_store = AudioSourceStore(config.storage.audio_sources_file)
|
||||
audio_template_store = AudioTemplateStore(config.storage.audio_templates_file)
|
||||
value_source_store = ValueSourceStore(config.storage.value_sources_file)
|
||||
automation_store = AutomationStore(config.storage.automations_file)
|
||||
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
|
||||
sync_clock_store = SyncClockStore(config.storage.sync_clocks_file)
|
||||
cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_processing_templates_file)
|
||||
device_store = DeviceStore(db)
|
||||
template_store = TemplateStore(db)
|
||||
pp_template_store = PostprocessingTemplateStore(db)
|
||||
picture_source_store = PictureSourceStore(db)
|
||||
output_target_store = OutputTargetStore(db)
|
||||
pattern_template_store = PatternTemplateStore(db)
|
||||
color_strip_store = ColorStripStore(db)
|
||||
audio_source_store = AudioSourceStore(db)
|
||||
audio_template_store = AudioTemplateStore(db)
|
||||
value_source_store = ValueSourceStore(db)
|
||||
automation_store = AutomationStore(db)
|
||||
scene_preset_store = ScenePresetStore(db)
|
||||
sync_clock_store = SyncClockStore(db)
|
||||
cspt_store = ColorStripProcessingTemplateStore(db)
|
||||
gradient_store = GradientStore(db)
|
||||
gradient_store.migrate_palette_references(color_strip_store)
|
||||
weather_source_store = WeatherSourceStore(db)
|
||||
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||
weather_manager = WeatherManager(weather_source_store)
|
||||
|
||||
processor_manager = ProcessorManager(
|
||||
ProcessorDependencies(
|
||||
@@ -84,10 +96,21 @@ processor_manager = ProcessorManager(
|
||||
audio_template_store=audio_template_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
cspt_store=cspt_store,
|
||||
gradient_store=gradient_store,
|
||||
weather_manager=weather_manager,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _save_all_stores() -> None:
|
||||
"""Shutdown hook — SQLite stores use write-through caching, so this is a no-op.
|
||||
|
||||
Every create/update/delete already goes to the database immediately.
|
||||
Kept for backward compatibility with server_ref.py which calls this.
|
||||
"""
|
||||
logger.info("Shutdown: all stores already persisted (write-through cache)")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager.
|
||||
@@ -103,23 +126,13 @@ async def lifespan(app: FastAPI):
|
||||
print(f" Open http://localhost:{config.server.port} in your browser")
|
||||
print(" =============================================\n")
|
||||
|
||||
# Validate authentication configuration
|
||||
# Log authentication mode
|
||||
if not config.auth.api_keys:
|
||||
logger.error("=" * 70)
|
||||
logger.error("CRITICAL: No API keys configured!")
|
||||
logger.error("Authentication is REQUIRED for all API requests.")
|
||||
logger.error("Please add API keys to your configuration:")
|
||||
logger.error(" 1. Generate keys: openssl rand -hex 32")
|
||||
logger.error(" 2. Add to config/default_config.yaml under auth.api_keys")
|
||||
logger.error(" 3. Format: label: \"your-generated-key\"")
|
||||
logger.error("=" * 70)
|
||||
raise RuntimeError("No API keys configured - server cannot start without authentication")
|
||||
|
||||
# Log authentication status
|
||||
logger.info(f"API Authentication: ENFORCED ({len(config.auth.api_keys)} clients configured)")
|
||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||
logger.info(f"Authorized clients: {client_labels}")
|
||||
logger.info("All API requests require valid Bearer token authentication")
|
||||
logger.info("Authentication disabled (no API keys configured)")
|
||||
else:
|
||||
logger.info(f"Authentication enabled ({len(config.auth.api_keys)} API key(s) configured)")
|
||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||
logger.info(f"Authorized clients: {client_labels}")
|
||||
|
||||
# Create MQTT service (shared broker connection)
|
||||
mqtt_service = MQTTService(config.mqtt)
|
||||
@@ -134,19 +147,30 @@ async def lifespan(app: FastAPI):
|
||||
device_store=device_store,
|
||||
)
|
||||
|
||||
# Create auto-backup engine — derive paths from storage config so that
|
||||
# Create auto-backup engine — derive paths from database location so that
|
||||
# demo mode auto-backups go to data/demo/ instead of data/.
|
||||
_data_dir = Path(config.storage.devices_file).parent
|
||||
_data_dir = Path(config.storage.database_file).parent
|
||||
auto_backup_engine = AutoBackupEngine(
|
||||
settings_path=_data_dir / "auto_backup_settings.json",
|
||||
backup_dir=_data_dir / "backups",
|
||||
store_map=STORE_MAP,
|
||||
storage_config=config.storage,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Create update service (checks for new releases)
|
||||
_release_provider = GiteaReleaseProvider(
|
||||
base_url="https://git.dolgolyov-family.by",
|
||||
repo="alexei.dolgolyov/wled-screen-controller-mixed",
|
||||
)
|
||||
update_service = UpdateService(
|
||||
provider=_release_provider,
|
||||
db=db,
|
||||
fire_event=processor_manager.fire_event,
|
||||
update_dir=_data_dir / "updates",
|
||||
)
|
||||
|
||||
# Initialize API dependencies
|
||||
init_dependencies(
|
||||
device_store, template_store, processor_manager,
|
||||
database=db,
|
||||
pp_template_store=pp_template_store,
|
||||
pattern_template_store=pattern_template_store,
|
||||
picture_source_store=picture_source_store,
|
||||
@@ -162,6 +186,10 @@ async def lifespan(app: FastAPI):
|
||||
sync_clock_store=sync_clock_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
cspt_store=cspt_store,
|
||||
gradient_store=gradient_store,
|
||||
weather_source_store=weather_source_store,
|
||||
weather_manager=weather_manager,
|
||||
update_service=update_service,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
@@ -208,6 +236,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start auto-backup engine (periodic configuration backups)
|
||||
await auto_backup_engine.start()
|
||||
|
||||
# Start update checker (periodic release polling)
|
||||
await update_service.start()
|
||||
|
||||
# Start OS notification listener (Windows toast → notification CSS streams)
|
||||
os_notif_listener = OsNotificationListener(
|
||||
color_strip_store=color_strip_store,
|
||||
@@ -220,6 +251,23 @@ async def lifespan(app: FastAPI):
|
||||
# Shutdown
|
||||
logger.info("Shutting down LED Grab")
|
||||
|
||||
# Persist all stores to disk before stopping anything.
|
||||
# This ensures in-memory data survives force-kills and restarts
|
||||
# where no CRUD happened during the session.
|
||||
_save_all_stores()
|
||||
|
||||
# Stop weather manager
|
||||
try:
|
||||
weather_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping weather manager: {e}")
|
||||
|
||||
# Stop update checker
|
||||
try:
|
||||
await update_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping update checker: {e}")
|
||||
|
||||
# Stop auto-backup engine
|
||||
try:
|
||||
await auto_backup_engine.stop()
|
||||
|
||||
59
server/src/wled_controller/server_ref.py
Normal file
59
server/src/wled_controller/server_ref.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Module-level holder for the uvicorn Server and TrayManager references.
|
||||
|
||||
Allows the shutdown API endpoint to trigger graceful shutdown via
|
||||
``server.should_exit = True`` + ``tray.stop()``, which is the same
|
||||
mechanism the system tray "Shutdown" menu item uses.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
_server: Optional[Any] = None # uvicorn.Server
|
||||
_tray: Optional[Any] = None # TrayManager
|
||||
|
||||
|
||||
def set_server(server: Any) -> None:
|
||||
"""Store the uvicorn Server instance (called from __main__)."""
|
||||
global _server
|
||||
_server = server
|
||||
|
||||
|
||||
def set_tray(tray: Any) -> None:
|
||||
"""Store the TrayManager instance (called from __main__)."""
|
||||
global _tray
|
||||
_tray = tray
|
||||
|
||||
|
||||
def request_shutdown() -> None:
|
||||
"""Signal uvicorn + tray to perform a graceful shutdown.
|
||||
|
||||
Broadcasts a ``server_restarting`` event so the frontend can show
|
||||
a restart indicator, persists all stores to disk, then sets
|
||||
``should_exit = True`` on the uvicorn Server and stops the tray.
|
||||
"""
|
||||
# Notify connected clients that a restart is in progress
|
||||
_broadcast_restarting()
|
||||
|
||||
# Persist stores before signaling shutdown.
|
||||
# The lifespan shutdown handler also saves, but it may not run
|
||||
# reliably when uvicorn is in a daemon thread.
|
||||
try:
|
||||
from wled_controller.main import _save_all_stores
|
||||
_save_all_stores()
|
||||
except Exception:
|
||||
pass # best-effort; lifespan handler is the backup
|
||||
|
||||
if _server is not None:
|
||||
_server.should_exit = True
|
||||
if _tray is not None:
|
||||
_tray.stop()
|
||||
|
||||
|
||||
def _broadcast_restarting() -> None:
|
||||
"""Push a server_restarting event to all connected WebSocket clients."""
|
||||
try:
|
||||
from wled_controller.api.dependencies import _deps
|
||||
pm = _deps.get("processor_manager")
|
||||
if pm is not None:
|
||||
pm.fire_event({"type": "server_restarting"})
|
||||
except Exception:
|
||||
pass
|
||||
@@ -14,4 +14,5 @@
|
||||
@import './tree-nav.css';
|
||||
@import './tutorials.css';
|
||||
@import './graph-editor.css';
|
||||
@import './appearance.css';
|
||||
@import './mobile.css';
|
||||
|
||||
355
server/src/wled_controller/static/css/appearance.css
Normal file
355
server/src/wled_controller/static/css/appearance.css
Normal file
@@ -0,0 +1,355 @@
|
||||
/* ── Appearance tab: preset cards & background effects ── */
|
||||
|
||||
/* Use --font-body / --font-heading CSS variables for preset font switching */
|
||||
body {
|
||||
font-family: var(--font-body, 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-heading, 'Orbitron', sans-serif);
|
||||
}
|
||||
|
||||
/* ─── Preset grid ─── */
|
||||
|
||||
.ap-hint {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ─── Preset card (shared) ─── */
|
||||
|
||||
.ap-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-normal) var(--ease-out),
|
||||
box-shadow var(--duration-normal) var(--ease-out),
|
||||
transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.ap-card:hover {
|
||||
border-color: var(--text-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ap-card.active {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color),
|
||||
0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
.ap-card.active::after {
|
||||
content: '\2713';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.ap-card-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ap-card.active .ap-card-label {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ─── Style preset preview ─── */
|
||||
|
||||
.ap-card-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid;
|
||||
padding: 8px 7px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap-card-accent {
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ap-card-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ap-card-lines span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ─── Background effect preview ─── */
|
||||
|
||||
.ap-bg-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ap-bg-preview-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Mini previews for each effect type */
|
||||
[data-effect="none"] .ap-bg-preview-inner {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
[data-effect="noise"] .ap-bg-preview-inner {
|
||||
background: radial-gradient(ellipse at 30% 50%,
|
||||
color-mix(in srgb, var(--primary-color) 20%, var(--bg-color)) 0%,
|
||||
var(--bg-color) 70%);
|
||||
animation: ap-noise-shimmer 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ap-noise-shimmer {
|
||||
from { opacity: 0.7; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Aurora preview: horizontal gradient bands ── */
|
||||
[data-effect="aurora"] .ap-bg-preview-inner {
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--primary-color) 18%, transparent) 25%,
|
||||
transparent 50%,
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent) 70%,
|
||||
transparent 100%);
|
||||
animation: ap-aurora-sway 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ap-aurora-sway {
|
||||
from { transform: translateX(-5%) scaleY(1); opacity: 0.8; }
|
||||
to { transform: translateX(5%) scaleY(1.15); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Plasma preview: color blobs ── */
|
||||
[data-effect="plasma"] .ap-bg-preview-inner {
|
||||
background:
|
||||
radial-gradient(circle at 30% 40%,
|
||||
color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 60%,
|
||||
color-mix(in srgb, var(--primary-color) 15%, transparent) 0%, transparent 50%),
|
||||
radial-gradient(circle at 50% 20%,
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent) 0%, transparent 40%);
|
||||
animation: ap-plasma-cycle 5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ap-plasma-cycle {
|
||||
from { transform: scale(1) rotate(0deg); }
|
||||
to { transform: scale(1.1) rotate(3deg); }
|
||||
}
|
||||
|
||||
/* ── Digital Rain preview: vertical lines ── */
|
||||
[data-effect="rain"] .ap-bg-preview-inner {
|
||||
background:
|
||||
linear-gradient(180deg, var(--primary-color) 0%, transparent 60%) 10% 0 / 1px 70% no-repeat,
|
||||
linear-gradient(180deg, var(--primary-color) 0%, transparent 50%) 25% 20% / 1px 50% no-repeat,
|
||||
linear-gradient(180deg, var(--primary-color) 0%, transparent 60%) 40% 10% / 1px 60% no-repeat,
|
||||
linear-gradient(180deg, var(--primary-color) 0%, transparent 40%) 55% 30% / 1px 40% no-repeat,
|
||||
linear-gradient(180deg, var(--primary-color) 0%, transparent 55%) 70% 5% / 1px 55% no-repeat,
|
||||
linear-gradient(180deg, var(--primary-color) 0%, transparent 50%) 85% 15% / 1px 50% no-repeat;
|
||||
opacity: 0.4;
|
||||
animation: ap-rain-fall 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ap-rain-fall {
|
||||
from { transform: translateY(-20%); }
|
||||
to { transform: translateY(20%); }
|
||||
}
|
||||
|
||||
/* ── Stars preview: scattered dots ── */
|
||||
[data-effect="stars"] .ap-bg-preview-inner {
|
||||
background:
|
||||
radial-gradient(circle 1px at 15% 20%, #fff 0%, transparent 100%),
|
||||
radial-gradient(circle 1.5px at 40% 60%, var(--primary-color) 0%, transparent 100%),
|
||||
radial-gradient(circle 1px at 65% 30%, #fff 0%, transparent 100%),
|
||||
radial-gradient(circle 0.8px at 80% 70%, #fff 0%, transparent 100%),
|
||||
radial-gradient(circle 1.2px at 30% 85%, var(--primary-color) 0%, transparent 100%),
|
||||
radial-gradient(circle 0.7px at 55% 15%, #fff 0%, transparent 100%),
|
||||
radial-gradient(circle 1px at 90% 45%, #fff 0%, transparent 100%),
|
||||
radial-gradient(circle 0.9px at 10% 55%, #fff 0%, transparent 100%);
|
||||
opacity: 0.7;
|
||||
animation: ap-stars-twinkle 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ap-stars-twinkle {
|
||||
from { opacity: 0.5; }
|
||||
to { opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ── Warp preview: radial tunnel ── */
|
||||
[data-effect="warp"] .ap-bg-preview-inner {
|
||||
background: radial-gradient(circle at 50% 50%,
|
||||
color-mix(in srgb, var(--primary-color) 20%, transparent) 0%,
|
||||
transparent 30%,
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent) 50%,
|
||||
transparent 70%,
|
||||
color-mix(in srgb, var(--primary-color) 6%, transparent) 90%);
|
||||
animation: ap-warp-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ap-warp-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.7; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
[data-effect="grid"] .ap-bg-preview-inner {
|
||||
background-image:
|
||||
radial-gradient(circle, var(--text-muted) 0.5px, transparent 0.5px);
|
||||
background-size: 8px 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-effect="mesh"] .ap-bg-preview-inner {
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 30%,
|
||||
color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 70%,
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
[data-effect="scanlines"] .ap-bg-preview-inner {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0px,
|
||||
transparent 2px,
|
||||
color-mix(in srgb, var(--text-muted) 10%, transparent) 2px,
|
||||
color-mix(in srgb, var(--text-muted) 10%, transparent) 3px
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══ Full-page background effects ═══
|
||||
Uses a dedicated <div id="bg-effect-layer"> (same pattern as the WebGL canvas).
|
||||
The active effect class (e.g. .bg-effect-grid) is set directly on the div.
|
||||
Shader effects use <canvas id="bg-effect-canvas"> instead. */
|
||||
|
||||
/* When a CSS/shader bg effect is active, make body transparent so the layer shows
|
||||
(mirrors [data-bg-anim="on"] body { background: transparent } in base.css) */
|
||||
[data-bg-effect] body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-bg-effect] header {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-bg-effect] header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, var(--bg-color) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Card translucency for bg effects (match existing bg-anim behaviour) */
|
||||
[data-bg-effect][data-theme="dark"] .card,
|
||||
[data-bg-effect][data-theme="dark"] .template-card,
|
||||
[data-bg-effect][data-theme="dark"] .add-device-card,
|
||||
[data-bg-effect][data-theme="dark"] .dashboard-target {
|
||||
background: rgba(45, 45, 45, 0.92);
|
||||
}
|
||||
|
||||
[data-bg-effect][data-theme="light"] .card,
|
||||
[data-bg-effect][data-theme="light"] .template-card,
|
||||
[data-bg-effect][data-theme="light"] .add-device-card,
|
||||
[data-bg-effect][data-theme="light"] .dashboard-target {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
#bg-effect-layer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#bg-effect-layer.bg-effect-grid,
|
||||
#bg-effect-layer.bg-effect-mesh,
|
||||
#bg-effect-layer.bg-effect-scanlines {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Grid: dot matrix ── */
|
||||
#bg-effect-layer.bg-effect-grid {
|
||||
background-image:
|
||||
radial-gradient(circle 1.5px, var(--text-color) 0%, transparent 100%);
|
||||
background-size: 24px 24px;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
/* ── Gradient mesh: ambient blobs ── */
|
||||
#bg-effect-layer.bg-effect-mesh {
|
||||
background:
|
||||
radial-gradient(ellipse 600px 400px at 15% 20%,
|
||||
color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 500px 500px at 85% 80%,
|
||||
color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 100%),
|
||||
radial-gradient(ellipse 400px 300px at 60% 40%,
|
||||
color-mix(in srgb, var(--primary-color) 14%, transparent) 0%, transparent 100%);
|
||||
animation: bg-mesh-drift 20s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes bg-mesh-drift {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-20px, 15px) scale(1.05); }
|
||||
100% { transform: translate(10px, -10px) scale(0.98); }
|
||||
}
|
||||
|
||||
/* ── Scanlines: retro CRT ── */
|
||||
#bg-effect-layer.bg-effect-scanlines {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent 0px,
|
||||
transparent 3px,
|
||||
color-mix(in srgb, var(--text-muted) 8%, transparent) 3px,
|
||||
color-mix(in srgb, var(--text-muted) 8%, transparent) 4px
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Mobile: 2-column grid ─── */
|
||||
@media (max-width: 480px) {
|
||||
.ap-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ body {
|
||||
|
||||
html {
|
||||
background: var(--bg-color);
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
@@ -132,7 +133,8 @@ html.modal-open {
|
||||
}
|
||||
|
||||
/* ── Ambient animated background ── */
|
||||
#bg-anim-canvas {
|
||||
#bg-anim-canvas,
|
||||
#bg-effect-canvas {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -480,13 +480,11 @@ body.cs-drag-active .card-drag-handle {
|
||||
.card-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-title-text {
|
||||
@@ -500,6 +498,7 @@ body.cs-drag-active .card-drag-handle {
|
||||
color: var(--primary-text-color);
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-url-badge {
|
||||
|
||||
@@ -1024,3 +1024,68 @@ textarea:focus-visible {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* ── Schedule time picker (value sources) ── */
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.schedule-time-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
transition: border-color var(--duration-fast) ease;
|
||||
}
|
||||
.schedule-time-wrap:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
.schedule-time-wrap input[type="number"] {
|
||||
width: 2.4ch;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: inherit;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-color);
|
||||
padding: 4px 2px;
|
||||
-moz-appearance: textfield;
|
||||
transition: border-color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
.schedule-time-wrap input[type="number"]::-webkit-inner-spin-button,
|
||||
.schedule-time-wrap input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.schedule-time-wrap input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-secondary));
|
||||
}
|
||||
.schedule-time-colon {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
padding: 0 1px;
|
||||
user-select: none;
|
||||
}
|
||||
.schedule-value {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
.schedule-value-display {
|
||||
min-width: 2.5ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,11 @@
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.dashboard-target-info > div {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-target-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -130,6 +135,11 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-target-name-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dashboard-card-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,58 @@ h2 {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.3s, color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
#server-version.has-update {
|
||||
background: var(--warning-color);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
animation: updatePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes updatePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); }
|
||||
}
|
||||
|
||||
/* ── Update banner ── */
|
||||
.update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
animation: bannerSlideDown 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.update-banner-text {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.update-banner-action {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.update-banner-action:hover {
|
||||
color: var(--primary-color);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
@keyframes bannerSlideDown {
|
||||
from { transform: translateY(-100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
|
||||
@@ -580,6 +580,10 @@
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-header-actions {
|
||||
@@ -1214,7 +1218,8 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#gradient-canvas {
|
||||
#gradient-canvas,
|
||||
#ge-gradient-canvas {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
display: block;
|
||||
@@ -1531,6 +1536,78 @@
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.composite-layer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.composite-layer-expand-btn {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.composite-layer-expanded .composite-layer-expand-btn {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.composite-layer-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.composite-layer-summary-name {
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.composite-layer-summary-blend {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.composite-layer-body-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.2s ease;
|
||||
}
|
||||
|
||||
.composite-layer-expanded .composite-layer-body-wrapper {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.composite-layer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 0;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
transition: padding-top 0.2s ease;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.composite-layer-expanded .composite-layer-body {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.composite-layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1545,7 +1622,6 @@
|
||||
.composite-layer-brightness-label {
|
||||
flex-shrink: 0;
|
||||
width: 90px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -1561,7 +1637,6 @@
|
||||
}
|
||||
|
||||
.composite-layer-opacity-label {
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1576,11 +1651,66 @@
|
||||
}
|
||||
|
||||
.composite-layer-remove-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.composite-layer-remove-btn:hover {
|
||||
color: var(--danger-color);
|
||||
background: color-mix(in srgb, var(--danger-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.composite-layer-range-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composite-layer-range-fields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composite-layer-range-fields label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.composite-layer-range-fields input[type="number"] {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.composite-layer-range-disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composite-layer-reverse-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Composite layer drag-to-reorder ── */
|
||||
@@ -1608,6 +1738,31 @@
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* ── Weather source location row ── */
|
||||
|
||||
.weather-location-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.weather-location-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.weather-location-field label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weather-location-field input[type="number"] {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.composite-layer-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-card .template-card-header {
|
||||
@@ -81,6 +83,7 @@
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.template-name > .icon {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
// Layer 0: state
|
||||
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
|
||||
import { apiKey, setApiKey, authRequired, refreshInterval } from './core/state.ts';
|
||||
import { Modal } from './core/modal.ts';
|
||||
import { queryEl } from './core/dom-utils.ts';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { t, initLocale, changeLocale } from './core/i18n.ts';
|
||||
// Layer 1.5: visual effects
|
||||
import { initCardGlare } from './core/card-glare.ts';
|
||||
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
|
||||
import { initBgShaders } from './core/bg-shaders.ts';
|
||||
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
|
||||
|
||||
// Layer 2: ui
|
||||
@@ -117,16 +118,20 @@ import {
|
||||
// Layer 5: color-strip sources
|
||||
import {
|
||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
|
||||
colorCycleAddColor, colorCycleRemoveColor,
|
||||
compositeAddLayer, compositeRemoveLayer,
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
onGradientPresetChange,
|
||||
promptAndSaveGradientPreset,
|
||||
applyCustomGradientPreset,
|
||||
deleteAndRefreshGradientPreset,
|
||||
showGradientModal,
|
||||
closeGradientEditor,
|
||||
saveGradientEntity,
|
||||
cloneGradient,
|
||||
editGradient,
|
||||
deleteGradient,
|
||||
cloneColorStrip,
|
||||
toggleCSSOverlay,
|
||||
previewCSSFromEditor,
|
||||
@@ -180,18 +185,25 @@ import {
|
||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
|
||||
import { navigateToCard } from './core/navigation.ts';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
|
||||
import {
|
||||
applyStylePreset, applyBgEffect, renderAppearanceTab, initAppearance,
|
||||
} from './features/appearance.ts';
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||
downloadBackup, handleRestoreFileSelected,
|
||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
saveAutoBackupSettings, triggerBackupNow, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
restartServer, saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport, handlePartialImportFileSelected,
|
||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||
openLogOverlay, closeLogOverlay,
|
||||
loadLogLevel, setLogLevel,
|
||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
} from './features/settings.ts';
|
||||
import {
|
||||
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||
loadUpdateSettings, saveUpdateSettings, dismissUpdate,
|
||||
initUpdateSettingsPanel, applyUpdate,
|
||||
} from './features/update.ts';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
|
||||
@@ -426,6 +438,7 @@ Object.assign(window, {
|
||||
deleteColorStrip,
|
||||
onCSSTypeChange,
|
||||
onEffectTypeChange,
|
||||
onEffectPaletteChange,
|
||||
onCSSClockChange,
|
||||
onAnimationTypeChange,
|
||||
onDaylightRealTimeChange,
|
||||
@@ -436,11 +449,15 @@ Object.assign(window, {
|
||||
mappedAddZone,
|
||||
mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
onGradientPresetChange,
|
||||
promptAndSaveGradientPreset,
|
||||
applyCustomGradientPreset,
|
||||
deleteAndRefreshGradientPreset,
|
||||
showGradientModal,
|
||||
closeGradientEditor,
|
||||
saveGradientEntity,
|
||||
cloneGradient,
|
||||
editGradient,
|
||||
deleteGradient,
|
||||
cloneColorStrip,
|
||||
toggleCSSOverlay,
|
||||
previewCSSFromEditor,
|
||||
@@ -523,21 +540,20 @@ Object.assign(window, {
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
|
||||
// settings (tabs / backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
|
||||
// settings (tabs / backup / restore / auto-backup / MQTT / api keys / log level)
|
||||
openSettingsModal,
|
||||
closeSettingsModal,
|
||||
switchSettingsTab,
|
||||
downloadBackup,
|
||||
handleRestoreFileSelected,
|
||||
saveAutoBackupSettings,
|
||||
triggerBackupNow,
|
||||
restoreSavedBackup,
|
||||
downloadSavedBackup,
|
||||
deleteSavedBackup,
|
||||
restartServer,
|
||||
saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport,
|
||||
handlePartialImportFileSelected,
|
||||
connectLogViewer,
|
||||
disconnectLogViewer,
|
||||
clearLogViewer,
|
||||
@@ -548,6 +564,19 @@ Object.assign(window, {
|
||||
setLogLevel,
|
||||
saveExternalUrl,
|
||||
getBaseOrigin,
|
||||
|
||||
// update
|
||||
checkForUpdates,
|
||||
loadUpdateSettings,
|
||||
saveUpdateSettings,
|
||||
dismissUpdate,
|
||||
initUpdateSettingsPanel,
|
||||
applyUpdate,
|
||||
|
||||
// appearance
|
||||
applyStylePreset,
|
||||
applyBgEffect,
|
||||
renderAppearanceTab,
|
||||
});
|
||||
|
||||
// ─── Global keyboard shortcuts ───
|
||||
@@ -626,6 +655,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize visual effects
|
||||
initCardGlare();
|
||||
initBgAnim();
|
||||
initBgShaders();
|
||||
initAppearance();
|
||||
initTabIndicator();
|
||||
updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light');
|
||||
const accent = localStorage.getItem('accentColor') || '#4CAF50';
|
||||
@@ -659,11 +690,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
|
||||
|
||||
// Always monitor server connection (even before login)
|
||||
loadServerInfo();
|
||||
await loadServerInfo();
|
||||
startConnectionMonitor();
|
||||
|
||||
// Show modal if no API key is stored
|
||||
if (!apiKey) {
|
||||
// Expose auth state for inline scripts (after loadServerInfo sets it)
|
||||
(window as any)._authRequired = authRequired;
|
||||
if (typeof window.updateAuthUI === 'function') window.updateAuthUI();
|
||||
|
||||
// Show login modal only when auth is enabled and no API key is stored
|
||||
if (authRequired && !apiKey) {
|
||||
setTimeout(() => {
|
||||
if (typeof window.showApiKeyModal === 'function') {
|
||||
window.showApiKeyModal(null, true);
|
||||
@@ -681,6 +716,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
startEntityEventListeners();
|
||||
startAutoRefresh();
|
||||
|
||||
// Initialize update checker (banner + WS listener)
|
||||
initUpdateListener();
|
||||
loadUpdateStatus();
|
||||
|
||||
// Show getting-started tutorial on first visit
|
||||
if (!localStorage.getItem('tour_completed')) {
|
||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* API utilities — base URL, auth headers, fetch wrapper, helpers.
|
||||
*/
|
||||
|
||||
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { showToast } from './ui.ts';
|
||||
import { getEl, queryEl } from './dom-utils.ts';
|
||||
import { serverRestarting, clearRestartingFlag } from './events-ws.ts';
|
||||
|
||||
export const API_BASE = '/api/v1';
|
||||
|
||||
@@ -137,6 +138,7 @@ export function isGameSenseDevice(type: string) {
|
||||
}
|
||||
|
||||
export function handle401Error() {
|
||||
if (!authRequired) return; // Auth disabled — ignore 401s
|
||||
if (!apiKey) return; // Already handled or no session
|
||||
localStorage.removeItem('wled_api_key');
|
||||
setApiKey(null);
|
||||
@@ -167,6 +169,30 @@ export function handle401Error() {
|
||||
let _connCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _serverOnline: boolean | null = null; // null = unknown, true/false
|
||||
|
||||
/** Toggle which message block is visible inside the connection overlay. */
|
||||
function _setOverlayMode(restarting: boolean) {
|
||||
const msgOffline = document.getElementById('conn-msg-offline');
|
||||
const msgRestarting = document.getElementById('conn-msg-restarting');
|
||||
if (msgOffline) msgOffline.style.display = restarting ? 'none' : '';
|
||||
if (msgRestarting) msgRestarting.style.display = restarting ? '' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the restart overlay immediately (called when server_restarting
|
||||
* event arrives via WebSocket, before the connection actually drops).
|
||||
*/
|
||||
export function showRestartingOverlay() {
|
||||
_serverOnline = false;
|
||||
const banner = document.getElementById('connection-overlay');
|
||||
const badge = document.getElementById('server-status');
|
||||
if (banner) {
|
||||
(banner as HTMLElement).style.display = 'flex';
|
||||
banner.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
_setOverlayMode(true);
|
||||
if (badge) badge.className = 'status-badge offline';
|
||||
}
|
||||
|
||||
function _setConnectionState(online: boolean) {
|
||||
const changed = _serverOnline !== online;
|
||||
_serverOnline = online;
|
||||
@@ -175,8 +201,14 @@ function _setConnectionState(online: boolean) {
|
||||
if (online) {
|
||||
if (banner) { (banner as HTMLElement).style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); }
|
||||
if (badge) badge.className = 'status-badge online';
|
||||
// Clear the restarting flag once the server is back
|
||||
if (serverRestarting) clearRestartingFlag();
|
||||
} else {
|
||||
if (banner) { (banner as HTMLElement).style.display = 'flex'; banner.setAttribute('aria-hidden', 'false'); }
|
||||
if (banner) {
|
||||
(banner as HTMLElement).style.display = 'flex';
|
||||
banner.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
_setOverlayMode(serverRestarting);
|
||||
if (badge) badge.className = 'status-badge offline';
|
||||
}
|
||||
return changed;
|
||||
@@ -200,6 +232,11 @@ export async function loadServerInfo() {
|
||||
window.dispatchEvent(new CustomEvent('server:reconnected'));
|
||||
}
|
||||
|
||||
// Auth mode detection
|
||||
const authNeeded = data.auth_required !== false;
|
||||
setAuthRequired(authNeeded);
|
||||
(window as any)._authRequired = authNeeded;
|
||||
|
||||
// Demo mode detection
|
||||
if (data.demo_mode && !demoMode) {
|
||||
demoMode = true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user