Compare commits

...

2 Commits

Author SHA1 Message Date
55772b58dd Replace deploy workflow with portable Windows release build
- Remove old Docker-based deploy.yml
- Add release.yml: builds portable ZIP on tag push, uploads to Gitea
- Add build-dist.ps1: downloads embedded Python, installs deps, bundles app
- Add scrollbar-gutter: stable to prevent layout shift

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:21:55 +03:00
968046d96b HA integration: fix reload loop, parallel device fetch, WS guards, translations
- Fix indentation bug causing scenes device to not register
- Use nonlocal tracking to prevent infinite reload loops on target/scene changes
- Guard WS start/stop to avoid redundant connections
- Parallel device brightness fetching via asyncio.gather
- Route set_leds service to correct coordinator by source ID
- Remove stale pattern cache, reuse single timeout object
- Fix translations structure for light/select entities
- Unregister service when last config entry unloaded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:21:46 +03:00
11 changed files with 430 additions and 101 deletions

View File

@@ -1,31 +0,0 @@
name: Build and Deploy
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to /opt/wled-controller
run: |
DEPLOY_DIR=/opt/wled-controller
# Ensure deploy directory exists
mkdir -p "$DEPLOY_DIR/data" "$DEPLOY_DIR/logs" "$DEPLOY_DIR/config"
# Copy server files to deploy directory
rsync -a --delete \
--exclude 'data/' \
--exclude 'logs/' \
server/ "$DEPLOY_DIR/"
# Build and restart
cd "$DEPLOY_DIR"
docker compose down
docker compose up -d --build

View File

@@ -0,0 +1,74 @@
name: Build Release
on:
push:
tags:
- 'v*'
jobs:
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build portable distribution
shell: pwsh
run: |
.\build-dist.ps1 -Version "${{ gitea.ref_name }}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: LedGrab-${{ gitea.ref_name }}-win-x64
path: build/LedGrab-*.zip
retention-days: 90
- name: Create Gitea release
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
$tag = "${{ gitea.ref_name }}"
$zipFile = Get-ChildItem "build\LedGrab-*.zip" | Select-Object -First 1
if (-not $zipFile) { throw "ZIP not found" }
$baseUrl = "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
$headers = @{
"Authorization" = "token $env:GITEA_TOKEN"
"Content-Type" = "application/json"
}
# Create release
$body = @{
tag_name = $tag
name = "LedGrab $tag"
body = "Portable Windows build — unzip, run ``LedGrab.bat``, open http://localhost:8080"
draft = $false
prerelease = ($tag -match '(alpha|beta|rc)')
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post `
-Uri "$baseUrl/releases" `
-Headers $headers -Body $body
Write-Host "Created release: $($release.html_url)"
# Upload ZIP asset
$uploadHeaders = @{
"Authorization" = "token $env:GITEA_TOKEN"
}
$uploadUrl = "$baseUrl/releases/$($release.id)/assets?name=$($zipFile.Name)"
Invoke-RestMethod -Method Post -Uri $uploadUrl `
-Headers $uploadHeaders `
-ContentType "application/octet-stream" `
-InFile $zipFile.FullName
Write-Host "Uploaded: $($zipFile.Name)"

250
build-dist.ps1 Normal file
View File

@@ -0,0 +1,250 @@
<#
.SYNOPSIS
Build a portable Windows distribution of LedGrab.
.DESCRIPTION
Downloads embedded Python, installs all dependencies, copies app code,
builds the frontend bundle, and produces a self-contained ZIP.
.PARAMETER Version
Version string (e.g. "0.1.0" or "v0.1.0"). Auto-detected from git tag
or __init__.py if omitted.
.PARAMETER PythonVersion
Embedded Python version to download. Default: 3.11.9
.PARAMETER SkipFrontend
Skip npm ci + npm run build (use if frontend is already built).
.PARAMETER SkipPerf
Skip installing optional [perf] extras (dxcam, bettercam, windows-capture).
.EXAMPLE
.\build-dist.ps1
.\build-dist.ps1 -Version "0.2.0"
.\build-dist.ps1 -SkipFrontend -SkipPerf
#>
param(
[string]$Version = "",
[string]$PythonVersion = "3.11.9",
[switch]$SkipFrontend,
[switch]$SkipPerf
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # faster downloads
$ScriptRoot = $PSScriptRoot
$BuildDir = Join-Path $ScriptRoot "build"
$DistName = "LedGrab"
$DistDir = Join-Path $BuildDir $DistName
$ServerDir = Join-Path $ScriptRoot "server"
$PythonDir = Join-Path $DistDir "python"
$AppDir = Join-Path $DistDir "app"
# ── Version detection ──────────────────────────────────────────
if (-not $Version) {
# Try git tag
try {
$gitTag = git describe --tags --exact-match 2>$null
if ($gitTag) { $Version = $gitTag }
} catch {}
}
if (-not $Version) {
# Try env var (CI)
if ($env:GITEA_REF_NAME) { $Version = $env:GITEA_REF_NAME }
elseif ($env:GITHUB_REF_NAME) { $Version = $env:GITHUB_REF_NAME }
}
if (-not $Version) {
# Parse from __init__.py
$initFile = Join-Path $ServerDir "src\wled_controller\__init__.py"
$match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"'
if ($match) { $Version = $match.Matches[0].Groups[1].Value }
}
if (-not $Version) { $Version = "0.0.0" }
# Strip leading 'v' for filenames
$VersionClean = $Version -replace '^v', ''
$ZipName = "LedGrab-v${VersionClean}-win-x64.zip"
Write-Host "=== Building LedGrab v${VersionClean} ===" -ForegroundColor Cyan
Write-Host " Python: $PythonVersion"
Write-Host " Output: build\$ZipName"
Write-Host ""
# ── Clean ──────────────────────────────────────────────────────
if (Test-Path $DistDir) {
Write-Host "[1/8] Cleaning previous build..."
Remove-Item -Recurse -Force $DistDir
}
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
# ── Download embedded Python ───────────────────────────────────
$PythonZipUrl = "https://www.python.org/ftp/python/${PythonVersion}/python-${PythonVersion}-embed-amd64.zip"
$PythonZipPath = Join-Path $BuildDir "python-embed.zip"
Write-Host "[2/8] Downloading embedded Python ${PythonVersion}..."
if (-not (Test-Path $PythonZipPath)) {
Invoke-WebRequest -Uri $PythonZipUrl -OutFile $PythonZipPath
}
Write-Host " Extracting to python/..."
Expand-Archive -Path $PythonZipPath -DestinationPath $PythonDir -Force
# ── Patch ._pth to enable site-packages ────────────────────────
Write-Host "[3/8] Patching Python path configuration..."
$pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" | Select-Object -First 1
if (-not $pthFile) { throw "Could not find python*._pth in $PythonDir" }
$pthContent = Get-Content $pthFile.FullName -Raw
# Uncomment 'import site'
$pthContent = $pthContent -replace '#\s*import site', 'import site'
# Add Lib\site-packages if not present
if ($pthContent -notmatch 'Lib\\site-packages') {
$pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n"
}
Set-Content -Path $pthFile.FullName -Value $pthContent -NoNewline
Write-Host " Patched $($pthFile.Name)"
# ── Install pip ────────────────────────────────────────────────
Write-Host "[4/8] Installing pip..."
$GetPipPath = Join-Path $BuildDir "get-pip.py"
if (-not (Test-Path $GetPipPath)) {
Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPipPath
}
$python = Join-Path $PythonDir "python.exe"
$ErrorActionPreference = 'Continue'
& $python $GetPipPath --no-warn-script-location 2>&1 | Out-Null
$ErrorActionPreference = 'Stop'
if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" }
# ── Install dependencies ──────────────────────────────────────
Write-Host "[5/8] Installing dependencies..."
$extras = "camera,notifications"
if (-not $SkipPerf) { $extras += ",perf" }
# Install the project (pulls all deps via pyproject.toml), then remove
# the installed package itself — PYTHONPATH handles app code loading.
$ErrorActionPreference = 'Continue'
& $python -m pip install --no-warn-script-location "${ServerDir}[${extras}]" 2>&1 | ForEach-Object {
if ($_ -match 'ERROR|Failed') { Write-Host " $_" -ForegroundColor Red }
}
$ErrorActionPreference = 'Stop'
if ($LASTEXITCODE -ne 0) {
Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow
}
# Remove the installed wled_controller package to avoid duplication
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# Clean up caches and test files to reduce size
Write-Host " Cleaning up caches..."
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "tests" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "test" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# ── Build frontend ─────────────────────────────────────────────
if (-not $SkipFrontend) {
Write-Host "[6/8] Building frontend bundle..."
Push-Location $ServerDir
try {
$ErrorActionPreference = 'Continue'
& npm ci --loglevel error 2>&1 | Out-Null
& npm run build 2>&1 | ForEach-Object {
$line = "$_"
if ($line -and $line -notmatch 'RemoteException') { Write-Host " $line" }
}
$ErrorActionPreference = 'Stop'
} finally {
Pop-Location
}
} else {
Write-Host "[6/8] Skipping frontend build (--SkipFrontend)"
}
# ── Copy application files ─────────────────────────────────────
Write-Host "[7/8] Copying application files..."
New-Item -ItemType Directory -Path $AppDir -Force | Out-Null
# Copy source code (includes static/dist bundle, templates, locales)
$srcDest = Join-Path $AppDir "src"
Copy-Item -Path (Join-Path $ServerDir "src") -Destination $srcDest -Recurse
# Copy config
$configDest = Join-Path $AppDir "config"
Copy-Item -Path (Join-Path $ServerDir "config") -Destination $configDest -Recurse
# Create empty data/ and logs/ directories
New-Item -ItemType Directory -Path (Join-Path $DistDir "data") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null
# Clean up source maps and __pycache__ from app code
Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# ── Create launcher ────────────────────────────────────────────
Write-Host "[8/8] Creating launcher..."
$launcherContent = @'
@echo off
title LedGrab v%VERSION%
cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml
:: Create data directory if missing
if not exist "%~dp0data" mkdir "%~dp0data"
if not exist "%~dp0logs" mkdir "%~dp0logs"
echo.
echo =============================================
echo LedGrab v%VERSION%
echo Open http://localhost:8080 in your browser
echo =============================================
echo.
:: Start the server (open browser after short delay)
start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080"
"%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
pause
'@
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
$launcherPath = Join-Path $DistDir "LedGrab.bat"
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
# ── Create ZIP ─────────────────────────────────────────────────
$ZipPath = Join-Path $BuildDir $ZipName
if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath }
Write-Host ""
Write-Host "Creating $ZipName..." -ForegroundColor Cyan
# Use 7-Zip if available (faster, handles locked files), else fall back to Compress-Archive
$7z = Get-Command 7z -ErrorAction SilentlyContinue
if ($7z) {
& 7z a -tzip -mx=7 $ZipPath "$DistDir\*" | Select-Object -Last 3
} else {
Compress-Archive -Path "$DistDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal
}
$zipSize = (Get-Item $ZipPath).Length / 1MB
Write-Host ""
Write-Host "=== Build complete ===" -ForegroundColor Green
Write-Host " Archive: $ZipPath"
Write-Host " Size: $([math]::Round($zipSize, 1)) MB"
Write-Host ""

View File

@@ -95,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
model="Scene Presets",
configuration_url=server_url,
)
current_identifiers.add(scenes_identifier)
current_identifiers.add(scenes_identifier)
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(
@@ -114,15 +114,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
}
# Track target and scene IDs to detect changes
initial_target_ids = set(
known_target_ids = set(
coordinator.data.get("targets", {}).keys() if coordinator.data else []
)
initial_scene_ids = set(
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
def _on_coordinator_update() -> None:
"""Manage WS connections and detect target list changes."""
nonlocal known_target_ids, known_scene_ids
if not coordinator.data:
return
@@ -134,16 +136,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
state = target_data.get("state") or {}
if info.get("target_type") == TARGET_TYPE_KEY_COLORS:
if state.get("processing"):
hass.async_create_task(ws_manager.start_listening(target_id))
if target_id not in ws_manager._connections:
hass.async_create_task(ws_manager.start_listening(target_id))
else:
hass.async_create_task(ws_manager.stop_listening(target_id))
if target_id in ws_manager._connections:
hass.async_create_task(ws_manager.stop_listening(target_id))
# Reload if target or scene list changed
current_ids = set(targets.keys())
current_scene_ids = set(
p["id"] for p in coordinator.data.get("scene_presets", [])
)
if current_ids != initial_target_ids or current_scene_ids != initial_scene_ids:
if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
known_target_ids = current_ids
known_scene_ids = current_scene_ids
_LOGGER.info("Target or scene list changed, reloading integration")
hass.async_create_task(
hass.config_entries.async_reload(entry.entry_id)
@@ -156,11 +162,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
# Route to the coordinator that owns this source
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
if coord:
if not coord or not coord.data:
continue
source_ids = {
s["id"] for s in coord.data.get("css_sources", [])
}
if source_id in source_ids:
await coord.push_segments(source_id, segments)
break
return
_LOGGER.error("No server found with source_id %s", source_id)
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
@@ -188,5 +201,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Unregister service if no entries remain
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "set_leds")
return unload_ok

View File

@@ -65,10 +65,9 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity):
"""Return if entity is available."""
if not self.coordinator.data:
return False
return any(
p["id"] == self._preset_id
for p in self.coordinator.data.get("scene_presets", [])
)
return self._preset_id in {
p["id"] for p in self.coordinator.data.get("scene_presets", [])
}
async def async_press(self) -> None:
"""Activate the scene preset."""

View File

@@ -37,7 +37,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
self.api_key = api_key
self.server_version = "unknown"
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
self._pattern_cache: dict[str, list[dict]] = {}
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__(
hass,
@@ -85,7 +85,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
kc_settings = target.get("key_colors_settings") or {}
template_id = kc_settings.get("pattern_template_id", "")
if template_id:
result["rectangles"] = await self._get_rectangles(
result["rectangles"] = await self._fetch_rectangles(
template_id
)
else:
@@ -136,7 +136,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
try:
async with self.session.get(
f"{self.server_url}/health",
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -150,7 +150,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -161,7 +161,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
@@ -171,27 +171,22 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _get_rectangles(self, template_id: str) -> list[dict]:
"""Get rectangles for a pattern template, using cache."""
if template_id in self._pattern_cache:
return self._pattern_cache[template_id]
async def _fetch_rectangles(self, template_id: str) -> list[dict]:
"""Fetch rectangles for a pattern template (no cache — always fresh)."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/pattern-templates/{template_id}",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
rectangles = data.get("rectangles", [])
self._pattern_cache[template_id] = rectangles
return rectangles
return data.get("rectangles", [])
except Exception as err:
_LOGGER.warning(
"Failed to fetch pattern template %s: %s", template_id, err
@@ -204,7 +199,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/devices",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -213,18 +208,16 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch devices: %s", err)
return {}
devices_data: dict[str, dict[str, Any]] = {}
for device in devices:
# Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []):
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 200:
bri_data = await resp.json()
@@ -234,7 +227,19 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to fetch brightness for device %s: %s",
device_id, err,
)
return device_id, entry
results = await asyncio.gather(
*(fetch_device_entry(d) for d in devices),
return_exceptions=True,
)
devices_data: dict[str, dict[str, Any]] = {}
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Device fetch failed: %s", r)
continue
device_id, entry = r
devices_data[device_id] = entry
return devices_data
@@ -245,7 +250,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -262,7 +267,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -280,7 +285,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"key_colors_settings": {"brightness": brightness_float}},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -297,7 +302,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -312,7 +317,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -327,7 +332,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.get(
f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
@@ -342,7 +347,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
@@ -358,7 +363,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
@@ -373,7 +378,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -390,7 +395,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -398,14 +403,15 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
"Failed to update source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update a output target's fields."""
"""Update an output target's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
@@ -421,7 +427,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id)
@@ -439,7 +445,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers,
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id)

View File

@@ -63,9 +63,13 @@ class ApiInputLight(CoordinatorEntity, LightEntity):
self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light"
# Local state — not derived from coordinator data
self._is_on: bool = False
self._rgb_color: tuple[int, int, int] = (255, 255, 255)
# Restore state from fallback_color
fallback = self._get_fallback_color()
is_off = fallback == [0, 0, 0]
self._is_on: bool = not is_off
self._rgb_color: tuple[int, int, int] = (
(255, 255, 255) if is_off else tuple(fallback) # type: ignore[arg-type]
)
self._brightness: int = 255
@property

View File

@@ -96,7 +96,7 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
return self._target_id in self.coordinator.data.get("targets", {})
async def async_select_option(self, option: str) -> None:
source_id = self._name_to_id(option)
source_id = self._name_to_id_map().get(option)
if source_id is None:
_LOGGER.error("CSS source not found: %s", option)
return
@@ -104,12 +104,9 @@ class CSSSourceSelect(CoordinatorEntity, SelectEntity):
self._target_id, color_strip_source_id=source_id
)
def _name_to_id(self, name: str) -> str | None:
def _name_to_id_map(self) -> dict[str, str]:
sources = (self.coordinator.data or {}).get("css_sources") or []
for s in sources:
if s["name"] == name:
return s["id"]
return None
return {s["name"]: s["id"] for s in sources}
class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
@@ -167,17 +164,14 @@ class BrightnessSourceSelect(CoordinatorEntity, SelectEntity):
if option == NONE_OPTION:
source_id = ""
else:
source_id = self._name_to_id(option)
name_map = {
s["name"]: s["id"]
for s in (self.coordinator.data or {}).get("value_sources") or []
}
source_id = name_map.get(option)
if source_id is None:
_LOGGER.error("Value source not found: %s", option)
return
await self.coordinator.update_target(
self._target_id, brightness_value_source_id=source_id
)
def _name_to_id(self, name: str) -> str | None:
sources = (self.coordinator.data or {}).get("value_sources") or []
for s in sources:
if s["name"] == name:
return s["id"]
return None

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
@@ -58,9 +63,12 @@
"name": "Brightness"
}
},
"light": {
"light": {
"name": "Light"
"select": {
"color_strip_source": {
"name": "Color Strip Source"
},
"brightness_source": {
"name": "Brightness Source"
}
}
}

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}"
}
},
"light": {
"api_input_light": {
"name": "Подсветка"
}
},
"switch": {
"processing": {
"name": "Обработка"
@@ -58,9 +63,12 @@
"name": "Яркость"
}
},
"light": {
"light": {
"name": "Подсветка"
"select": {
"color_strip_source": {
"name": "Источник цветовой полосы"
},
"brightness_source": {
"name": "Источник яркости"
}
}
}

View File

@@ -81,6 +81,7 @@ html {
background: var(--bg-color);
overflow-y: scroll;
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
body {