feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
Build Android APK / build-android (push) Failing after 1m44s
Lint & Test / test (push) Successful in 4m22s

End-to-end BLE streaming: provider + client + per-protocol wire encoders
with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge
via Chaquopy) transports, discovery with protocol-family detection that
auto-fills the UI, throttled not-connected warning + 10 s reconnect
cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame,
and an explicit asyncio.wait_for wrapper around bleak connect() since
the WinRT backend doesn't always honor the timeout kwarg.

Also rewrites server/restart.ps1 to be parameterized (-Port / -Module /
-PythonVersion / timeouts / -Quiet), pick the right interpreter via the
py launcher, pre-flight the target module, poll port readiness on both
shutdown and startup, redirect child stdout/stderr so Start-Process
doesn't hang on inherited Git-Bash handles, and return proper exit codes.

Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh
resources, Chaquopy-safe value_stream psutil fallback, setup-required
modal, asset-store test coverage, and misc system/config touch-ups.
This commit is contained in:
2026-04-21 14:58:35 +03:00
parent d3a6416a1d
commit 2b5dac2c42
54 changed files with 3412 additions and 174 deletions
+307 -71
View File
@@ -1,78 +1,207 @@
# Restart the LedGrab server
# Uses graceful shutdown first (lets the server persist data to disk),
# then force-kills as a fallback.
<#
.SYNOPSIS
Restart a LedGrab Python server (real or demo) reliably.
$serverRoot = $PSScriptRoot
.DESCRIPTION
Gracefully asks the running instance to shut down via its HTTP API, waits
for the port to free, then launches a detached replacement and polls the
port until it is actually accepting connections.
# 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
The script is parameterised so it works for the real server (default:
port 8080, module `ledgrab`), the demo server, and any future variant —
no code edits required to point it somewhere else.
.PARAMETER Port
TCP port the server binds. Used both to locate the running process and
to poll startup readiness.
.PARAMETER Module
Python `-m` module to launch. Also used as a substring match when
identifying which python.exe processes belong to this server so we don't
kill unrelated Python instances.
.PARAMETER ServerRoot
Working directory for the server process. Defaults to the directory that
contains this script.
.PARAMETER ConfigPath
Path (relative to -ServerRoot or absolute) to the YAML config the running
server is using. Used only to read the API key for the graceful-shutdown
request. If empty or missing we skip graceful shutdown and force-kill.
.PARAMETER StartupTimeoutSec
How long to poll for the new server to start accepting connections.
.PARAMETER ShutdownTimeoutSec
How long to wait for the graceful-shutdown API call to cause the running
process to exit before force-killing it.
.PARAMETER SkipBrowser
Set LEDGRAB_RESTART=1 in the child env so the app doesn't open a browser
tab on startup. On by default — pass -SkipBrowser:$false to allow it.
.PARAMETER Quiet
Suppress progress messages; only emit warnings/errors.
.EXAMPLE
# Restart the real server (default invocation)
powershell -ExecutionPolicy Bypass -File restart.ps1
.EXAMPLE
# Restart the demo server on port 8081
powershell -ExecutionPolicy Bypass -File restart.ps1 `
-Port 8081 -Module ledgrab.demo -ConfigPath 'config\demo_config.yaml'
.NOTES
Exit codes:
0 — server is up and accepting connections on the target port
1 — startup timed out; process may or may not be running
2 — could not locate a Python interpreter
#>
[CmdletBinding()]
param(
[int]$Port = 8080,
[string]$Module = 'ledgrab',
[string]$ServerRoot = '',
[string]$ConfigPath = 'config\default_config.yaml',
[int]$StartupTimeoutSec = 30,
[int]$ShutdownTimeoutSec = 15,
[string]$PythonExe = '',
[string]$PythonVersion = '3.13',
[switch]$SkipBrowser = $true,
[switch]$Quiet
)
$ErrorActionPreference = 'Stop'
function Write-Info {
param([string]$Message)
if (-not $Quiet) { Write-Host $Message }
}
# ---- Resolve paths ---------------------------------------------------------
# PS 5.1 doesn't expand $PSScriptRoot at param-binding time, so apply it here.
if (-not $ServerRoot) { $ServerRoot = $PSScriptRoot }
if (-not $ServerRoot) {
Write-Error 'ServerRoot not provided and $PSScriptRoot is unavailable'
exit 2
}
if (-not (Test-Path $ServerRoot)) {
Write-Error "ServerRoot '$ServerRoot' does not exist"
exit 2
}
$ServerRoot = (Resolve-Path $ServerRoot).Path
$resolvedConfig = $null
if ($ConfigPath) {
$candidate = if ([IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath
} else {
Join-Path $ServerRoot $ConfigPath
}
if (Test-Path $candidate) { $resolvedConfig = $candidate }
}
# ---- Locate the running server ---------------------------------------------
function Get-ServerProcesses {
param([string]$ModuleName, [string]$Root)
# Match python.exe processes whose command line references this module AND
# whose cwd (via command line fragment) looks like it's running from this
# server root. Excludes unrelated python.exe (VS Code extensions, isort,
# pip tooling, etc.) by requiring a module reference.
$rootPattern = [regex]::Escape($Root)
Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue |
Where-Object {
$cl = $_.CommandLine
if (-not $cl) { return $false }
# Must launch the target module via `-m <Module>` or an exact token
$launchesModule = $cl -match ('-m\s+' + [regex]::Escape($ModuleName) + '(\s|$|\.)')
if (-not $launchesModule) { return $false }
# Exclude obvious tooling false-positives
if ($cl -match '(vscode|isort|pip[-\s]|flake8|ruff|mypy|pylint|black)') {
return $false
}
return $true
}
if ($inKeys -and $line -match '^\S') { break } # left the api_keys block
}
function Test-PortOpen {
param([int]$Port)
try {
$listener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop
return [bool]$listener
} catch {
return $false
}
}
# Find running server processes
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
$existing = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot
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"
# ---- Graceful shutdown (if the target is currently up) ---------------------
if ($existing) {
$apiKey = $null
if ($resolvedConfig) {
# Pull the first api_keys entry — good enough for the local shutdown
# endpoint; production deploys don't use this script.
$inKeys = $false
foreach ($line in Get-Content $resolvedConfig) {
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 }
}
}
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..."
$shutdownRequested = $false
if ($apiKey) {
Write-Info 'Requesting graceful shutdown...'
try {
$headers = @{ Authorization = "Bearer $apiKey" }
Invoke-RestMethod -Uri "http://localhost:$Port/api/v1/system/shutdown" `
-Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null
$shutdownRequested = $true
} catch {
Write-Info " API shutdown failed ($($_.Exception.Message)); will force-kill"
}
}
if ($shutdownRequested) {
Write-Info 'Waiting for graceful shutdown...'
$waited = 0
while ($waited -lt 15) {
while ($waited -lt $ShutdownTimeoutSec) {
Start-Sleep -Seconds 1
$waited++
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if (-not $still) {
Write-Host " Server exited cleanly after ${waited}s"
if (-not (Get-ServerProcesses -ModuleName $Module -Root $ServerRoot)) {
Write-Info " Exited cleanly after ${waited}s"
break
}
}
# Step 3: Force-kill stragglers
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if ($still) {
Write-Host " Force-killing remaining processes..."
foreach ($p in $still) {
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))..."
}
$still = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot
if ($still) {
Write-Info ' Force-killing remaining processes...'
foreach ($p in $still) {
Write-Info " Stop PID $($p.ProcessId)"
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
}
# Wait for Windows to release the TCP socket before we rebind. A fixed
# 12 s sleep isn't enough on machines where the kernel lingers in
# CLOSE_WAIT; poll the port state instead.
$portDeadline = (Get-Date).AddSeconds(10)
while ((Get-Date) -lt $portDeadline -and (Test-PortOpen -Port $Port)) {
Start-Sleep -Milliseconds 250
}
}
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
# ---- Merge per-user PATH (captures tools installed after the shell started) ----
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
@@ -83,25 +212,132 @@ if ($regUser) {
}
}
# Start server detached (set WLED_RESTART=1 to skip browser open)
Write-Host "Starting server..."
$env:LEDGRAB_RESTART = "1"
$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source
if (-not $pythonExe) {
# Fallback to known install location
$pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe"
# ---- Locate a Python interpreter -------------------------------------------
# We need the Python that actually has the target module installed. Naively
# resolving `python` on PATH can pick up 3.11 or another version that doesn't
# have `ledgrab` in its site-packages, so prefer an explicit interpreter in
# this priority order:
# 1. -PythonExe (caller override)
# 2. `py -<Version>` via the Windows Python launcher
# 3. A Python<Version> install under %LOCALAPPDATA%\Programs\Python
# 4. `python` on PATH (last-resort fallback)
function Test-HasModule {
param([string]$Exe, [string]$ModuleName)
if (-not $Exe -or -not (Test-Path $Exe)) { return $false }
& $Exe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)" 2>$null
return ($LASTEXITCODE -eq 0)
}
Start-Process -FilePath $pythonExe -ArgumentList '-m', 'ledgrab' `
-WorkingDirectory $serverRoot `
-WindowStyle Hidden
Start-Sleep -Seconds 3
$resolvedPython = $null
$launchArgs = @()
# Verify it's running
$check = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
if ($check) {
Write-Host "Server started (PID $($check[0].ProcessId))"
if ($PythonExe) {
if (-not (Test-Path $PythonExe)) {
Write-Error "PythonExe '$PythonExe' does not exist"
exit 2
}
$resolvedPython = (Resolve-Path $PythonExe).Path
} else {
Write-Host "WARNING: Server does not appear to be running!"
# Try `py -<version>`
$pyLauncher = (Get-Command py -ErrorAction SilentlyContinue).Source
if ($pyLauncher) {
$probe = & $pyLauncher "-$PythonVersion" -c "import sys; print(sys.executable)" 2>$null
if ($LASTEXITCODE -eq 0 -and $probe) {
$resolvedPython = $pyLauncher
$launchArgs = @("-$PythonVersion")
}
}
# Fall back to a known install path for that version
if (-not $resolvedPython) {
$verTag = $PythonVersion -replace '\.', ''
$candidate = Join-Path $env:LOCALAPPDATA "Programs\Python\Python$verTag\python.exe"
if (Test-Path $candidate) { $resolvedPython = $candidate }
}
# Last resort: plain `python` on PATH
if (-not $resolvedPython) {
$onPath = (Get-Command python -ErrorAction SilentlyContinue).Source
if ($onPath) { $resolvedPython = $onPath }
}
}
if (-not $resolvedPython) {
Write-Error "No Python $PythonVersion interpreter found (tried: -PythonExe, py -$PythonVersion, %LOCALAPPDATA%\Programs\Python\Python*, PATH)"
exit 2
}
# Verify the module is actually importable with the chosen interpreter so we
# don't launch a process that would immediately die with "No module named X".
# When using the `py` launcher, delegate to the versioned interpreter.
$effectiveExe = if ($launchArgs.Count -gt 0) {
& $resolvedPython @launchArgs -c "import sys; print(sys.executable)" 2>$null
} else {
$resolvedPython
}
if (-not (Test-HasModule -Exe $effectiveExe -ModuleName $Module)) {
Write-Error "Module '$Module' is not importable with $effectiveExe. Install it (e.g. pip install -e .) or pass -PythonExe pointing to the right interpreter."
exit 2
}
$pythonExe = $resolvedPython
# ---- Launch detached replacement -------------------------------------------
Write-Info "Starting $Module on port $Port..."
if ($SkipBrowser) { $env:LEDGRAB_RESTART = '1' }
# Redirect the child's stdout/stderr to a log file. Without this, inheriting
# the parent shell's handles via Start-Process -WindowStyle Hidden can cause
# the child to exit immediately when those handles aren't real console fds
# (e.g. when restart.ps1 is driven from WSL/Git-Bash).
$logPath = Join-Path $env:TEMP ("ledgrab-{0}-{1}.log" -f $Module, $Port)
$errPath = "$logPath.err"
$argList = @()
$argList += $launchArgs
$argList += @('-m', $Module)
$startedProc = Start-Process -FilePath $pythonExe `
-ArgumentList $argList `
-WorkingDirectory $ServerRoot `
-WindowStyle Hidden `
-RedirectStandardOutput $logPath `
-RedirectStandardError $errPath `
-PassThru
$startedPid = $startedProc.Id
# ---- Poll readiness --------------------------------------------------------
# Port readiness is the authoritative signal — the process can be alive for
# many seconds before uvicorn finishes binding on cold starts (store init,
# etc.). Polling avoids spurious "not running" warnings that the old fixed
# 3-second sleep produced.
$deadline = (Get-Date).AddSeconds($StartupTimeoutSec)
$ready = $false
while ((Get-Date) -lt $deadline) {
# Bail early if the process has already exited — something went wrong.
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) { break }
if (Test-PortOpen -Port $Port) { $ready = $true; break }
Start-Sleep -Milliseconds 500
}
if ($ready) {
Write-Info "Server ready on port $Port (PID $startedPid)"
exit 0
}
$proc = Get-Process -Id $startedPid -ErrorAction SilentlyContinue
if (-not $proc) {
Write-Warning "Server process $startedPid exited before binding port $Port"
} else {
Write-Warning "Server PID $startedPid is running but did not bind port $Port within ${StartupTimeoutSec}s"
}
if (Test-Path $errPath) {
$tail = Get-Content $errPath -Tail 20 -ErrorAction SilentlyContinue
if ($tail) {
Write-Warning "Last stderr lines from $errPath :"
$tail | ForEach-Object { Write-Warning " $_" }
}
}
exit 1