feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
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:
+307
-71
@@ -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
|
||||
# 1–2 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
|
||||
|
||||
Reference in New Issue
Block a user