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
+13
View File
@@ -12,6 +12,8 @@ android {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
// Bump versionCode on every release (Play Store and sideload
// updates both require a strictly increasing value).
versionCode = 1
versionName = "0.3.0"
@@ -23,6 +25,17 @@ android {
}
}
// Per-ABI APK splits — reduces download size by ~60% vs universal APK.
// Each split contains only one native ABI's shared libraries + wheels.
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "x86_64", "x86")
isUniversalApk = true // also produce a fat APK for sideloading
}
}
// Signing config from env vars (CI) — only registered when all four are set.
// Local release builds fall back to the debug signing config.
val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
+23 -2
View File
@@ -1,5 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BLE scanning and connecting — API ≥31 uses granular permissions;
older releases need BLUETOOTH + ACCESS_FINE_LOCATION for scanning.
neverForLocation avoids the location permission dialog on API 31+. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- BLE hardware — required=false so non-BT boxes still install. -->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
<uses-permission android:name="android.permission.INTERNET" />
@@ -30,9 +50,10 @@
<application
android:name=".LedGrabApp"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.LedGrab">
<!-- TV launcher activity -->
@@ -0,0 +1,288 @@
package com.ledgrab.android
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
/**
* Android BLE bridge exposed to the Python server via Chaquopy.
*
* Wraps the Android BluetoothGatt / BluetoothLeScanner APIs into
* synchronous, blocking calls that can be safely invoked from
* a Python thread (Chaquopy proxy threads are real OS threads).
*
* All GATT callbacks run on a private [HandlerThread] so they don't
* block the main looper. [runBlocking] is used to bridge callback
* completions back to the calling Python thread.
*
* Python callers access the singleton via
* `BleBridge.INSTANCE.scan()` etc. — see
* `server/src/ledgrab/core/devices/android_ble_transport.py`.
*/
object BleBridge {
private const val TAG = "BleBridge"
private const val CONNECT_TIMEOUT_MS = 18_000L // connect + service discovery
private const val WRITE_TIMEOUT_MS = 5_000L
@Volatile private var appContext: Context? = null
private val handleSeq = AtomicInteger(1)
// Dedicated looper thread so BLE callbacks don't land on the main thread.
private val bleHandlerThread = HandlerThread("LedGrab-BLE").also { it.start() }
private val bleHandler = Handler(bleHandlerThread.looper)
private data class GattHandle(
val gatt: BluetoothGatt,
val writeChar: BluetoothGattCharacteristic,
)
private val handles = ConcurrentHashMap<Int, GattHandle>()
// Write completion futures, keyed by handle. Only populated for
// WRITE_TYPE_DEFAULT (with-response) writes.
private val pendingWrites = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
@JvmStatic
fun init(context: Context) {
appContext = context.applicationContext
}
private fun ctx(): Context =
appContext ?: error("BleBridge.init() not called — app context unavailable")
private fun adapter() =
ctx().getSystemService(BluetoothManager::class.java)?.adapter
// ─── Public API ──────────────────────────────────────────────────────────
/**
* Scan for BLE peripherals for [timeoutMs] milliseconds.
*
* Returns a list of `"address|name|rssi"` strings. Addresses are
* deduplicated — only the last-seen RSSI for each address is kept.
* Returns an empty list if Bluetooth is off or the permission is denied.
*/
@JvmStatic
@JvmOverloads
fun scan(timeoutMs: Long = 4_000L): List<String> {
val adapter = adapter() ?: return emptyList()
if (!adapter.isEnabled) return emptyList()
val scanner = try { adapter.bluetoothLeScanner } catch (_: SecurityException) { null }
?: return emptyList()
val seen = Collections.synchronizedMap(LinkedHashMap<String, String>())
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val address = result.device.address ?: return
val name = result.scanRecord?.deviceName ?: result.device.name ?: ""
seen[address] = "$address|$name|${result.rssi}"
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "BLE scan failed with error $errorCode")
}
}
try {
bleHandler.post { scanner.startScan(callback) }
Thread.sleep(timeoutMs)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
} finally {
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
}
return seen.values.toList()
}
/**
* Connect to the BLE peripheral at [address] and locate the GATT
* characteristic identified by [writeCharUuid] across all services.
*
* Blocks until connected + services discovered, or returns -1 on failure.
* The returned integer is an opaque handle passed to [write]/[disconnect].
*/
@JvmStatic
fun connect(address: String, writeCharUuid: String): Int {
val adapter = adapter() ?: return -1
val device = try { adapter.getRemoteDevice(address) } catch (e: Exception) {
Log.e(TAG, "Invalid BLE address '$address': ${e.message}")
return -1
}
val readyDeferred = CompletableDeferred<Boolean>()
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when {
newState == BluetoothProfile.STATE_CONNECTED
&& status == BluetoothGatt.GATT_SUCCESS -> {
Log.d(TAG, "GATT connected to $address, discovering services")
gatt.discoverServices()
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
Log.w(TAG, "GATT disconnected from $address (status=$status)")
readyDeferred.complete(false)
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
readyDeferred.complete(status == BluetoothGatt.GATT_SUCCESS)
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
val h = handles.entries.firstOrNull { it.value.gatt === gatt }?.key ?: return
pendingWrites.remove(h)?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
}
val gatt: BluetoothGatt = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
device.connectGatt(
ctx(), false, callback,
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK,
bleHandler,
)
} else {
@Suppress("DEPRECATION")
device.connectGatt(
ctx(), false, callback,
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
)
}
} catch (e: SecurityException) {
Log.e(TAG, "BLUETOOTH_CONNECT permission denied for $address", e)
return -1
} catch (e: Exception) {
Log.e(TAG, "connectGatt failed for $address", e)
return -1
}
val ready = try {
runBlocking { withTimeout(CONNECT_TIMEOUT_MS) { readyDeferred.await() } }
} catch (_: TimeoutCancellationException) {
Log.e(TAG, "BLE connect+discovery timed out for $address")
runCatching { gatt.close() }
return -1
}
if (!ready) {
runCatching { gatt.close() }
return -1
}
val charUuid = try { UUID.fromString(writeCharUuid) } catch (e: Exception) {
Log.e(TAG, "Invalid characteristic UUID '$writeCharUuid'")
gatt.disconnect(); gatt.close()
return -1
}
val writeChar = gatt.services.flatMap { it.characteristics }
.firstOrNull { it.uuid == charUuid }
if (writeChar == null) {
Log.e(TAG, "Characteristic $writeCharUuid not found on $address")
gatt.disconnect(); gatt.close()
return -1
}
val handle = handleSeq.getAndIncrement()
handles[handle] = GattHandle(gatt, writeChar)
Log.i(TAG, "BLE connected: address=$address char=$writeCharUuid handle=$handle")
return handle
}
/**
* Write [data] to the characteristic associated with [handle].
*
* [withResponse] controls the GATT write type:
* - `true` → Write Request (waits for device ACK, slower but reliable)
* - `false` → Write Command (fire-and-forget, faster, used by SP110E/Triones/Zengge)
*
* Returns `true` on success, `false` on any error.
*/
@JvmStatic
fun write(handle: Int, data: ByteArray, withResponse: Boolean): Boolean {
val entry = handles[handle] ?: return false
val gatt = entry.gatt
val char = entry.writeChar
val writeType = if (withResponse)
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
else
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
return if (withResponse) {
val deferred = CompletableDeferred<Boolean>()
pendingWrites[handle] = deferred
val initiated = gattWrite(gatt, char, data, writeType)
if (!initiated) {
pendingWrites.remove(handle)
return false
}
try {
runBlocking { withTimeout(WRITE_TIMEOUT_MS) { deferred.await() } }
} catch (_: TimeoutCancellationException) {
pendingWrites.remove(handle)
Log.w(TAG, "BLE write-with-response timed out on handle $handle")
false
}
} else {
gattWrite(gatt, char, data, writeType)
}
}
/** Disconnect and close the GATT connection for [handle]. */
@JvmStatic
fun disconnect(handle: Int) {
val entry = handles.remove(handle) ?: return
pendingWrites.remove(handle)?.complete(false)
runCatching {
entry.gatt.disconnect()
entry.gatt.close()
}.onFailure { Log.w(TAG, "BLE disconnect error for handle $handle: ${it.message}") }
Log.i(TAG, "BLE disconnected handle=$handle")
}
// ─── Internal helpers ─────────────────────────────────────────────────────
private fun gattWrite(
gatt: BluetoothGatt,
char: BluetoothGattCharacteristic,
data: ByteArray,
writeType: Int,
): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(char, data, writeType) ==
android.bluetooth.BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
char.writeType = writeType
@Suppress("DEPRECATION")
char.value = data
@Suppress("DEPRECATION")
gatt.writeCharacteristic(char)
}
}
@@ -3,6 +3,7 @@ package com.ledgrab.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
@@ -33,6 +34,12 @@ class CaptureService : Service() {
private const val CAPTURE_HEIGHT = 270
private const val CAPTURE_FPS = 30
/** True while the service is alive. Survives activity recreation. */
@Volatile
@JvmStatic
var isRunning: Boolean = false
private set
fun createIntent(
context: Context,
resultCode: Int,
@@ -65,6 +72,7 @@ class CaptureService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isRunning = true
// CRITICAL: startForeground must be called IMMEDIATELY —
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
@@ -139,10 +147,23 @@ class CaptureService : Service() {
}
mediaProjection = projection
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
val metrics = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val windowMetrics = (getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.currentWindowMetrics
DisplayMetrics().apply {
val bounds = windowMetrics.bounds
widthPixels = bounds.width()
heightPixels = bounds.height()
// densityDpi is still needed for VirtualDisplay; read from resources.
densityDpi = resources.displayMetrics.densityDpi
}
} else {
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
DisplayMetrics().also { m ->
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(m)
}
}
val newBridge = PythonBridge(this).also { b ->
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
@@ -163,6 +184,8 @@ class CaptureService : Service() {
}
override fun onDestroy() {
isRunning = false
screenCapture?.stop()
screenCapture = null
@@ -194,10 +217,19 @@ class CaptureService : Service() {
}
private fun buildNotification(url: String): Notification {
val tapIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LedGrab Running")
.setContentText("Web UI: $url")
.setSmallIcon(R.drawable.ic_launcher)
.setContentIntent(tapIntent)
.setOngoing(true)
.build()
}
@@ -4,9 +4,16 @@ import android.app.Application
import android.util.Log
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import java.io.File
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Application class — initializes the Chaquopy Python runtime.
* Application class — initializes the Chaquopy Python runtime and
* installs a global uncaught exception handler that persists crash
* logs to app-private storage.
*
* `Python.start()` must be called once before any Python code runs.
* It loads libpython, extracts stdlib + pip packages from APK assets
@@ -21,6 +28,7 @@ class LedGrabApp : Application() {
override fun onCreate() {
super.onCreate()
installCrashLogger()
try {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
@@ -28,7 +36,7 @@ class LedGrabApp : Application() {
} catch (t: Throwable) {
// Don't crash here — MainActivity will render a failure
// screen with a Copy log button so the user can report it.
Log.e("LedGrabApp", "Python.start() failed", t)
Log.e(TAG, "Python.start() failed", t)
initError = t
return
}
@@ -36,5 +44,40 @@ class LedGrabApp : Application() {
// can enumerate and open USB-to-TTL adapters without needing
// an Activity reference.
UsbSerialBridge.init(this)
// Bind application context for the BLE bridge so Python can
// scan and connect to BLE LED controllers.
BleBridge.init(this)
}
/**
* Install a global uncaught exception handler that writes the
* stack trace to `files/crash-<timestamp>.log` before letting
* the default handler terminate the process. Logs survive app
* restarts and can be pulled via `adb pull` for diagnostics.
*/
private fun installCrashLogger() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val logFile = File(filesDir, "crash-$ts.log")
PrintWriter(logFile).use { pw ->
pw.println("LedGrab crash at $ts")
pw.println("Thread: ${thread.name}")
pw.println()
throwable.printStackTrace(pw)
}
Log.e(TAG, "Crash log written to ${logFile.absolutePath}")
} catch (_: Exception) {
// Best effort — don't crash inside the crash handler.
}
// Chain to the default handler so Android shows the crash dialog
// and terminates the process.
defaultHandler?.uncaughtException(thread, throwable)
}
}
companion object {
private const val TAG = "LedGrabApp"
}
}
@@ -1,8 +1,11 @@
package com.ledgrab.android
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
@@ -36,6 +39,7 @@ class MainActivity : Activity() {
private const val TAG = "MainActivity"
private const val SERVER_PORT = 8080
private const val REQUEST_MEDIA_PROJECTION = 1001
private const val REQUEST_POST_NOTIFICATIONS = 1002
}
private lateinit var stoppedPanel: View
@@ -46,7 +50,6 @@ class MainActivity : Activity() {
private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView
private var serviceRunning = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -118,8 +121,8 @@ class MainActivity : Activity() {
}
private fun startRootCaptureService() {
ensureNotificationPermission()
startForegroundService(CaptureService.createRootIntent(this))
serviceRunning = true
updateUI()
}
@@ -137,20 +140,19 @@ class MainActivity : Activity() {
}
private fun startCaptureService(resultCode: Int, resultData: Intent) {
ensureNotificationPermission()
val intent = CaptureService.createIntent(this, resultCode, resultData)
startForegroundService(intent)
serviceRunning = true
updateUI()
}
private fun stopCaptureService() {
stopService(Intent(this, CaptureService::class.java))
serviceRunning = false
updateUI()
}
private fun updateUI() {
if (serviceRunning) {
if (CaptureService.isRunning) {
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
@@ -161,7 +163,7 @@ class MainActivity : Activity() {
uiScope.launch(Dispatchers.Default) {
val bitmap = generateQrCode(url)
withContext(Dispatchers.Main) {
if (serviceRunning && urlText.text == url) {
if (CaptureService.isRunning && urlText.text == url) {
qrImage.setImageBitmap(bitmap)
}
}
@@ -235,4 +237,23 @@ class MainActivity : Activity() {
container.addView(scroll)
setContentView(container)
}
/**
* Request POST_NOTIFICATIONS permission on Android 13+ so the
* foreground service notification is visible. On older API levels
* this is a no-op.
*/
private fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
@Suppress("DEPRECATION")
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_POST_NOTIFICATIONS,
)
}
}
}
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Adaptive icon foreground: TV with LED glow strips.
Centered in the 108dp safe zone (inner 72dp is guaranteed visible). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow - top (teal) -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.7"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<!-- LED glow - left (purple) -->
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.6"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<!-- LED glow - right (red) -->
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.6"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<!-- LED glow - bottom (yellow) -->
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.6"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_navy" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">Фоновая подсветка для телевизора</string>
<string name="btn_start">Начать захват</string>
<string name="btn_stop">Стоп</string>
<string name="status_running">Работает</string>
<string name="label_web_ui">Адрес веб-интерфейса</string>
<string name="scan_to_configure">Сканируйте для настройки</string>
<string name="qr_description">QR-код для веб-интерфейса</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">电视氛围灯光</string>
<string name="btn_start">开始捕获</string>
<string name="btn_stop">停止</string>
<string name="status_running">运行中</string>
<string name="label_web_ui">Web界面地址</string>
<string name="scan_to_configure">扫码配置</string>
<string name="qr_description">Web界面二维码</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
brokers on the local network via plain HTTP/UDP. Cleartext traffic
must be allowed for these connections to work on Android 9+.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
+15 -11
View File
@@ -17,27 +17,29 @@ Both modes can run simultaneously on different ports.
## Restart Procedure
Use the PowerShell restart script — it gracefully shuts the running server down (so stores persist to disk), kills stragglers, launches a detached replacement, and polls the port until it's actually accepting connections. Exit code is 0 on success, 1 if the new server failed to bind the port, 2 on environment errors.
### Real server
Use the PowerShell restart script — it reliably stops only the server process and starts a new detached instance:
```bash
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\ledgrab\server\restart.ps1"
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1"
```
### Demo server
Find and kill the process on port 8081, then restart:
```bash
# Find PID
powershell -Command "netstat -ano | Select-String ':8081.*LISTEN'"
# Kill it
powershell -Command "Stop-Process -Id <PID> -Force"
# Restart
cd server && python -m ledgrab.demo
powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\led-grab-mixed\led-grab\server\restart.ps1" `
-Port 8081 -Module ledgrab.demo -ConfigPath "config\demo_config.yaml"
```
### Useful parameters
- `-Port <int>` / `-Module <name>` — override the target (default: 8080 / `ledgrab`).
- `-StartupTimeoutSec <int>` — how long to wait for the new server to bind the port (default: 30).
- `-ShutdownTimeoutSec <int>` — how long to wait for graceful shutdown before force-killing (default: 15).
- `-Quiet` — suppress progress output.
- `-SkipBrowser:$false` — allow the app to open a browser tab on startup (default: skipped).
**Do NOT use** `Stop-Process -Name python` — it kills unrelated Python processes (VS Code extensions, etc.).
**Do NOT use** bash background `&` jobs — they get killed when the shell session ends.
@@ -45,6 +47,7 @@ cd server && python -m ledgrab.demo
## When to Restart
**Restart required** for changes to:
- API routes (`api/routes/`, `api/schemas/`)
- Core logic (`core/*.py`)
- Configuration (`config.py`)
@@ -52,6 +55,7 @@ cd server && python -m ledgrab.demo
- Data models (`storage/`)
**No restart needed** for:
- Static files (`static/js/`, `static/css/`) — but **must rebuild bundle**: `cd server && npm run build`
- Locale files (`static/locales/*.json`) — loaded by frontend
- Documentation files (`*.md`)
+6 -2
View File
@@ -18,8 +18,12 @@ auth:
api_keys: {}
# dev: "replace-with-openssl-rand-hex-32"
storage:
database_file: "data/ledgrab.db"
# Storage paths default to ./data relative to the server's working directory.
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root
# (the whole dir — both the database and assets), or uncomment the block
# below to pin an absolute database file.
# storage:
# database_file: "/absolute/path/to/ledgrab.db"
mqtt:
enabled: false
+6
View File
@@ -86,6 +86,12 @@ perf = [
"bettercam>=1.0.0; sys_platform == 'win32'",
"windows-capture>=1.5.0; sys_platform == 'win32'",
]
# BLE LED controllers (SP110E, Triones/HappyLighting, Zengge/iLightsIn, Govee).
# Desktop-only — bleak does not support Android; Chaquopy build must NOT list
# bleak. Imports are guarded with try/except ImportError on all BLE modules.
ble = [
"bleak>=0.22",
]
[project.urls]
Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
+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
+14 -9
View File
@@ -8,10 +8,10 @@ inside an Android application. Sets up Android-specific paths
import asyncio
import os
import threading
from typing import Optional
from typing import Any, Optional
_server_thread: Optional[threading.Thread] = None
_shutdown_event: Optional[asyncio.Event] = None
_server: Optional[Any] = None # uvicorn.Server
_loop: Optional[asyncio.AbstractEventLoop] = None
@@ -63,22 +63,27 @@ def start_server(data_dir: str, port: int = 8080) -> None:
# No uvloop/httptools on Android — use pure-Python asyncio
loop="asyncio",
)
server = uvicorn.Server(uv_config)
global _shutdown_event, _loop
global _server, _loop
_server = uvicorn.Server(uv_config)
_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_loop)
_shutdown_event = asyncio.Event()
logger.info("LedGrab Android: server starting")
_loop.run_until_complete(server.serve())
_loop.run_until_complete(_server.serve())
logger.info("LedGrab Android: server stopped")
# Clean up so the next start_server() call begins fresh.
_server = None
_loop = None
def stop_server() -> None:
"""Signal the uvicorn server to shut down gracefully.
Called from Kotlin's ``PythonBridge.stopServer()``.
Called from Kotlin's ``PythonBridge.stopServer()``. Sets
``should_exit`` on the uvicorn Server which causes ``server.serve()``
to return, unblocking the Python thread.
"""
if _shutdown_event is not None and _loop is not None:
_loop.call_soon_threadsafe(_shutdown_event.set)
if _server is not None:
_server.should_exit = True
+7
View File
@@ -66,6 +66,8 @@ def _device_to_response(device) -> DeviceResponse:
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
gamesense_device_type=device.gamesense_device_type,
ble_family=device.ble_family,
ble_govee_key=device.ble_govee_key,
default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids,
group_mode=device.group_mode,
@@ -198,6 +200,8 @@ async def create_device(
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
ble_family=device_data.ble_family or "",
ble_govee_key=device_data.ble_govee_key or "",
group_device_ids=group_device_ids,
group_mode=group_mode,
)
@@ -281,6 +285,7 @@ async def discover_devices(
mac=d.mac,
led_count=d.led_count,
version=d.version,
ble_family=getattr(d, "ble_family", None),
already_added=already_added,
)
)
@@ -430,6 +435,8 @@ async def update_device(
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
gamesense_device_type=update_data.gamesense_device_type,
ble_family=update_data.ble_family,
ble_govee_key=update_data.ble_govee_key,
group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode,
)
+15 -4
View File
@@ -12,10 +12,10 @@ from typing import Optional
import os
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ledgrab import __version__, REPO_URL, DONATE_URL
from ledgrab.api.auth import AuthRequired, is_auth_enabled
from ledgrab.api.auth import AuthRequired, _is_loopback, is_auth_enabled
from ledgrab.api.dependencies import (
get_audio_source_store,
get_audio_template_store,
@@ -96,19 +96,30 @@ router = APIRouter()
@router.get("/health", response_model=HealthResponse, tags=["Health"])
async def health_check():
async def health_check(request: Request):
"""Check service health status.
Returns basic health information including status, version, and timestamp.
"""
logger.debug("Health check requested")
client_host = request.client.host if request.client else None
loopback = _is_loopback(client_host)
keys_configured = is_auth_enabled()
# Report auth_required=True for LAN clients even when no keys are configured,
# because the server rejects non-loopback requests without keys.
auth_required = keys_configured or not loopback
# LAN client with no keys configured → no key will ever work; signal to
# the UI so it can show a setup-required screen instead of a login form.
setup_required = not keys_configured and not loopback
return HealthResponse(
status="healthy",
timestamp=datetime.now(timezone.utc),
version=__version__,
demo_mode=get_config().demo,
auth_required=is_auth_enabled(),
auth_required=auth_required,
setup_required=setup_required,
repo_url=REPO_URL,
donate_url=DONATE_URL,
)
+24
View File
@@ -66,6 +66,15 @@ class DeviceCreate(BaseModel):
gamesense_device_type: Optional[str] = Field(
None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator"
)
# BLE controller fields
ble_family: Optional[str] = Field(
None,
description="BLE protocol family: sp110e, triones, zengge, govee",
)
ble_govee_key: Optional[str] = Field(
None,
description="Govee AES key (hex) — required for encrypted Govee firmware",
)
default_css_processing_template_id: Optional[str] = Field(
None, description="Default color strip processing template ID"
)
@@ -117,6 +126,12 @@ class DeviceUpdate(BaseModel):
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
ble_family: Optional[str] = Field(
None, description="BLE protocol family: sp110e, triones, zengge, govee"
)
ble_govee_key: Optional[str] = Field(
None, description="Govee AES key (hex) — required for encrypted Govee firmware"
)
default_css_processing_template_id: Optional[str] = Field(
None, description="Default color strip processing template ID"
)
@@ -266,6 +281,12 @@ class DeviceResponse(BaseModel):
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
ble_family: str = Field(
default="", description="BLE protocol family: sp110e, triones, zengge, govee"
)
ble_govee_key: str = Field(
default="", description="Govee AES key (hex) — required for encrypted Govee firmware"
)
default_css_processing_template_id: str = Field(
default="", description="Default color strip processing template ID"
)
@@ -320,6 +341,9 @@ class DiscoveredDeviceResponse(BaseModel):
mac: str = Field(default="", description="MAC address")
led_count: Optional[int] = Field(None, description="LED count (if reachable)")
version: Optional[str] = Field(None, description="Firmware version")
ble_family: Optional[str] = Field(
None, description="Detected BLE protocol family (sp110e/triones/zengge/govee)"
)
already_added: bool = Field(
default=False, description="Whether this device is already in the system"
)
+8
View File
@@ -16,6 +16,14 @@ class HealthResponse(BaseModel):
auth_required: bool = Field(
default=True, description="Whether API key authentication is required"
)
setup_required: bool = Field(
default=False,
description=(
"True when the server has no API keys configured AND the request "
"comes from a non-loopback client. The client is effectively locked "
"out until someone configures auth.api_keys server-side."
),
)
repo_url: str = Field(default="", description="Source code repository URL")
donate_url: str = Field(default="", description="Donation page URL")
+33 -12
View File
@@ -9,6 +9,14 @@ import yaml
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from ledgrab import paths as _paths
# Evaluate once at import time so every StorageConfig/AssetsConfig instance
# sees the same default across the process. Use POSIX separators so the
# default value is stable across platforms (SQLite and Python both accept
# forward slashes on Windows).
_DEFAULT_DATA_DIR_STR = _paths.default_data_dir().as_posix()
# ── Legacy env var migration ─────────────────────────────────
# Warn users who still have WLED_ env vars from pre-rename installs.
_OLD_PREFIX = "WLED_"
@@ -66,13 +74,13 @@ class AssetsConfig(BaseSettings):
"""Assets configuration."""
max_file_size_mb: int = 50 # Max upload size in MB
assets_dir: str = "data/assets" # Directory for uploaded asset files
assets_dir: str = f"{_DEFAULT_DATA_DIR_STR}/assets"
class StorageConfig(BaseSettings):
"""Storage configuration."""
database_file: str = "data/ledgrab.db"
database_file: str = f"{_DEFAULT_DATA_DIR_STR}/ledgrab.db"
class MQTTConfig(BaseSettings):
@@ -163,16 +171,29 @@ class Config(BaseSettings):
updates: UpdatesConfig = Field(default_factory=UpdatesConfig)
def model_post_init(self, __context: object) -> None:
"""Override storage and assets paths when demo mode is active."""
if self.demo:
for field_name in StorageConfig.model_fields:
value = getattr(self.storage, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
for field_name in AssetsConfig.model_fields:
value = getattr(self.assets, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.assets, field_name, value.replace("data/", "data/demo/", 1))
"""Override storage and assets paths when demo mode is active.
Inserts a ``demo`` segment before the final path component so that
``<data_dir>/ledgrab.db`` becomes ``<data_dir>/demo/ledgrab.db``.
Works for both absolute platform paths and legacy relative ones.
"""
if not self.demo:
return
def _demo_path(value: str) -> str:
p = Path(value)
if "demo" in p.parts:
return value
return str(p.parent / "demo" / p.name)
for field_name in StorageConfig.model_fields:
value = getattr(self.storage, field_name)
if isinstance(value, str) and value:
setattr(self.storage, field_name, _demo_path(value))
for field_name in AssetsConfig.model_fields:
value = getattr(self.assets, field_name)
if isinstance(value, str) and value:
setattr(self.assets, field_name, _demo_path(value))
@classmethod
def from_yaml(cls, config_path: str | Path) -> "Config":
@@ -0,0 +1,141 @@
"""Android BLE transport backed by the Kotlin ``BleBridge`` singleton.
Calls into Java land through Chaquopy. This module only loads on Android;
importing it on desktop raises ``RuntimeError`` from ``_bridge()``.
The public surface mirrors :class:`~ledgrab.core.devices.ble_transport.BLETransport`
so ``BLEClient`` can treat both backends identically.
"""
from __future__ import annotations
import asyncio
from typing import List, Optional
from ledgrab.core.devices.ble_transport import DiscoveredBLEDevice
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
logger = get_logger(__name__)
def _bridge():
"""Return the Kotlin ``BleBridge`` singleton, or raise on non-Android."""
if not is_android():
raise RuntimeError("AndroidBLETransport is only usable on Android")
try:
from java import jclass # type: ignore[import-not-found]
except ImportError as exc:
raise RuntimeError("Chaquopy java interop not available") from exc
return jclass("com.ledgrab.android.BleBridge").INSTANCE
async def android_ble_scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
"""Scan for BLE peripherals using the Android ``BleBridge``.
Runs the blocking scan on a thread-pool thread so the asyncio event
loop is not blocked during the scan window.
"""
bridge = _bridge()
timeout_ms = int(timeout * 1000)
loop = asyncio.get_running_loop()
def _scan() -> List[DiscoveredBLEDevice]:
results = bridge.scan(timeout_ms)
devices: List[DiscoveredBLEDevice] = []
for entry in results:
parts = str(entry).split("|", 2)
if len(parts) < 3:
continue
address, name, rssi_str = parts
try:
rssi: Optional[int] = int(rssi_str)
except ValueError:
rssi = None
devices.append(DiscoveredBLEDevice(address=address, name=name or address, rssi=rssi))
return devices
devices = await loop.run_in_executor(None, _scan)
devices.sort(key=lambda d: (d.rssi is None, -(d.rssi or 0)))
return devices
class AndroidBLETransport:
"""BLE transport for Android — delegates to the Kotlin ``BleBridge`` singleton.
Lifecycle is identical to :class:`~ledgrab.core.devices.ble_transport.BLETransport`:
transport = AndroidBLETransport(address, write_char_uuid, ...)
await transport.connect()
await transport.write(b"...")
await transport.close()
"""
def __init__(
self,
address: str,
write_char_uuid: str,
write_with_response: bool = False,
connect_timeout: float = 10.0,
) -> None:
self._address = address
self._write_char_uuid = write_char_uuid
self._write_with_response = write_with_response
self._handle: Optional[int] = None
self._lock = asyncio.Lock()
@property
def address(self) -> str:
return self._address
@property
def is_connected(self) -> bool:
return self._handle is not None and self._handle >= 0
async def connect(self) -> None:
if self.is_connected:
return
bridge = _bridge()
loop = asyncio.get_running_loop()
handle = await loop.run_in_executor(
None, lambda: int(bridge.connect(self._address, self._write_char_uuid))
)
if handle < 0:
raise RuntimeError(
f"Failed to connect to BLE device {self._address} via Android bridge "
f"(device not found, permission denied, or characteristic missing)"
)
self._handle = handle
logger.info("Android BLE connected: address=%s handle=%d", self._address, handle)
async def close(self) -> None:
if self._handle is None:
return
bridge = _bridge()
handle = self._handle
self._handle = None
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(None, lambda: bridge.disconnect(handle))
except Exception as exc:
logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes) -> None:
"""Write bytes to the configured characteristic.
Serialised through an internal lock — BLE stacks do not tolerate
overlapping writes on the same characteristic.
"""
if not self.is_connected or self._handle is None:
raise RuntimeError(f"Android BLE transport {self._address} not connected")
bridge = _bridge()
handle = self._handle
with_response = self._write_with_response
loop = asyncio.get_running_loop()
async with self._lock:
success = await loop.run_in_executor(
None, lambda: bool(bridge.write(handle, data, with_response))
)
if not success:
raise RuntimeError(f"Android BLE write to {self._address} failed")
@@ -0,0 +1,264 @@
"""Unified BLE LED client — whole-strip ambient color for BLE controllers.
Supports four families via the :mod:`ble_protocols` registry: SP110E,
Triones/HappyLighting, Zengge/iLightsIn, Govee. None of these protocols
stream per-pixel frames — so ``send_pixels`` averages the incoming strip
and writes one solid color per frame.
URL format: ``ble://<address>`` where ``<address>`` is a MAC on
Windows/Linux and a CoreBluetooth UUID on macOS. The protocol family is
a separate ``ble_family`` field on the device record (not in the URL)
because the same address advertises different services depending on
firmware variant.
"""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
from ledgrab.core.devices.ble_protocols import BLEProtocol, get_protocol
from ledgrab.core.devices.ble_transport import make_transport
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
from ledgrab.utils import get_logger
logger = get_logger(__name__)
# Minimum interval between BLE writes. BLE connection intervals start at
# ~7.5 ms on most controllers; 30 ms leaves headroom for GATT ACKs on the
# with-response families without saturating the air time.
_MIN_WRITE_INTERVAL_SEC = 0.03
def _encrypt_govee_frame(frame: bytes, key: bytes) -> bytes:
"""AES-128-ECB encrypt a 20-byte Govee frame using a 16-byte device key.
Newer Govee firmware (2022+) drops unencrypted frames silently.
Pads the 20-byte frame to 32 bytes (two AES blocks) before encrypting.
Falls back to the plaintext frame if the ``cryptography`` package is
unavailable (logs a warning so the user knows why the controller ignores it).
"""
try:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
except ImportError:
logger.warning(
"cryptography package not available — sending unencrypted Govee frame; "
"install it with: pip install cryptography"
)
return frame
padded = frame + b"\x00" * (32 - len(frame))
cipher = Cipher(algorithms.AES(key), modes.ECB())
enc = cipher.encryptor()
return enc.update(padded) + enc.finalize()
def _strip_ble_scheme(url: str) -> str:
"""Normalise a ``ble://<address>`` URL to just the address."""
if url.startswith("ble://"):
return url[len("ble://") :].strip("/")
return url.strip("/")
def _average_color(pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> Tuple[int, int, int]:
"""Reduce an N-pixel strip to one average RGB."""
if isinstance(pixels, np.ndarray):
if pixels.size == 0:
return (0, 0, 0)
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
mean = arr.mean(axis=0)
return int(mean[0]), int(mean[1]), int(mean[2])
if not pixels:
return (0, 0, 0)
total_r = total_g = total_b = 0
for r, g, b in pixels:
total_r += r
total_g += g
total_b += b
n = len(pixels)
return total_r // n, total_g // n, total_b // n
class BLEClient(LEDClient):
"""LED client for BLE controllers speaking one of the registered protocols.
Args:
url: ``ble://<address>`` URL.
ble_family: Family identifier (``sp110e``, ``triones``, ``zengge``, ``govee``).
led_count: Logical LED count — recorded for UI/reporting; on the wire
every BLE protocol here is whole-strip.
"""
def __init__(
self,
url: str,
ble_family: str,
led_count: int = 0,
ble_govee_key: str = "",
**_kwargs,
):
self._url = url
self._address = _strip_ble_scheme(url)
self._led_count = led_count
self._protocol: BLEProtocol = get_protocol(ble_family)
self._transport = make_transport(
address=self._address,
write_char_uuid=self._protocol.write_char_uuid,
write_with_response=self._protocol.write_with_response,
)
# AES key for Govee encrypted firmware — 16 raw bytes or None.
self._aes_key: Optional[bytes] = None
if ble_govee_key and ble_family == "govee":
try:
import binascii
key_bytes = binascii.unhexlify(ble_govee_key.strip())
if len(key_bytes) != 16:
raise ValueError(f"Govee AES key must be 16 bytes, got {len(key_bytes)}")
self._aes_key = key_bytes
except Exception as exc:
logger.warning("Invalid Govee AES key — ignoring: %s", exc)
self._last_write_at: float = 0.0
self._last_color: Optional[Tuple[int, int, int, int]] = None
self._connected = False
# Throttle "not connected" warnings so the send loop doesn't spam logs
# at frame rate when a BLE connection drops silently.
self._last_not_connected_warn_at: float = 0.0
# When a reconnect attempt fails, skip further write attempts for a
# cooldown window. Each failed write on Windows can hang up to the
# transport's write timeout + a full connect timeout, so letting
# every frame retry turns a 60 FPS loop into a 0.03 FPS slideshow.
self._reconnect_cooldown_until: float = 0.0
async def connect(self) -> bool:
await self._transport.connect()
self._connected = True
logger.info(
"BLE client connected: address=%s family=%s", self._address, self._protocol.family
)
return True
async def close(self) -> None:
# Leave the strip in whatever state it's in — streaming power commands
# on every connect/close cycle causes Windows BLE stack quirks (back-to-back
# writes after connect can hang for 30s on some firmwares). The user can
# explicitly toggle power via the UI.
await self._transport.close()
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected and self._transport.is_connected
@property
def device_led_count(self) -> Optional[int]:
return self._led_count or None
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
"""Average the strip to one color and write it — BLE protocols are whole-strip only."""
now = time.monotonic()
if now < self._reconnect_cooldown_until:
return False
if not self.is_connected:
if (now - self._last_not_connected_warn_at) >= 5.0:
logger.warning(
"BLE send_pixels skipped — not connected (address=%s family=%s)",
self._address,
self._protocol.family,
)
self._last_not_connected_warn_at = now
return False
r, g, b = _average_color(pixels)
color = (r, g, b, brightness)
# Skip exact duplicates within a short window — long idle periods on a
# BLE connection can cause the peripheral to drop it, after which the
# next write hangs for 30s on Windows. A 250ms window forces regular
# traffic which keeps cheap BLE LED chips alive without flooding them.
now = time.monotonic()
if color == self._last_color and (now - self._last_write_at) < 0.25:
return True
delay = _MIN_WRITE_INTERVAL_SEC - (now - self._last_write_at)
if delay > 0:
await asyncio.sleep(delay)
frame = self._protocol.encode_color(r, g, b, brightness)
if self._aes_key is not None:
frame = _encrypt_govee_frame(frame, self._aes_key)
try:
await self._transport.write(frame)
except asyncio.TimeoutError:
# BLE connection likely dropped silently — reconnect and retry once.
logger.warning(
"BLE write to %s (%s) timed out — reconnecting",
self._address,
self._protocol.family,
)
try:
await self._transport.close()
await self._transport.connect()
await self._transport.write(frame)
except Exception as exc:
logger.warning(
"BLE reconnect+retry to %s failed — backing off 10s: %s",
self._address,
exc,
)
self._reconnect_cooldown_until = time.monotonic() + 10.0
return False
except Exception as exc:
logger.warning(
"BLE write to %s (%s) failed: %s", self._address, self._protocol.family, exc
)
return False
self._last_color = color
self._last_write_at = time.monotonic()
return True
async def set_power(self, on: bool) -> bool:
if not self.is_connected:
return False
try:
frame = self._protocol.encode_power(on)
if self._aes_key is not None:
frame = _encrypt_govee_frame(frame, self._aes_key)
await self._transport.write(frame)
return True
except Exception as exc:
logger.warning("BLE power command to %s failed: %s", self._address, exc)
return False
@classmethod
async def check_health(
cls,
url: str,
http_client, # noqa: ARG003 — unused; kept for the LEDClient contract
prev_health: Optional[DeviceHealth] = None,
) -> DeviceHealth:
"""BLE health isn't a passive check — a full GATT connect is the only signal.
Doing that on every poll would exhaust the controller's connection
slots, so we report the previously observed state and refresh only
the timestamp. Live errors surface via ``send_pixels`` and are
persisted by the device health tracker.
"""
address = _strip_ble_scheme(url)
return DeviceHealth(
online=prev_health.online if prev_health else False,
latency_ms=prev_health.latency_ms if prev_health else None,
last_checked=datetime.now(timezone.utc),
device_name=prev_health.device_name if prev_health else address,
device_version=prev_health.device_version if prev_health else None,
device_led_count=prev_health.device_led_count if prev_health else None,
device_led_type=prev_health.device_led_type if prev_health else None,
error=prev_health.error if prev_health else None,
)
@@ -0,0 +1,105 @@
"""BLE LED controller protocols.
Each submodule implements one controller family's wire protocol as a set
of pure byte-encoding functions. The :class:`BLEProtocol` contract defines
the minimal surface every family must expose; :func:`get_protocol` looks
one up by family identifier.
Protocols live here as pure functions (no BLE dependency) so they can be
unit-tested without hardware and without the ``bleak`` package installed.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Dict, Tuple
@dataclass(frozen=True)
class BLEProtocol:
"""Wire protocol for one BLE LED controller family.
Attributes:
family: Short identifier (``sp110e``, ``triones``, ``zengge``, ``govee``).
display_name: Human-readable name for UIs.
service_uuid: GATT service UUID containing the write characteristic.
write_char_uuid: Write characteristic UUID.
write_with_response: If True, use Write Request; else Write Without Response.
encode_color: ``(r, g, b, brightness) -> bytes`` — frame setting a solid color.
encode_power: ``on -> bytes`` — frame toggling power.
name_prefixes: Advertisement-name prefixes that identify this family.
"""
family: str
display_name: str
service_uuid: str
write_char_uuid: str
write_with_response: bool
encode_color: Callable[[int, int, int, int], bytes]
encode_power: Callable[[bool], bytes]
name_prefixes: Tuple[str, ...]
_registry: Dict[str, BLEProtocol] = {}
def register_protocol(protocol: BLEProtocol) -> None:
"""Register a protocol so :func:`get_protocol` can find it."""
_registry[protocol.family] = protocol
def get_protocol(family: str) -> BLEProtocol:
"""Look up a registered protocol by family identifier."""
try:
return _registry[family]
except KeyError as exc:
raise ValueError(f"Unknown BLE family: {family!r}") from exc
def all_protocols() -> Dict[str, BLEProtocol]:
"""Return a copy of the registry (family → protocol)."""
return dict(_registry)
def identify_family(advertised_name: str) -> str | None:
"""Best-effort family detection from a BLE advertisement name.
Returns the family identifier if the name matches a known prefix,
otherwise ``None``.
"""
if not advertised_name:
return None
for proto in _registry.values():
for prefix in proto.name_prefixes:
if advertised_name.startswith(prefix):
return proto.family
return None
def identify_family_by_service_uuids(service_uuids: Tuple[str, ...]) -> str | None:
"""Best-effort family detection from advertised GATT service UUIDs.
Returns the first matching family or ``None``. Families that share the
same service UUID (e.g. SP110E and Zengge both use FFE0) are matched in
registration order — SP110E is registered first so it wins the tie.
"""
if not service_uuids:
return None
uuids_lower = {u.lower() for u in service_uuids}
for proto in _registry.values():
if proto.service_uuid.lower() in uuids_lower:
return proto.family
return None
def _register_builtins() -> None:
# Imported lazily to avoid circular imports during module init.
from ledgrab.core.devices.ble_protocols import govee, sp110e, triones, zengge
register_protocol(sp110e.PROTOCOL)
register_protocol(triones.PROTOCOL)
register_protocol(zengge.PROTOCOL)
register_protocol(govee.PROTOCOL)
_register_builtins()
@@ -0,0 +1,92 @@
"""Govee BLE controller protocol (experimental, per-model AES keyed).
Govee H6XXX strips speak a 20-byte framed BLE protocol. Newer firmware
(2022+) additionally wraps every frame with AES-128 where the key is
derived per model. Without the correct key the controller silently
drops frames.
This module exposes the **unencrypted** frame encoder — enough to drive
older Govee firmware and useful as a scaffold if a community AES key
ends up being wired in later. The encoder is pure; key negotiation
belongs in the transport layer where it can cache per-address state.
Frame layout (20 bytes):
``33 05 02 RR GG BB 00 00 00 00 00 00 00 00 00 00 00 00 00 XX``
where ``XX`` is an XOR checksum of bytes 0..18.
Reference:
* https://github.com/Freemanium/govee_btled (reverse-engineered)
* https://github.com/Beshelmek/govee_ble_lights
Status: **experimental**. If frames are silently dropped the model likely
requires encryption — that is out of scope here and will raise a clear
error at the transport layer.
"""
from __future__ import annotations
from ledgrab.core.devices.ble_protocols import BLEProtocol
# Govee uses a single 128-bit custom service with one write characteristic.
_SERVICE_UUID = "00010203-0405-0607-0809-0a0b0c0d1910"
_WRITE_CHAR_UUID = "00010203-0405-0607-0809-0a0b0c0d2b11"
_CMD_COLOR = 0x05
_CMD_POWER = 0x01
_MODE_MANUAL = 0x02
def _clamp_byte(value: int) -> int:
if value < 0:
return 0
if value > 255:
return 255
return value
def _frame(command: int, payload: bytes) -> bytes:
"""Wrap a command + payload in Govee's 20-byte framed format with XOR checksum."""
if len(payload) > 17:
raise ValueError("Govee payload must be ≤17 bytes")
buf = bytearray(20)
buf[0] = 0x33
buf[1] = command & 0xFF
buf[2 : 2 + len(payload)] = payload
checksum = 0
for i in range(19):
checksum ^= buf[i]
buf[19] = checksum & 0xFF
return bytes(buf)
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
"""Build a Govee "set solid color" frame."""
r = _clamp_byte(r)
g = _clamp_byte(g)
b = _clamp_byte(b)
brightness = _clamp_byte(brightness)
if brightness != 255:
r = (r * brightness) // 255
g = (g * brightness) // 255
b = (b * brightness) // 255
return _frame(_CMD_COLOR, bytes((_MODE_MANUAL, r, g, b)))
def encode_power(on: bool) -> bytes:
"""Build a Govee power on/off frame."""
return _frame(_CMD_POWER, bytes((0x01 if on else 0x00,)))
PROTOCOL = BLEProtocol(
family="govee",
display_name="Govee H6XXX (unencrypted — experimental)",
service_uuid=_SERVICE_UUID,
write_char_uuid=_WRITE_CHAR_UUID,
# Govee requires Write Request (with response) for reliable delivery.
write_with_response=True,
encode_color=encode_color,
encode_power=encode_power,
name_prefixes=("ihoment_H6", "Govee_H6", "Minger_H6"),
)
@@ -0,0 +1,84 @@
"""SP110E / SP108E addressable-BLE-controller protocol.
The SP110E is a BLE controller for addressable LED strips (WS2811, WS2812B,
SK6812, APA102, etc.). Its phone app (several rebrands, including "LED Hue",
"SP110E", "Custom Lights") streams control commands — but does **not** stream
per-pixel frames. The BLE protocol exposes:
* pick LED IC type + channel order
* pick a built-in animation pattern
* set animation speed + brightness
* set a single static color for the whole strip
So from LedGrab's perspective, SP110E is a whole-strip ambient controller.
Frame format (5 bytes, big-endian):
``RR GG BB 00 CC``
where ``CC`` is the command byte. Static-color command is ``0x1E`` (set
"RGB" mode = whole-strip solid color from the RR GG BB payload). Power is
a distinct command (``0xAA`` ON / ``0xAB`` OFF, with the three payload
bytes ignored). Brightness is applied by the *caller* scaling the RGB
triple — there is no separate brightness command for solid-color mode,
which is simpler and lets LedGrab apply its own processing pipeline.
References:
* https://github.com/Lehkeda/SP110E_controller (reverse-engineered)
* https://github.com/sysofwan/ha-sp110e
"""
from __future__ import annotations
from ledgrab.core.devices.ble_protocols import BLEProtocol
_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb"
_WRITE_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb"
_CMD_SET_COLOR = 0x1E
_CMD_POWER_ON = 0xAA
_CMD_POWER_OFF = 0xAB
def _clamp_byte(value: int) -> int:
if value < 0:
return 0
if value > 255:
return 255
return value
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
"""Build a "set solid color" frame.
Applies software brightness by scaling RGB — the SP110E protocol has
no separate brightness channel for static color mode.
"""
r = _clamp_byte(r)
g = _clamp_byte(g)
b = _clamp_byte(b)
brightness = _clamp_byte(brightness)
if brightness != 255:
r = (r * brightness) // 255
g = (g * brightness) // 255
b = (b * brightness) // 255
return bytes((r, g, b, 0x00, _CMD_SET_COLOR))
def encode_power(on: bool) -> bytes:
"""Build a power on/off frame."""
cmd = _CMD_POWER_ON if on else _CMD_POWER_OFF
return bytes((0x00, 0x00, 0x00, 0x00, cmd))
PROTOCOL = BLEProtocol(
family="sp110e",
display_name="SP110E / SP108E (addressable)",
service_uuid=_SERVICE_UUID,
write_char_uuid=_WRITE_CHAR_UUID,
# SP110E accepts Write Without Response — much lower latency.
write_with_response=False,
encode_color=encode_color,
encode_power=encode_power,
name_prefixes=("SP110E", "SP108E", "BLE-LED"),
)
@@ -0,0 +1,69 @@
"""Triones / HappyLighting / LEDnet BLE controller protocol.
Applies to the large family of cheap single-color BLE RGB(W) controllers
sold under names like ``Triones``, ``LEDnet``, ``HappyLighting``,
``Magic Home BLE``. They share a 9-byte framed protocol:
``7E 07 05 03 RR GG BB 10 EF`` — set solid color (RGB)
``7E 04 04 RR GG BB 10 EF`` — some LEDnet variants use the shorter form
Power is a separate frame:
``7E 04 04 F0 00 01 FF 00 EF`` — ON
``7E 04 04 00 00 00 FF 00 EF`` — OFF
Reference implementations:
* https://github.com/sysofwan/ha-magicfan
* https://github.com/madhead/saberlight
"""
from __future__ import annotations
from ledgrab.core.devices.ble_protocols import BLEProtocol
_SERVICE_UUID = "0000ffe5-0000-1000-8000-00805f9b34fb"
_WRITE_CHAR_UUID = "0000ffe9-0000-1000-8000-00805f9b34fb"
def _clamp_byte(value: int) -> int:
if value < 0:
return 0
if value > 255:
return 255
return value
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
"""Build a Triones "set solid color" frame.
Software brightness is applied to RGB — the on-wire protocol has no
separate brightness byte.
"""
r = _clamp_byte(r)
g = _clamp_byte(g)
b = _clamp_byte(b)
brightness = _clamp_byte(brightness)
if brightness != 255:
r = (r * brightness) // 255
g = (g * brightness) // 255
b = (b * brightness) // 255
return bytes((0x7E, 0x07, 0x05, 0x03, r, g, b, 0x10, 0xEF))
def encode_power(on: bool) -> bytes:
"""Build a Triones power on/off frame."""
if on:
return bytes((0x7E, 0x04, 0x04, 0xF0, 0x00, 0x01, 0xFF, 0x00, 0xEF))
return bytes((0x7E, 0x04, 0x04, 0x00, 0x00, 0x00, 0xFF, 0x00, 0xEF))
PROTOCOL = BLEProtocol(
family="triones",
display_name="Triones / HappyLighting / LEDnet",
service_uuid=_SERVICE_UUID,
write_char_uuid=_WRITE_CHAR_UUID,
write_with_response=False,
encode_color=encode_color,
encode_power=encode_power,
name_prefixes=("Triones", "LEDBLE", "LEDnet", "HappyLighting", "MagicHome"),
)
@@ -0,0 +1,67 @@
"""Zengge / iLightsIn BLE controller protocol.
Zengge (a.k.a. iLightsIn, Mohuan Lighting, generic "LED BLE") controllers
use a distinct wire protocol from Triones despite targeting the same
segment. Colors are framed with a leading ``0x56`` and trailed with
``0xAA``:
``56 RR GG BB 00 F0 AA`` — set RGB (bright=0xF0 marker)
``56 00 00 00 WW 0F AA`` — set warm-white (when RGBW is wired)
Power is a separate 7-byte frame:
``CC 23 33`` — ON
``CC 24 33`` — OFF
References:
* https://github.com/mjg59/python-zengge
* https://github.com/madhead/saberlight
"""
from __future__ import annotations
from ledgrab.core.devices.ble_protocols import BLEProtocol
# Zengge controllers share the FFE0/FFE1 pair with SP110E but run a different
# command protocol — a recurring source of confusion. Differentiate them by
# advertisement name (see ``name_prefixes``) or by user-picked family.
_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb"
_WRITE_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb"
def _clamp_byte(value: int) -> int:
if value < 0:
return 0
if value > 255:
return 255
return value
def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
"""Build a Zengge "set RGB" frame."""
r = _clamp_byte(r)
g = _clamp_byte(g)
b = _clamp_byte(b)
brightness = _clamp_byte(brightness)
if brightness != 255:
r = (r * brightness) // 255
g = (g * brightness) // 255
b = (b * brightness) // 255
return bytes((0x56, r, g, b, 0x00, 0xF0, 0xAA))
def encode_power(on: bool) -> bytes:
"""Build a Zengge power on/off frame."""
return bytes((0xCC, 0x23 if on else 0x24, 0x33))
PROTOCOL = BLEProtocol(
family="zengge",
display_name="Zengge / iLightsIn",
service_uuid=_SERVICE_UUID,
write_char_uuid=_WRITE_CHAR_UUID,
write_with_response=False,
encode_color=encode_color,
encode_power=encode_power,
name_prefixes=("Zengge", "iLightsIn", "Mohuan"),
)
@@ -0,0 +1,176 @@
"""BLE device provider — dispatch for BLE LED controllers."""
from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional, Tuple
from ledgrab.core.devices.ble_client import BLEClient, _strip_ble_scheme
from ledgrab.core.devices.ble_protocols import (
all_protocols,
identify_family,
identify_family_by_service_uuids,
)
from ledgrab.core.devices.ble_transport import scan as ble_scan
from ledgrab.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
ProviderDeps,
)
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.devices.device_config import BLEConfig
logger = get_logger(__name__)
class BLEDeviceProvider(LEDDeviceProvider):
"""Provider for BLE LED controllers (SP110E / Triones / Zengge / Govee).
URL format: ``ble://<address>``. The controller family is stored on
the device record as ``ble_family`` — not in the URL — because the
same MAC can advertise under different protocol variants depending
on firmware.
"""
@property
def device_type(self) -> str:
return "ble"
@property
def capabilities(self) -> set:
return {
"manual_led_count",
"power_control",
"static_color",
# BLE cannot do per-pixel streaming — no fast_send / brightness_control.
}
def create_client(self, config: "BLEConfig", *, deps: ProviderDeps) -> LEDClient:
if not config.ble_family:
raise ValueError(
"BLE device requires 'ble_family' (one of: "
+ ", ".join(sorted(all_protocols()))
+ ")"
)
return BLEClient(
url=config.device_url,
ble_family=config.ble_family,
led_count=config.led_count,
ble_govee_key=config.ble_govee_key,
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await BLEClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""BLE has no cheap, non-intrusive probe — a GATT connect is the only real signal
and would contend with an active streaming session, so we only sanity-check shape.
The live connection will surface a clear error on first ``send_pixels``
if the address is wrong or the protocol family was mis-picked.
"""
address = _strip_ble_scheme(url)
if not address:
raise ValueError("BLE device URL must be 'ble://<address>'")
return {}
async def discover(self, timeout: float = 4.0) -> List[DiscoveredDevice]:
"""Scan for BLE peripherals and classify by advertised name prefix."""
try:
found = await ble_scan(timeout=timeout)
except RuntimeError as exc:
logger.warning("BLE discovery unavailable: %s", exc)
return []
results: List[DiscoveredDevice] = []
for device in found:
family = identify_family(device.name)
if family is None:
# Windows often omits the advertisement name for non-paired
# devices — fall back to service UUID matching.
family = identify_family_by_service_uuids(device.service_uuids)
if family is not None:
logger.debug(
"BLE device %s (%s) identified by service UUID as %s",
device.address,
device.name,
family,
)
if family is not None:
display_name = f"{device.name} [{family}]"
else:
# Unknown device — include it so the user can add it manually.
logger.debug(
"BLE device %s (%s) does not match a known LED family",
device.address,
device.name,
)
display_name = device.name
results.append(
DiscoveredDevice(
name=display_name,
url=f"ble://{device.address}",
device_type="ble",
ip=device.address,
mac=device.address,
led_count=None,
version=None,
ble_family=family,
)
)
return results
async def set_power(self, url: str, on: bool, **kwargs) -> None:
"""Open a short-lived BLE session to toggle power, then close."""
family = kwargs.get("ble_family")
if not family:
raise ValueError("BLE power control requires 'ble_family'")
client = BLEClient(
url=url, ble_family=family, ble_govee_key=kwargs.get("ble_govee_key", "")
)
try:
await client.connect()
await client.set_power(on)
finally:
await client.close()
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
"""Open a short-lived BLE session to set a solid color, then close."""
family = kwargs.get("ble_family")
if not family:
raise ValueError("BLE color control requires 'ble_family'")
brightness: int = kwargs.get("brightness", 255)
client = BLEClient(
url=url, ble_family=family, ble_govee_key=kwargs.get("ble_govee_key", "")
)
try:
await client.connect()
await client.send_pixels([color], brightness=brightness)
finally:
await client.close()
async def get_power(self, url: str, **kwargs) -> bool:
# None of the supported BLE protocols expose a "get power state" read.
# Treat as always-on so the UI doesn't show a misleading OFF indicator.
return True
def list_families(self) -> List[dict]:
"""Enumerate registered BLE protocol families (for the frontend device form)."""
return [
{"family": proto.family, "display_name": proto.display_name}
for proto in all_protocols().values()
]
def get_ble_provider() -> Optional["BLEDeviceProvider"]:
"""Return the registered BLE provider, or ``None`` if not registered."""
from ledgrab.core.devices.led_client import get_provider
try:
provider = get_provider("ble")
except ValueError:
return None
return provider if isinstance(provider, BLEDeviceProvider) else None
@@ -0,0 +1,208 @@
"""Thin async wrapper around ``bleak`` for LED-controller use.
Exists to:
* Isolate the ``import bleak`` site so the rest of the codebase doesn't
crash on platforms where bleak is unavailable (Chaquopy / Android).
* Normalise addresses so UUID-on-macOS and MAC-on-Windows/Linux both
work with the same API shape.
* Coalesce rapid ``write()`` calls — BLE writes are O(tens of ms) each
and LedGrab's hot loop runs at 60+ FPS, so we drop any pending write
that has been superseded before it is sent.
Import-order note: ``bleak`` is imported lazily inside methods so the
module itself imports cleanly on Android, where the whole BLE feature
is effectively disabled.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import List, Optional
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
logger = get_logger(__name__)
def _bleak_available() -> bool:
try:
import bleak # noqa: F401
except ImportError:
return False
return True
@dataclass(frozen=True)
class DiscoveredBLEDevice:
"""One BLE peripheral found during scanning."""
address: str
name: str
rssi: Optional[int]
service_uuids: tuple = ()
async def scan(timeout: float = 4.0) -> List[DiscoveredBLEDevice]:
"""Scan for nearby BLE peripherals.
On Android dispatches to the Kotlin BleBridge scanner.
On desktop uses bleak (requires the [ble] extra).
Returns devices sorted by RSSI descending (strongest first).
Raises:
RuntimeError: If neither backend is available.
"""
if is_android():
from ledgrab.core.devices.android_ble_transport import android_ble_scan
return await android_ble_scan(timeout=timeout)
if not _bleak_available():
raise RuntimeError(
"bleak is not installed — BLE support requires the [ble] extra. "
"Install with: pip install 'ledgrab[ble]'"
)
from bleak import BleakScanner
raw = await BleakScanner.discover(timeout=timeout, return_adv=True)
devices: List[DiscoveredBLEDevice] = []
for address, (device, adv) in raw.items():
# Some platforms don't surface names for non-advertising peripherals —
# fall back to the address so the UI can still show something.
name = adv.local_name or device.name or address
rssi = getattr(adv, "rssi", None)
service_uuids = tuple(getattr(adv, "service_uuids", None) or [])
devices.append(
DiscoveredBLEDevice(address=address, name=name, rssi=rssi, service_uuids=service_uuids)
)
devices.sort(key=lambda d: (d.rssi is None, -(d.rssi or 0)))
return devices
class BLETransport:
"""Async wrapper around a ``BleakClient`` with write coalescing.
Lifecycle:
transport = BLETransport(address, write_char_uuid)
await transport.connect()
await transport.write(b"...")
await transport.close()
"""
def __init__(
self,
address: str,
write_char_uuid: str,
write_with_response: bool = False,
connect_timeout: float = 10.0,
):
self._address = address
self._write_char_uuid = write_char_uuid
self._write_with_response = write_with_response
self._connect_timeout = connect_timeout
self._client = None # BleakClient | None
self._lock = asyncio.Lock()
@property
def address(self) -> str:
return self._address
@property
def is_connected(self) -> bool:
return self._client is not None and bool(getattr(self._client, "is_connected", False))
async def connect(self) -> None:
"""Connect to the peripheral.
Raises:
RuntimeError: If bleak is unavailable or connection fails.
"""
if not _bleak_available():
raise RuntimeError("bleak is not installed — BLE support requires the [ble] extra.")
from bleak import BleakClient
if self.is_connected:
return
self._client = BleakClient(self._address, timeout=self._connect_timeout)
try:
# bleak's WinRT backend does not always respect the constructor
# timeout — connect() can block 30s+ when the peripheral is gone.
# Wrap in wait_for so the Python-side bound is enforced.
await asyncio.wait_for(self._client.connect(), timeout=self._connect_timeout)
except Exception as exc:
self._client = None
raise RuntimeError(f"Failed to connect to BLE device {self._address}: {exc}") from exc
logger.info("BLE connected to %s", self._address)
async def close(self) -> None:
"""Disconnect (best effort — never raises)."""
client = self._client
self._client = None
if client is None:
return
try:
if getattr(client, "is_connected", False):
await client.disconnect()
except Exception as exc:
logger.warning("BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes) -> None:
"""Send bytes to the configured write characteristic.
Serialised through an internal lock — BLE stacks do not like
overlapping writes on the same GATT characteristic.
Bounded by a 2-second timeout: Windows/bleak occasionally hangs for
its default 30s on the second write to certain cheap BLE LED chips.
Timing out keeps the target's processing loop responsive.
Raises:
RuntimeError: If not connected.
TimeoutError: If the write does not complete within 2 seconds.
"""
if not self.is_connected or self._client is None:
raise RuntimeError(f"BLE transport {self._address} not connected")
async with self._lock:
await asyncio.wait_for(
self._client.write_gatt_char(
self._write_char_uuid, data, response=self._write_with_response
),
timeout=2.0,
)
def make_transport(
address: str,
write_char_uuid: str,
write_with_response: bool = False,
connect_timeout: float = 10.0,
) -> "BLETransport":
"""Return the appropriate BLE transport for the current platform.
On Android returns an :class:`~ledgrab.core.devices.android_ble_transport.AndroidBLETransport`
backed by the Kotlin ``BleBridge`` singleton. On desktop returns a
:class:`BLETransport` backed by bleak.
The returned object has the same interface regardless of backend
(``connect``, ``close``, ``write``, ``is_connected``, ``address``).
"""
if is_android():
from ledgrab.core.devices.android_ble_transport import AndroidBLETransport
return AndroidBLETransport( # type: ignore[return-value]
address=address,
write_char_uuid=write_char_uuid,
write_with_response=write_with_response,
connect_timeout=connect_timeout,
)
return BLETransport(
address=address,
write_char_uuid=write_char_uuid,
write_with_response=write_with_response,
connect_timeout=connect_timeout,
)
@@ -47,6 +47,10 @@ class DiscoveredDevice:
mac: str
led_count: Optional[int]
version: Optional[str]
# Optional provider-specific detected protocol identifier (e.g. BLE family
# like "sp110e" / "triones" / "zengge" / "govee"). Surfaced so the UI can
# preselect the right sub-type when the user adds a discovered device.
ble_family: Optional[str] = None
class LEDClient(ABC):
@@ -1190,6 +1190,11 @@ class SystemMetricsValueStream(ValueStream):
Normalizes readings to [0, 1], with optional EMA smoothing and
configurable poll interval.
On Android (Chaquopy), psutil is unavailable. The stream falls back
to the platform-aware :func:`~ledgrab.utils.metrics.get_metrics_provider`
for cpu/memory and returns 0.0 for desktop-only sensors (temps,
fans, battery, network, disk, GPU).
"""
def __init__(
@@ -1219,16 +1224,23 @@ class SystemMetricsValueStream(ValueStream):
self._prev_net_time: Optional[float] = None
# GPU unavailable flag (avoid repeated warnings)
self._gpu_unavailable = False
# psutil may be unavailable on Android
try:
import psutil as _psutil
self._psutil = _psutil
except ImportError:
self._psutil = None
def start(self) -> None:
import psutil
if self._psutil is None:
return
# Prime cpu_percent so the first real call returns meaningful data
if self._metric == "cpu_load":
psutil.cpu_percent(interval=None)
self._psutil.cpu_percent(interval=None)
# Prime network counters
if self._metric in ("network_rx", "network_tx"):
counters = psutil.net_io_counters()
counters = self._psutil.net_io_counters()
if counters:
self._prev_net_bytes = (
counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent
@@ -1280,34 +1292,64 @@ class SystemMetricsValueStream(ValueStream):
return 0.0
def _read_metric(self) -> float:
"""Read the raw metric value from the system."""
import psutil
"""Read the raw metric value from the system.
When psutil is unavailable (Android), falls back to the
platform-aware MetricsProvider for cpu/memory and returns 0.0
for desktop-only metrics.
"""
try:
if self._metric == "cpu_load":
return psutil.cpu_percent(interval=None)
elif self._metric == "ram_usage":
return psutil.virtual_memory().percent
elif self._metric == "disk_usage":
return psutil.disk_usage(self._disk_path).percent
elif self._metric == "battery_level":
bat = psutil.sensors_battery()
return bat.percent if bat else 0.0
elif self._metric == "cpu_temp":
return self._read_cpu_temp()
elif self._metric == "fan_speed":
return self._read_fan_speed()
elif self._metric in ("gpu_load", "gpu_temp"):
return self._read_gpu_metric()
elif self._metric in ("network_rx", "network_tx"):
return self._read_network_rate()
if self._psutil is not None:
return self._read_metric_psutil()
return self._read_metric_fallback()
except Exception as e:
logger.debug("SystemMetricsValueStream read error (%s): %s", self._metric, e)
return self._raw_value if self._raw_value is not None else 0.0
def _read_cpu_temp(self) -> float:
import psutil
def _read_metric_psutil(self) -> float:
"""Read metrics via psutil (desktop path)."""
psutil = self._psutil
if self._metric == "cpu_load":
return psutil.cpu_percent(interval=None)
elif self._metric == "ram_usage":
return psutil.virtual_memory().percent
elif self._metric == "disk_usage":
return psutil.disk_usage(self._disk_path).percent
elif self._metric == "battery_level":
bat = psutil.sensors_battery()
return bat.percent if bat else 0.0
elif self._metric == "cpu_temp":
return self._read_cpu_temp()
elif self._metric == "fan_speed":
return self._read_fan_speed()
elif self._metric in ("gpu_load", "gpu_temp"):
return self._read_gpu_metric()
elif self._metric in ("network_rx", "network_tx"):
return self._read_network_rate()
return 0.0
def _read_metric_fallback(self) -> float:
"""Read metrics without psutil (Android / fallback path).
Uses the MetricsProvider abstraction for cpu/memory. Sensors,
battery, network, disk, and GPU are not available.
"""
from ledgrab.utils.metrics import get_metrics_provider
provider = get_metrics_provider()
if self._metric == "cpu_load":
return provider.cpu_percent()
elif self._metric == "ram_usage":
mem = provider.virtual_memory()
if mem.total_bytes > 0:
return (mem.used_bytes / mem.total_bytes) * 100.0
return 0.0
return 0.0
def _read_cpu_temp(self) -> float:
psutil = self._psutil
if psutil is None:
return 0.0
temps = psutil.sensors_temperatures()
if not temps:
return 0.0
@@ -1324,8 +1366,9 @@ class SystemMetricsValueStream(ValueStream):
return 0.0
def _read_fan_speed(self) -> float:
import psutil
psutil = self._psutil
if psutil is None:
return 0.0
fans = psutil.sensors_fans()
if not fans:
return 0.0
@@ -1360,8 +1403,9 @@ class SystemMetricsValueStream(ValueStream):
return 0.0
def _read_network_rate(self) -> float:
import psutil
psutil = self._psutil
if psutil is None:
return 0.0
counters = psutil.net_io_counters()
if not counters:
return 0.0
+60 -1
View File
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from ledgrab import __version__, GITEA_BASE_URL, GITEA_REPO
from ledgrab import __version__, GITEA_BASE_URL, GITEA_REPO, paths as _paths
from ledgrab.api import router
from ledgrab.api.dependencies import init_dependencies
from ledgrab.config import get_config
@@ -70,6 +70,65 @@ logger = get_logger(__name__)
# Get configuration
config = get_config()
def _migrate_legacy_data_location() -> None:
"""Rescue data from pre-rename cwd-relative paths.
Older versions (and dev runs from inside ``server/``) wrote the database
and assets to ``<cwd>/data/``. If the configured database location is
empty but a legacy path has data, copy it over so the user's data
follows them to the platform-standard location.
"""
import shutil
db_path = Path(config.storage.database_file)
if db_path.exists():
return # configured location already populated — nothing to do
for legacy_db in _paths.legacy_db_candidates():
if not legacy_db.is_file():
continue
try:
# Skip if legacy is the same file we were going to open.
if db_path.parent.exists() and legacy_db.resolve() == db_path.resolve():
continue
except OSError:
continue
if legacy_db.stat().st_size < 4096:
# 4 KiB is roughly a freshly-initialised SQLite file with no
# user data — skip so an empty dev DB doesn't shadow a real one.
continue
logger.warning(
"Migrating database from legacy location %s -> %s. "
"The original file is kept in place; you may delete it once you "
"confirm the new location works.",
legacy_db,
db_path,
)
db_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(legacy_db, db_path)
# Copy WAL/SHM side-files too so uncheckpointed writes come along.
for suffix in ("-wal", "-shm"):
side = legacy_db.with_name(legacy_db.name + suffix)
if side.exists():
shutil.copy2(side, db_path.with_name(db_path.name + suffix))
# Also migrate assets dir if the configured one is missing.
assets_dir = Path(config.assets.assets_dir)
legacy_assets = legacy_db.parent / "assets"
if not assets_dir.exists() and legacy_assets.is_dir():
logger.warning(
"Migrating assets from legacy location %s -> %s",
legacy_assets,
assets_dir,
)
shutil.copytree(legacy_assets, assets_dir)
return
_migrate_legacy_data_location()
# Initialize SQLite database
db = Database(config.storage.database_file)
+56
View File
@@ -0,0 +1,56 @@
"""Default data directory resolution.
Each LedGrab install/checkout uses its own data directory by default —
a cwd-relative ``data`` folder — so running two versions side-by-side does
not mix their databases together.
Precedence:
1. ``LEDGRAB_STORAGE__DATABASE_FILE`` / ``LEDGRAB_ASSETS__ASSETS_DIR`` env
vars (used by the Android entry point and fine-grained overrides).
2. ``storage.database_file`` / ``assets.assets_dir`` in config.yaml.
3. ``LEDGRAB_DATA_DIR`` env var (one-line override for the whole data
dir — useful for dev launchers that want to isolate from prod).
4. ``./data`` relative to the process working directory.
"""
import os
from pathlib import Path
_ENV_DATA_DIR = "LEDGRAB_DATA_DIR"
def default_data_dir() -> Path:
"""Return the directory where data files live by default.
Honours the ``LEDGRAB_DATA_DIR`` env var; otherwise returns ``./data``.
Callers should treat the result as the *parent* of ``ledgrab.db`` and
``assets/``.
"""
override = os.environ.get(_ENV_DATA_DIR)
if override:
return Path(override)
return Path("data")
def legacy_db_candidates() -> list[Path]:
"""Return cwd-relative database paths that predate :func:`default_data_dir`.
Used by the startup migration in ``main.py`` to rescue data that was
previously written by older versions (or by dev runs from inside ``server/``).
Order matters: first existing match wins.
"""
cwd = Path.cwd()
return [
cwd / "data" / "ledgrab.db",
cwd / "server" / "data" / "ledgrab.db",
]
def legacy_assets_candidates() -> list[Path]:
"""Return cwd-relative assets directories paired with :func:`legacy_db_candidates`."""
cwd = Path.cwd()
return [
cwd / "data" / "assets",
cwd / "server" / "data" / "assets",
]
+38
View File
@@ -2268,3 +2268,41 @@ body.composite-layer-dragging .composite-layer-drag-handle {
color: white;
}
/* Inline code + copyable snippet used by the setup-required modal */
.code-snippet-wrapper {
position: relative;
margin: 6px 0;
}
.code-snippet {
display: block;
margin: 0;
padding: 10px 44px 10px 12px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 6px;
font-family: var(--font-mono, 'Consolas', 'Courier New', monospace);
font-size: 0.85em;
line-height: 1.45;
color: var(--text-color, #e0e0e0);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.code-snippet code {
font-family: inherit;
background: none;
padding: 0;
color: inherit;
}
.code-snippet-wrapper .copy-btn {
position: absolute;
top: 6px;
right: 6px;
padding: 4px 6px;
line-height: 1;
}
+22 -5
View File
@@ -3,7 +3,7 @@
*/
// Layer 0: state
import { apiKey, setApiKey, authRequired, refreshInterval } from './core/state.ts';
import { apiKey, setApiKey, authRequired, refreshInterval, setupRequired } from './core/state.ts';
import { Modal } from './core/modal.ts';
import { queryEl } from './core/dom-utils.ts';
@@ -695,8 +695,19 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize locale (dispatches languageChanged which may trigger API calls)
await initLocale();
// Load external URL setting early so getBaseOrigin() is available for card rendering
loadExternalUrl();
// Probe /health first so we know whether the server has API keys configured
// AND whether this client is loopback or LAN. The result (setup_required
// and auth_required flags) gates every subsequent call; without it, a LAN
// client without keys flashes a useless login modal before the setup
// screen can take over.
await loadServerInfo();
// Load external URL setting early so getBaseOrigin() is available for card
// rendering — but skip when the server has no keys for LAN access, as the
// call would just 401 and trigger the login modal behind the setup screen.
if (!setupRequired) {
loadExternalUrl();
}
// Restore active tab before showing content to avoid visible jump
initTabs();
@@ -750,14 +761,20 @@ document.addEventListener('DOMContentLoaded', async () => {
const addDeviceForm = queryEl('add-device-form');
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
// Always monitor server connection (even before login)
await loadServerInfo();
// Keep monitoring server connection (initial /health ran earlier).
startConnectionMonitor();
// Expose auth state for inline scripts (after loadServerInfo sets it)
(window as any)._authRequired = authRequired;
if (typeof window.updateAuthUI === 'function') window.updateAuthUI();
// Server is unconfigured for LAN access → setup screen already shown by
// loadServerInfo. Skip login modal and data loads; the user can't do
// anything until they configure keys on the server.
if (setupRequired) {
return;
}
// Show login modal only when auth is enabled and no API key is stored
if (authRequired && !apiKey) {
setTimeout(() => {
+24 -1
View File
@@ -2,7 +2,7 @@
* API utilities — base URL, auth headers, fetch wrapper, helpers.
*/
import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { apiKey, setApiKey, authRequired, setAuthRequired, setupRequired, setSetupRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { t } from './i18n.ts';
import { showToast } from './ui.ts';
import { getEl, queryEl } from './dom-utils.ts';
@@ -175,8 +175,16 @@ export function isGroupDevice(type: string) {
return type === 'group';
}
export function isBleDevice(type: string) {
return type === 'ble';
}
export function handle401Error() {
if (!authRequired) return; // Auth disabled — ignore 401s
// Server has no keys configured and we're on LAN: the setup-required
// screen is (or is about to be) shown by loadServerInfo. Don't pop a
// login modal on top of it — no key would ever work.
if (setupRequired) return;
if (!apiKey) return; // Already handled or no session
localStorage.removeItem('ledgrab_api_key');
setApiKey(null);
@@ -278,6 +286,21 @@ export async function loadServerInfo() {
setAuthRequired(authNeeded);
(window as any)._authRequired = authNeeded;
// Setup-required detection (LAN client + no keys configured server-side).
// When true, no API key will ever succeed — show a dedicated screen
// instead of the login form.
const setupNeeded = data.setup_required === true;
setSetupRequired(setupNeeded);
(window as any)._setupRequired = setupNeeded;
if (setupNeeded) {
if (typeof window.showSetupRequiredModal === 'function') {
window.showSetupRequiredModal();
}
} else if (typeof window.hideSetupRequiredModal === 'function') {
// Server was reconfigured — clear the setup overlay if it was up.
if ((window as any)._setupModalOpen) window.hideSetupRequiredModal();
}
// Project URLs (repo, donate)
if (data.repo_url) serverRepoUrl = data.repo_url;
if (data.donate_url) serverDonateUrl = data.donate_url;
@@ -38,6 +38,7 @@ export const star = '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.67
export const hash = '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>';
export const camera = '<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>';
export const bellRing = '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/>';
export const bluetooth = '<path d="m7 7 10 10-5 5V2l5 5L7 17"/>';
export const wrench = '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>';
export const music = '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>';
export const search = '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>';
@@ -50,6 +50,7 @@ const _deviceTypeIcons = {
dmx: _svg(P.radio), mock: _svg(P.wrench),
espnow: _svg(P.radio), hue: _svg(P.lightbulb), usbhid: _svg(P.usb),
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
ble: _svg(P.bluetooth),
group: _svg(P.layers),
};
const _engineTypeIcons = {
@@ -321,6 +322,8 @@ export const ICON_UNDO = _svg(P.undo2);
export const ICON_SCENE = _svg(P.sparkles);
export const ICON_CAPTURE = _svg(P.camera);
export const ICON_BELL = _svg(P.bellRing);
export const ICON_BLUETOOTH = _svg(P.bluetooth);
export const ICON_LIGHTBULB = _svg(P.lightbulb);
export const ICON_THERMOMETER = _svg(P.thermometer);
export const ICON_CPU = _svg(P.cpu);
export const ICON_KEYBOARD = _svg(P.keyboard);
@@ -28,6 +28,15 @@ export function setApiKey(v: string | null) { apiKey = v; }
export let authRequired = true;
export function setAuthRequired(v: boolean) { authRequired = v; }
/**
* True when the server reports it has no API keys configured AND the request
* is coming from a non-loopback client. In that state, no key can succeed —
* the UI should show a dedicated "setup required" screen instead of the
* login form.
*/
export let setupRequired = false;
export function setSetupRequired(v: boolean) { setupRequired = v; }
export let refreshInterval: ReturnType<typeof setInterval> | null = null;
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }
@@ -7,13 +7,13 @@ import {
_discoveryCache, set_discoveryCache,
csptCache,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { _computeMaxFps, _renderFpsHint } from './devices.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY } from '../core/icons.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES } from '../core/icons.ts';
import { EntitySelect, EntityPalette } from '../core/entity-palette.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
@@ -36,6 +36,8 @@ class AddDeviceModal extends Modal {
dmxProtocol: (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet',
dmxStartUniverse: (document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0',
dmxStartChannel: (document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1',
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '',
groupChildren: JSON.stringify(_getGroupChildIds('device')),
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
};
@@ -46,7 +48,7 @@ const addDeviceModal = new AddDeviceModal();
/* ── Icon-grid type selector ──────────────────────────────────── */
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
function _buildDeviceTypeItems() {
return DEVICE_TYPE_KEYS.map(key => ({
@@ -229,6 +231,7 @@ export function onDeviceTypeChanged() {
// Hide new device type fields by default
_showEspnowFields(false);
_showHueFields(false);
_showBleFields(false);
_showSpiFields(false);
_showChromaFields(false);
_showGameSenseFields(false);
@@ -383,6 +386,28 @@ export function onDeviceTypeChanged() {
} else {
scanForDevices();
}
} else if (isBleDevice(deviceType)) {
// BLE: show URL (ble://<address>), LED count, protocol family picker,
// and a Govee-only AES key field that toggles with the family selection.
urlGroup.style.display = '';
urlInput.setAttribute('required', '');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = '';
baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (scanBtn) scanBtn.style.display = '';
_showBleFields(true);
_ensureBleFamilyIconSelect();
if (urlLabel) urlLabel.textContent = t('device.ble.url') || 'BLE Address';
if (urlHint) urlHint.textContent = t('device.ble.url.hint') || 'MAC address (Windows/Linux) or UUID (macOS), prefixed with ble://';
urlInput.placeholder = 'ble://AA:BB:CC:DD:EE:FF';
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
} else if (isUsbhidDevice(deviceType)) {
// USB HID: show URL (VID:PID), LED count
urlGroup.style.display = '';
@@ -666,6 +691,17 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
const sendLatencyEl = document.getElementById('device-send-latency') as HTMLInputElement;
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
}
// Prefill BLE fields
if (isBleDevice(presetType)) {
const bleFamilyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (bleFamilyEl && cloneData.ble_family) {
bleFamilyEl.value = cloneData.ble_family;
if (_bleFamilyIconSelect) _bleFamilyIconSelect.setValue(cloneData.ble_family);
}
const goveeKeyEl = document.getElementById('device-ble-govee-key') as HTMLInputElement;
if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key;
_updateBleGoveeKeyVisibility();
}
// Prefill DMX fields
if (isDmxDevice(presetType)) {
const dmxProto = document.getElementById('device-dmx-protocol') as HTMLSelectElement;
@@ -727,7 +763,8 @@ export async function scanForDevices(forceType?: any) {
if (scanBtn) scanBtn.disabled = true;
try {
const response = await fetchWithAuth(`/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`);
const scanTimeout = scanType === 'ble' ? 8 : 3;
const response = await fetchWithAuth(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false;
@@ -778,6 +815,16 @@ export function selectDiscoveredDevice(device: any) {
if (isOpenrgbDevice(device.device_type)) {
_fetchOpenrgbZones(device.url, 'device-zone-list');
}
// Auto-fill the BLE protocol family detected during discovery so the
// user doesn't silently get the default (sp110e) against a different
// controller. Wrong family → writes go to a non-existent GATT
// characteristic and the strip stays dark.
if (isBleDevice(device.device_type) && device.ble_family) {
const familyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (familyEl) familyEl.value = device.ble_family;
if (_bleFamilyIconSelect) _bleFamilyIconSelect.setValue(device.ble_family);
_updateBleGoveeKeyVisibility();
}
showToast(t('device.scan.selected'), 'info');
}
@@ -859,6 +906,11 @@ export async function handleAddDevice(event: any) {
body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || '';
body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || '';
}
if (isBleDevice(deviceType)) {
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
if (goveeKey) body.ble_govee_key = goveeKey;
}
if (isSpiDevice(deviceType)) {
body.spi_speed_hz = parseInt((document.getElementById('device-spi-speed') as HTMLInputElement)?.value || '800000', 10);
body.spi_led_type = (document.getElementById('device-spi-led-type') as HTMLSelectElement)?.value || 'WS2812B';
@@ -889,7 +941,7 @@ export async function handleAddDevice(event: any) {
if (response.ok) {
const result = await response.json();
console.log('Device added successfully:', result);
// result is logged by the API layer; no console.log here.
showToast(t('device_discovery.added'), 'success');
devicesCache.invalidate();
addDeviceModal.forceClose();
@@ -1207,6 +1259,56 @@ function _showHueFields(show: boolean) {
});
}
// Tracks whether the BLE fields are currently shown — avoids reading
// style.display strings in _updateBleGoveeKeyVisibility.
let _bleFieldsVisible = false;
function _showBleFields(show: boolean) {
_bleFieldsVisible = show;
const familyGroup = document.getElementById('device-ble-family-group') as HTMLElement;
if (familyGroup) familyGroup.style.display = show ? '' : 'none';
if (!show) _destroyBleFamilyIconSelect();
_updateBleGoveeKeyVisibility();
}
function _updateBleGoveeKeyVisibility() {
const family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value;
const goveeGroup = document.getElementById('device-ble-govee-key-group') as HTMLElement;
if (goveeGroup) goveeGroup.style.display = _bleFieldsVisible && family === 'govee' ? '' : 'none';
}
function _buildBleFamilyItems() {
return [
{ value: 'sp110e', icon: ICON_CPU, label: 'SP110E / SP108E', desc: t('device.ble.family.sp110e.desc') },
{ value: 'triones', icon: ICON_BLUETOOTH, label: 'Triones / HappyLighting / LEDnet', desc: t('device.ble.family.triones.desc') },
{ value: 'zengge', icon: ICON_LIGHTBULB, label: 'Zengge / iLightsIn', desc: t('device.ble.family.zengge.desc') },
{ value: 'govee', icon: ICON_BLUETOOTH, label: 'Govee (experimental)', desc: t('device.ble.family.govee.desc') },
];
}
let _bleFamilyIconSelect: any = null;
function _destroyBleFamilyIconSelect() {
if (_bleFamilyIconSelect) {
_bleFamilyIconSelect.destroy();
_bleFamilyIconSelect = null;
}
}
function _ensureBleFamilyIconSelect() {
const sel = document.getElementById('device-ble-family') as HTMLSelectElement;
if (!sel) return;
if (_bleFamilyIconSelect) {
_bleFamilyIconSelect.updateItems(_buildBleFamilyItems());
} else {
_bleFamilyIconSelect = new IconSelect({ target: sel, items: _buildBleFamilyItems(), columns: 2 } as any);
// Register once — native <select> change fires when IconSelect picks a value,
// which is what toggles the Govee key field.
sel.addEventListener('change', _updateBleGoveeKeyVisibility);
}
_updateBleGoveeKeyVisibility();
}
function _showSpiFields(show: boolean) {
const ids = ['device-spi-speed-group', 'device-spi-led-type-group'];
ids.forEach(id => {
+2
View File
@@ -15,6 +15,8 @@ interface Window {
// ─── Auth (set by inline <script> in index.html) ───
updateAuthUI: (() => void) | undefined;
showApiKeyModal: ((msg: string | null, force?: boolean) => void) | undefined;
showSetupRequiredModal: (() => void) | undefined;
hideSetupRequiredModal: (() => void) | undefined;
// ─── Core / state ───
setApiKey: (key: string | null) => void;
+24
View File
@@ -27,6 +27,7 @@
"auth.button.cancel": "Cancel",
"auth.button.login": "Login",
"auth.error.required": "Please enter an API key",
"auth.error.invalid": "Invalid API key. Please try again.",
"auth.success": "Logged in successfully!",
"auth.logout.confirm": "Are you sure you want to logout?",
"auth.logout.success": "Logged out successfully",
@@ -36,6 +37,16 @@
"auth.prompt_enter": "Enter your API key:",
"auth.toggle_password": "Toggle password visibility",
"api_key.login": "Login",
"setup.title": "Server setup required",
"setup.description": "This LedGrab server has no API keys configured, so access from other devices on the network is disabled for security. Configure a key on the machine running the server to enable LAN access.",
"setup.step1_label": "1. On the server machine, edit <code>config/default_config.yaml</code>:",
"setup.step2_label": "2. Restart the server, then reload this page and log in with that key.",
"setup.step3_label": "Alternative: open LedGrab from the server machine itself (loopback), no key required:",
"setup.hint_openssl": "Generate a strong key on Linux/macOS with <code>openssl rand -hex 32</code>, or on Windows PowerShell with <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.",
"setup.copy": "Copy snippet",
"setup.copied": "Copied to clipboard",
"setup.retry": "I've configured a key — retry",
"setup.still_required": "Server still reports no API keys. Make sure you saved the config file and restarted the server.",
"displays.title": "Available Displays",
"displays.layout": "Displays",
"displays.information": "Display Information",
@@ -168,6 +179,19 @@
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
"device.type.hue": "Philips Hue",
"device.type.hue.desc": "Hue Entertainment API streaming",
"device.type.ble": "BLE LED Controller",
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
"device.ble.url": "BLE Address:",
"device.ble.url.hint": "MAC address (Windows/Linux) or UUID (macOS), prefixed with ble://",
"device.ble.family": "Protocol Family:",
"device.ble.family.hint": "Which BLE protocol your controller speaks. Match the phone app you normally use.",
"device.ble.family.sp110e.desc": "Addressable controllers — LED Hue / SP110E app",
"device.ble.family.triones.desc": "Single-color controllers — HappyLighting / LEDnet app",
"device.ble.family.zengge.desc": "Single-color controllers — iLightsIn / Mohuan app",
"device.ble.family.govee.desc": "Govee H6xxx strips — unencrypted firmware only",
"device.ble.govee_key": "Govee AES Key (hex):",
"device.ble.govee_key.hint": "Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.",
"device.ble.govee_key.placeholder": "32 hex digits, e.g. 0102…1f20",
"device.type.usbhid": "USB HID",
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
"device.type.spi": "SPI Direct",
+24
View File
@@ -31,6 +31,7 @@
"auth.button.cancel": "Отмена",
"auth.button.login": "Войти",
"auth.error.required": "Пожалуйста, введите API ключ",
"auth.error.invalid": "Неверный API ключ. Попробуйте ещё раз.",
"auth.success": "Вход выполнен успешно!",
"auth.logout.confirm": "Вы уверены, что хотите выйти?",
"auth.logout.success": "Выход выполнен успешно",
@@ -40,6 +41,16 @@
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"api_key.login": "Войти",
"setup.title": "Требуется настройка сервера",
"setup.description": "На этом сервере LedGrab не настроены API-ключи, поэтому доступ с других устройств в сети отключён из соображений безопасности. Чтобы разрешить доступ по локальной сети, настройте ключ на машине, где работает сервер.",
"setup.step1_label": "1. На машине с сервером откройте <code>config/default_config.yaml</code>:",
"setup.step2_label": "2. Перезапустите сервер, затем перезагрузите эту страницу и войдите с этим ключом.",
"setup.step3_label": "Либо откройте LedGrab прямо на машине с сервером (через loopback), ключ не нужен:",
"setup.hint_openssl": "Сгенерировать надёжный ключ: Linux/macOS — <code>openssl rand -hex 32</code>; Windows PowerShell — <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.",
"setup.copy": "Скопировать фрагмент",
"setup.copied": "Скопировано в буфер обмена",
"setup.retry": "Я настроил ключ — проверить снова",
"setup.still_required": "Сервер всё ещё сообщает об отсутствии API-ключей. Проверьте, что файл сохранён и сервер перезапущен.",
"displays.title": "Доступные Дисплеи",
"displays.layout": "Дисплеи",
"displays.information": "Информация о Дисплеях",
@@ -172,6 +183,19 @@
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
"device.type.hue": "Philips Hue",
"device.type.hue.desc": "Hue Entertainment API streaming",
"device.type.ble": "BLE LED контроллер",
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
"device.ble.url": "BLE адрес:",
"device.ble.url.hint": "MAC-адрес (Windows/Linux) или UUID (macOS) с префиксом ble://",
"device.ble.family": "Протокол:",
"device.ble.family.hint": "Какой BLE-протокол использует контроллер. Выбирайте по названию приложения, которым обычно управляете.",
"device.ble.family.sp110e.desc": "Адресуемые контроллеры — приложение LED Hue / SP110E",
"device.ble.family.triones.desc": "Одноцветные контроллеры — HappyLighting / LEDnet",
"device.ble.family.zengge.desc": "Одноцветные контроллеры — iLightsIn / Mohuan",
"device.ble.family.govee.desc": "Ленты Govee H6xxx — только без AES-шифрования",
"device.ble.govee_key": "Ключ AES Govee (hex):",
"device.ble.govee_key.hint": "Необязательно. Новая прошивка Govee требует AES-ключ под конкретную модель — оставьте пустым для старой прошивки.",
"device.ble.govee_key.placeholder": "32 символа hex, напр. 0102…1f20",
"device.type.usbhid": "USB HID",
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
"device.type.spi": "SPI Direct",
+24
View File
@@ -31,6 +31,7 @@
"auth.button.cancel": "取消",
"auth.button.login": "登录",
"auth.error.required": "请输入 API 密钥",
"auth.error.invalid": "API 密钥无效,请重试。",
"auth.success": "登录成功!",
"auth.logout.confirm": "确定要退出登录吗?",
"auth.logout.success": "已成功退出",
@@ -40,6 +41,16 @@
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"api_key.login": "登录",
"setup.title": "服务器需要配置",
"setup.description": "此 LedGrab 服务器未配置 API 密钥,出于安全考虑已禁用来自网络其他设备的访问。请在运行服务器的机器上配置密钥以启用局域网访问。",
"setup.step1_label": "1. 在服务器所在机器上,编辑 <code>config/default_config.yaml</code>",
"setup.step2_label": "2. 重启服务器,然后刷新此页面并使用该密钥登录。",
"setup.step3_label": "或者:直接在服务器本机打开 LedGrab(回环地址),无需密钥:",
"setup.hint_openssl": "生成强密钥:Linux/macOS 使用 <code>openssl rand -hex 32</code>Windows PowerShell 使用 <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>。",
"setup.copy": "复制代码片段",
"setup.copied": "已复制到剪贴板",
"setup.retry": "我已配置密钥 — 重试",
"setup.still_required": "服务器仍报告未配置 API 密钥。请确认已保存配置文件并重启服务器。",
"displays.title": "可用显示器",
"displays.layout": "显示器",
"displays.information": "显示器信息",
@@ -172,6 +183,19 @@
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
"device.type.hue": "Philips Hue",
"device.type.hue.desc": "Hue Entertainment API streaming",
"device.type.ble": "BLE LED 控制器",
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
"device.ble.url": "BLE 地址:",
"device.ble.url.hint": "MAC 地址(Windows/Linux)或 UUIDmacOS),加前缀 ble://",
"device.ble.family": "协议:",
"device.ble.family.hint": "控制器使用哪种 BLE 协议。按你平时使用的手机应用选择。",
"device.ble.family.sp110e.desc": "可编址控制器 — LED Hue / SP110E 应用",
"device.ble.family.triones.desc": "单色控制器 — HappyLighting / LEDnet",
"device.ble.family.zengge.desc": "单色控制器 — iLightsIn / Mohuan",
"device.ble.family.govee.desc": "Govee H6xxx 灯带 — 仅支持未加密固件",
"device.ble.govee_key": "Govee AES 密钥(hex:",
"device.ble.govee_key.hint": "可选。新版 Govee 固件需要按型号的 AES 密钥 — 老固件留空即可。",
"device.ble.govee_key.placeholder": "32位十六进制,如 0102…1f20",
"device.type.usbhid": "USB HID",
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
"device.type.spi": "SPI Direct",
+80 -5
View File
@@ -231,17 +231,92 @@ class AssetStore(BaseSqliteStore[Asset]):
logger.info(f"Restored prebuilt asset: {asset.name} ({asset.id})")
return restored
def import_prebuilt_sounds(self, prebuilt_dir: Path) -> List[Asset]:
"""Import prebuilt sound files that don't already exist as assets.
def heal_prebuilt_files(self, prebuilt_dir: Path) -> List[Asset]:
"""Re-copy shipped files for prebuilt assets whose on-disk file is missing.
Called on startup. Skips files that are already imported (by original
filename match with prebuilt=True), including soft-deleted ones.
Covers the case where the database row still exists (``prebuilt=True``,
``deleted=False``) but ``stored_filename`` has been lost — e.g. after a
partial restore from backup, a data-dir move, or manual file deletion.
Contrast with :meth:`restore_prebuilt`, which only handles the
user-initiated soft-delete flow.
Returns list of newly imported assets.
Returns list of healed assets.
"""
if not prebuilt_dir.exists():
return []
healed: List[Asset] = []
for asset in self._items.values():
if not asset.prebuilt or asset.deleted:
continue
dest = self._assets_dir / asset.stored_filename
if dest.exists():
continue
src = prebuilt_dir / asset.filename
if not src.exists():
logger.warning(
"Prebuilt asset %r (%s) is missing on disk and its source "
"%s is also missing — cannot heal.",
asset.name,
asset.id,
src,
)
continue
shutil.copy2(src, dest)
asset.size_bytes = dest.stat().st_size
asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset.id, asset)
healed.append(asset)
logger.warning(
"Healed missing prebuilt asset %r (%s) -> %s",
asset.name,
asset.id,
asset.stored_filename,
)
return healed
def warn_missing_custom_files(self) -> List[Asset]:
"""Log a warning for each non-prebuilt asset whose file is missing.
Custom files cannot be auto-healed (there's no authoritative source),
but surfacing them on startup makes the state of the data visible
rather than only appearing as 404s when the UI tries to play them.
Returns the list of assets with missing files.
"""
missing: List[Asset] = []
for asset in self._items.values():
if asset.prebuilt or asset.deleted:
continue
if not (self._assets_dir / asset.stored_filename).exists():
missing.append(asset)
if missing:
logger.warning(
"%d custom asset file(s) missing on disk — the UI will return "
"404 when trying to load them. Re-upload or delete the "
"orphan records: %s",
len(missing),
", ".join(f"{a.name} ({a.id})" for a in missing),
)
return missing
def import_prebuilt_sounds(self, prebuilt_dir: Path) -> List[Asset]:
"""Import prebuilt sound files that don't already exist as assets.
Called on startup. Also heals prebuilt assets whose metadata row
exists but whose stored file is missing on disk, and logs a warning
for any custom assets in the same situation.
Returns list of newly imported assets (not healed or missing ones).
"""
if not prebuilt_dir.exists():
return []
# Heal first so a freshly-restored DB with missing prebuilt files
# recovers before the UI has a chance to request them.
self.heal_prebuilt_files(prebuilt_dir)
self.warn_missing_custom_files()
# Build set of known prebuilt filenames (including deleted ones)
known_filenames = {a.filename for a in self._items.values() if a.prebuilt}
+113 -1
View File
@@ -203,6 +203,7 @@
{% include 'modals/notification-history.html' %}
{% include 'modals/pattern-template.html' %}
{% include 'modals/api-key.html' %}
{% include 'modals/setup-required.html' %}
{% include 'modals/confirm.html' %}
{% include 'modals/add-device.html' %}
{% include 'modals/capture-template.html' %}
@@ -551,21 +552,132 @@
updateAuthUI();
}
function submitApiKey(event) {
// ─── Setup-required modal (shown when LAN client hits a server with
// no auth.api_keys configured — no key will ever work) ───
let _setupModalOpen = false;
function showSetupRequiredModal() {
const modal = document.getElementById('setup-required-modal');
if (!modal) return;
// Update loopback link to match the current port (default 8080)
try {
const link = document.getElementById('setup-loopback-link');
if (link) {
const port = location.port || '8080';
const href = `http://localhost:${port}`;
link.setAttribute('href', href);
link.textContent = href;
}
} catch { /* best-effort */ }
// Hide the api-key modal if it happened to be open
const apiModal = document.getElementById('api-key-modal');
if (apiModal) apiModal.style.display = 'none';
modal.style.display = 'flex';
_setupModalOpen = true;
lockBody();
// Tabs + login button make no sense while locked out
const tabBar = document.querySelector('.tab-bar');
if (tabBar) tabBar.style.display = 'none';
const loginBtn = document.getElementById('login-btn');
if (loginBtn) loginBtn.style.display = 'none';
}
function hideSetupRequiredModal() {
const modal = document.getElementById('setup-required-modal');
if (modal) modal.style.display = 'none';
_setupModalOpen = false;
unlockBody();
}
function copySetupSnippet() {
const pre = document.getElementById('setup-yaml-snippet');
if (!pre) return;
const text = pre.textContent || '';
const done = () => {
if (typeof showToast === 'function') {
showToast(t('setup.copied'), 'success');
}
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, done);
} else {
try {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
} finally {
done();
}
}
}
async function retrySetupCheck() {
// Re-query /health. If setup_required is now false, reload so the
// normal auth flow takes over.
try {
const resp = await fetch('/health', { signal: AbortSignal.timeout(5000) });
if (resp.ok) {
const data = await resp.json();
if (!data.setup_required) {
hideSetupRequiredModal();
location.reload();
return;
}
}
} catch { /* ignore — will stay on the setup modal */ }
if (typeof showToast === 'function') {
showToast(t('setup.still_required'), 'info');
}
}
async function submitApiKey(event) {
if (event) {
event.preventDefault();
}
const input = document.getElementById('api-key-input');
const error = document.getElementById('api-key-error');
const submitBtn = document.getElementById('api-key-submit');
const key = input.value.trim();
error.style.display = 'none';
if (!key) {
error.textContent = t('auth.error.required');
error.style.display = 'block';
return;
}
// Validate against server before accepting. Use a real auth-protected
// endpoint so wrong keys return 401. /api/v1/system/api-keys is
// cheap and requires AuthRequired.
if (submitBtn) submitBtn.disabled = true;
try {
const resp = await fetch('/api/v1/system/api-keys', {
headers: { 'Authorization': `Bearer ${key}` }
});
if (resp.status === 401) {
let msg = t('auth.error.invalid');
try {
const body = await resp.json();
if (body && body.detail) msg = body.detail;
} catch { /* ignore parse errors */ }
error.textContent = msg;
error.style.display = 'block';
return;
}
if (!resp.ok && resp.status !== 401) {
// Server reachable but non-auth error — accept the key anyway
}
} catch (e) {
// Network error — accept key; user may be on a slow connection
} finally {
if (submitBtn) submitBtn.disabled = false;
}
// Store the key
localStorage.setItem('ledgrab_api_key', key);
if (window.setApiKey) window.setApiKey(key);
@@ -36,6 +36,7 @@
<option value="dmx">DMX</option>
<option value="espnow">ESP-NOW</option>
<option value="hue">Philips Hue</option>
<option value="ble">BLE LED Controller</option>
<option value="usbhid">USB HID</option>
<option value="spi">SPI Direct</option>
<option value="chroma">Razer Chroma</option>
@@ -203,6 +204,30 @@
<small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small>
<input type="text" id="device-hue-group-id" placeholder="Entertainment group ID">
</div>
<!-- BLE LED Controller fields -->
<div class="form-group" id="device-ble-family-group" style="display: none;">
<div class="label-row">
<label for="device-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ble.family.hint">Which BLE protocol your controller speaks. Match the phone app you normally use.</small>
<select id="device-ble-family">
<option value="sp110e">SP110E / SP108E</option>
<option value="triones">Triones / HappyLighting / LEDnet</option>
<option value="zengge">Zengge / iLightsIn</option>
<option value="govee">Govee (experimental)</option>
</select>
</div>
<div class="form-group" id="device-ble-govee-key-group" style="display: none;">
<div class="label-row">
<label for="device-ble-govee-key" data-i18n="device.ble.govee_key">Govee AES Key (hex):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ble.govee_key.hint">Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.</small>
<input type="text" id="device-ble-govee-key"
data-i18n-placeholder="device.ble.govee_key.placeholder"
placeholder="32 hex digits, e.g. 0102…1f20">
</div>
<!-- SPI Direct fields -->
<div class="form-group" id="device-spi-speed-group" style="display: none;">
<div class="label-row">
@@ -33,7 +33,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-icon btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button type="submit" class="btn btn-icon btn-primary" data-i18n-title="api_key.login" title="Login" data-i18n-aria-label="aria.save">&#x2713;</button>
<button type="submit" id="api-key-submit" class="btn btn-icon btn-primary" data-i18n-title="api_key.login" title="Login" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</form>
</div>
@@ -0,0 +1,47 @@
<!-- Setup Required Modal (shown when LAN client hits a server with no API keys configured) -->
<div id="setup-required-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="setup-required-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="setup-required-title">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12"/><circle cx="17" cy="7" r="5"/></svg>
<span data-i18n="setup.title">Server setup required</span>
</h2>
</div>
<div class="modal-body">
<p class="modal-description" data-i18n="setup.description">
This LedGrab server has no API keys configured, so access from other devices on the network is disabled for security. Configure a key on the machine running the server to enable LAN access.
</p>
<div class="form-group">
<label data-i18n="setup.step1_label">1. On the server machine, edit <code>config/default_config.yaml</code>:</label>
<div class="code-snippet-wrapper">
<pre id="setup-yaml-snippet" class="code-snippet"><code>auth:
api_keys:
dev: "REPLACE_WITH_A_LONG_RANDOM_SECRET"</code></pre>
<button type="button" class="btn btn-icon btn-secondary copy-btn" onclick="copySetupSnippet()" data-i18n-title="setup.copy" title="Copy">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</button>
</div>
<small class="input-hint" data-i18n="setup.hint_openssl">
Generate a strong key on Linux/macOS with <code>openssl rand -hex 32</code>, or on Windows PowerShell with <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.
</small>
</div>
<div class="form-group">
<label data-i18n="setup.step2_label">2. Restart the server, then reload this page and log in with that key.</label>
</div>
<div class="form-group">
<label data-i18n="setup.step3_label">Alternative: open LedGrab from the server machine itself (loopback), no key required:</label>
<div class="code-snippet-wrapper">
<a id="setup-loopback-link" class="btn btn-secondary" href="http://localhost:8080" target="_blank" rel="noopener">http://localhost:8080</a>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-icon btn-secondary" onclick="retrySetupCheck()" data-i18n-title="setup.retry" title="Retry">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
</button>
</div>
</div>
</div>
+155
View File
@@ -0,0 +1,155 @@
"""Tests for AssetStore — focusing on self-heal behavior on startup.
Regression guard for the "restored backup loses notification sounds" class
of bug: when the SQLite DB is restored from elsewhere but the ``assets/``
directory hasn't moved with it, prebuilt sound files end up with stale
DB rows pointing at missing files. ``heal_prebuilt_files`` is the safety
net.
"""
import pytest
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database
@pytest.fixture
def store_with_prebuilt(tmp_path):
"""AssetStore seeded with the shipped prebuilt sounds."""
db = Database(tmp_path / "test.db")
assets_dir = tmp_path / "assets"
prebuilt_dir = tmp_path / "prebuilt"
prebuilt_dir.mkdir()
# Minimal valid WAV-ish bytes — content doesn't matter for these tests,
# only that the file exists and is copied around correctly.
for name in ("alert.wav", "bell.wav"):
(prebuilt_dir / name).write_bytes(b"RIFF\x00\x00\x00\x00WAVEfmt " + name.encode())
store = AssetStore(db, assets_dir)
imported = store.import_prebuilt_sounds(prebuilt_dir)
assert len(imported) == 2
yield store, prebuilt_dir, assets_dir
db.close()
class TestHealPrebuiltFiles:
def test_heals_missing_stored_file(self, store_with_prebuilt):
store, prebuilt_dir, assets_dir = store_with_prebuilt
alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav")
# Simulate a partial restore: DB row survived, file vanished.
(assets_dir / alert.stored_filename).unlink()
assert store.get_file_path(alert.id) is None
healed = store.heal_prebuilt_files(prebuilt_dir)
assert [a.id for a in healed] == [alert.id]
restored_path = assets_dir / alert.stored_filename
assert restored_path.exists()
# File content round-trips from the prebuilt source.
assert restored_path.read_bytes() == (prebuilt_dir / "alert.wav").read_bytes()
# Size metadata is updated to match the re-copied file.
assert store.get_asset(alert.id).size_bytes == restored_path.stat().st_size
def test_idempotent_when_files_present(self, store_with_prebuilt):
store, prebuilt_dir, _ = store_with_prebuilt
first = store.heal_prebuilt_files(prebuilt_dir)
second = store.heal_prebuilt_files(prebuilt_dir)
assert first == [] and second == []
def test_skips_soft_deleted_prebuilt(self, store_with_prebuilt):
store, prebuilt_dir, assets_dir = store_with_prebuilt
alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav")
store.delete_asset(alert.id) # prebuilt → soft-delete; file removed
assert store.get_asset(alert.id).deleted is True
healed = store.heal_prebuilt_files(prebuilt_dir)
# Heal must not resurrect a soft-deleted asset — that is
# `restore_prebuilt`'s job and is an explicit user action.
assert healed == []
assert not (assets_dir / alert.stored_filename).exists()
def test_returns_empty_when_source_also_missing(self, store_with_prebuilt):
"""If both the stored file and the prebuilt source are gone, the
heal is a no-op — the asset stays broken and the method does not
raise (so startup is not aborted)."""
store, prebuilt_dir, assets_dir = store_with_prebuilt
alert = next(a for a in store.get_all_assets() if a.filename == "alert.wav")
(assets_dir / alert.stored_filename).unlink()
(prebuilt_dir / "alert.wav").unlink()
healed = store.heal_prebuilt_files(prebuilt_dir)
assert healed == []
# Unrelated assets still heal correctly.
bell = next(a for a in store.get_all_assets() if a.filename == "bell.wav")
(assets_dir / bell.stored_filename).unlink()
healed = store.heal_prebuilt_files(prebuilt_dir)
assert [a.id for a in healed] == [bell.id]
def test_import_prebuilt_sounds_runs_heal(self, tmp_path):
"""End-to-end: restoring a DB and re-running startup heals missing files."""
# First run: import both sounds normally.
db = Database(tmp_path / "first.db")
assets_dir = tmp_path / "assets"
prebuilt_dir = tmp_path / "prebuilt"
prebuilt_dir.mkdir()
(prebuilt_dir / "alert.wav").write_bytes(b"hello-alert")
store = AssetStore(db, assets_dir)
imported = store.import_prebuilt_sounds(prebuilt_dir)
assert len(imported) == 1
alert_stored = imported[0].stored_filename
db.close()
# Simulate partial restore: file gone, DB row preserved.
(assets_dir / alert_stored).unlink()
# Second run: reopen the same DB + assets dir.
db2 = Database(tmp_path / "first.db")
store2 = AssetStore(db2, assets_dir)
store2.import_prebuilt_sounds(prebuilt_dir)
assert (assets_dir / alert_stored).exists()
db2.close()
class TestWarnMissingCustomFiles:
def test_reports_custom_asset_with_missing_file(self, tmp_path):
db = Database(tmp_path / "test.db")
assets_dir = tmp_path / "assets"
store = AssetStore(db, assets_dir)
asset = store.create_asset(
name="User Sound",
filename="user.wav",
file_data=b"RIFF\x00\x00\x00\x00WAVEfmt user",
)
(assets_dir / asset.stored_filename).unlink()
missing = store.warn_missing_custom_files()
assert [a.id for a in missing] == [asset.id]
db.close()
def test_quiet_when_all_custom_files_present(self, tmp_path):
db = Database(tmp_path / "test.db")
store = AssetStore(db, tmp_path / "assets")
store.create_asset(name="User", filename="user.wav", file_data=b"data123")
assert store.warn_missing_custom_files() == []
db.close()
def test_ignores_prebuilt_and_soft_deleted(self, tmp_path):
db = Database(tmp_path / "test.db")
assets_dir = tmp_path / "assets"
prebuilt_dir = tmp_path / "prebuilt"
prebuilt_dir.mkdir()
(prebuilt_dir / "alert.wav").write_bytes(b"prebuilt-alert")
store = AssetStore(db, assets_dir)
store.import_prebuilt_sounds(prebuilt_dir)
prebuilt_asset = store.get_all_assets()[0]
(assets_dir / prebuilt_asset.stored_filename).unlink()
# Missing prebuilt files are NOT reported here (that's heal's job).
assert store.warn_missing_custom_files() == []
db.close()
+213
View File
@@ -0,0 +1,213 @@
"""Tests for BLEClient using a fake transport — no bleak, no hardware."""
from typing import List
import numpy as np
import pytest
from ledgrab.core.devices import ble_client as ble_client_module
from ledgrab.core.devices.ble_client import (
BLEClient,
_average_color,
_encrypt_govee_frame,
_strip_ble_scheme,
)
class FakeTransport:
"""In-memory stand-in for ``BLETransport`` — records every write."""
def __init__(self, *_, **__):
self.writes: List[bytes] = []
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected
async def connect(self) -> None:
self._connected = True
async def close(self) -> None:
self._connected = False
async def write(self, data: bytes) -> None:
if not self._connected:
raise RuntimeError("fake transport not connected")
self.writes.append(data)
@pytest.fixture
def fake_transport_cls(monkeypatch):
monkeypatch.setattr(ble_client_module, "make_transport", lambda *_, **__: FakeTransport())
# Disable the inter-write delay so tests don't sleep.
monkeypatch.setattr(ble_client_module, "_MIN_WRITE_INTERVAL_SEC", 0.0)
return FakeTransport
class TestStripBleScheme:
def test_strips_ble_prefix(self):
assert _strip_ble_scheme("ble://AA:BB:CC:DD:EE:FF") == "AA:BB:CC:DD:EE:FF"
def test_leaves_bare_address_alone(self):
assert _strip_ble_scheme("AA:BB:CC:DD:EE:FF") == "AA:BB:CC:DD:EE:FF"
def test_strips_trailing_slashes(self):
assert _strip_ble_scheme("ble://abc/") == "abc"
class TestAverageColor:
def test_returns_black_on_empty_list(self):
assert _average_color([]) == (0, 0, 0)
def test_averages_list_of_tuples(self):
assert _average_color([(255, 0, 0), (0, 255, 0), (0, 0, 255)]) == (85, 85, 85)
def test_returns_black_on_empty_array(self):
assert _average_color(np.empty((0, 3), dtype=np.uint8)) == (0, 0, 0)
def test_averages_numpy_array(self):
arr = np.array([[100, 100, 100], [200, 200, 200]], dtype=np.uint8)
assert _average_color(arr) == (150, 150, 150)
class TestBLEClientLifecycle:
@pytest.mark.asyncio
async def test_connect_sets_connected_flag(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
assert not client.is_connected
await client.connect()
assert client.is_connected
@pytest.mark.asyncio
async def test_close_does_not_send_power_off(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
await client.connect()
await client.close()
# Strip is left in whatever state it's in — rapid power toggles on
# connect/close cause BLE stack hangs on Windows.
assert bytes((0, 0, 0, 0, 0xAB)) not in client._transport.writes
@pytest.mark.asyncio
async def test_unknown_family_raises(self):
with pytest.raises(ValueError, match="Unknown BLE family"):
BLEClient("ble://AA:BB:CC", ble_family="not-real")
class TestBLEClientSendPixels:
@pytest.mark.asyncio
async def test_send_averages_and_writes_one_frame(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
await client.connect()
ok = await client.send_pixels([(255, 0, 0), (0, 0, 0)], brightness=255)
assert ok
assert len(client._transport.writes) == 1
frame = client._transport.writes[0]
# Averaged to (127, 0, 0), SP110E trailer 00 1E
assert frame == bytes((127, 0, 0, 0, 0x1E))
@pytest.mark.asyncio
async def test_duplicate_frames_are_dropped(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
await client.connect()
await client.send_pixels([(10, 20, 30)])
await client.send_pixels([(10, 20, 30)])
await client.send_pixels([(10, 20, 30)])
assert len(client._transport.writes) == 1
@pytest.mark.asyncio
async def test_send_returns_false_when_disconnected(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
# No connect() call.
ok = await client.send_pixels([(1, 2, 3)])
assert ok is False
@pytest.mark.asyncio
async def test_triones_frame_format(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="triones")
await client.connect()
await client.send_pixels([(100, 150, 200)])
frame = client._transport.writes[0]
assert frame == bytes((0x7E, 0x07, 0x05, 0x03, 100, 150, 200, 0x10, 0xEF))
class TestBLEClientHealth:
@pytest.mark.asyncio
async def test_health_preserves_previous_state(self):
from ledgrab.core.devices.led_client import DeviceHealth
prev = DeviceHealth(online=True, device_name="Test", device_led_count=60)
result = await BLEClient.check_health("ble://AA:BB:CC", http_client=None, prev_health=prev)
assert result.online is True
assert result.device_name == "Test"
assert result.device_led_count == 60
@pytest.mark.asyncio
async def test_health_defaults_offline_when_no_prev(self):
result = await BLEClient.check_health("ble://AA:BB:CC", http_client=None)
assert result.online is False
assert result.device_name == "AA:BB:CC"
class TestGoveeAESEncryption:
_KEY = bytes.fromhex("74657374746573747465737474657374") # "testtesttesttest"
def test_encrypt_produces_32_bytes(self):
from ledgrab.core.devices.ble_protocols.govee import encode_color
frame = encode_color(255, 0, 0)
encrypted = _encrypt_govee_frame(frame, self._KEY)
assert len(encrypted) == 32
def test_encrypt_is_deterministic(self):
frame = bytes(range(20))
assert _encrypt_govee_frame(frame, self._KEY) == _encrypt_govee_frame(frame, self._KEY)
def test_encrypt_differs_from_plaintext(self):
frame = bytes(range(20))
assert _encrypt_govee_frame(frame, self._KEY) != frame + b"\x00" * 12
def test_decrypt_roundtrip(self):
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
frame = bytes(range(20))
encrypted = _encrypt_govee_frame(frame, self._KEY)
cipher = Cipher(algorithms.AES(self._KEY), modes.ECB())
dec = cipher.decryptor()
decrypted = dec.update(encrypted) + dec.finalize()
assert decrypted[:20] == frame
@pytest.mark.asyncio
async def test_govee_client_encrypts_frames(self, fake_transport_cls):
key_hex = self._KEY.hex()
client = BLEClient("ble://AA:BB:CC", ble_family="govee", ble_govee_key=key_hex)
await client.connect()
await client.send_pixels([(255, 0, 0)])
assert len(client._transport.writes) == 1
# Encrypted frame is 32 bytes, not the raw 20.
assert len(client._transport.writes[0]) == 32
@pytest.mark.asyncio
async def test_govee_client_without_key_sends_plaintext(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="govee")
await client.connect()
await client.send_pixels([(255, 0, 0)])
assert len(client._transport.writes[0]) == 20
@pytest.mark.asyncio
async def test_invalid_key_hex_falls_back_gracefully(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="govee", ble_govee_key="not-hex!")
await client.connect()
ok = await client.send_pixels([(100, 100, 100)])
assert ok is True
assert len(client._transport.writes[0]) == 20 # plaintext fallback
@pytest.mark.asyncio
async def test_govee_key_ignored_for_non_govee_family(self, fake_transport_cls):
key_hex = self._KEY.hex()
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e", ble_govee_key=key_hex)
assert client._aes_key is None
await client.connect()
await client.send_pixels([(100, 100, 100)])
# SP110E frame is 5 bytes, not encrypted.
assert len(client._transport.writes[0]) == 5
+130
View File
@@ -0,0 +1,130 @@
"""Unit tests for BLE LED controller wire protocols.
These tests exercise the pure byte-encoding functions for each family,
so they run without ``bleak`` installed and without any real BLE hardware.
"""
import pytest
from ledgrab.core.devices.ble_protocols import (
all_protocols,
get_protocol,
identify_family,
govee,
sp110e,
triones,
zengge,
)
class TestSP110E:
def test_color_frame_is_five_bytes_with_cmd_tail(self):
frame = sp110e.encode_color(255, 128, 64)
assert frame == bytes((255, 128, 64, 0x00, 0x1E))
def test_brightness_scales_rgb(self):
frame = sp110e.encode_color(200, 200, 200, brightness=128)
# 200 * 128 // 255 == 100
assert frame == bytes((100, 100, 100, 0x00, 0x1E))
def test_brightness_255_is_passthrough(self):
assert sp110e.encode_color(1, 2, 3, 255) == bytes((1, 2, 3, 0x00, 0x1E))
def test_clamps_out_of_range(self):
assert sp110e.encode_color(-5, 300, 128) == bytes((0, 255, 128, 0x00, 0x1E))
def test_power_frames(self):
assert sp110e.encode_power(True) == bytes((0, 0, 0, 0, 0xAA))
assert sp110e.encode_power(False) == bytes((0, 0, 0, 0, 0xAB))
class TestTriones:
def test_color_frame_matches_documented_template(self):
# 7E 07 05 03 RR GG BB 10 EF
frame = triones.encode_color(0xAA, 0xBB, 0xCC)
assert frame == bytes((0x7E, 0x07, 0x05, 0x03, 0xAA, 0xBB, 0xCC, 0x10, 0xEF))
def test_frame_is_nine_bytes(self):
assert len(triones.encode_color(10, 20, 30)) == 9
def test_brightness_scales(self):
frame = triones.encode_color(255, 255, 255, brightness=51) # 20%
# 255 * 51 // 255 == 51
assert frame[4:7] == bytes((51, 51, 51))
def test_power_on_off_distinct(self):
on = triones.encode_power(True)
off = triones.encode_power(False)
assert on != off
assert on[0] == 0x7E and off[0] == 0x7E
assert on[-1] == 0xEF and off[-1] == 0xEF
class TestZengge:
def test_color_frame_format(self):
# 56 RR GG BB 00 F0 AA
frame = zengge.encode_color(0x11, 0x22, 0x33)
assert frame == bytes((0x56, 0x11, 0x22, 0x33, 0x00, 0xF0, 0xAA))
def test_frame_is_seven_bytes(self):
assert len(zengge.encode_color(1, 2, 3)) == 7
def test_power_frames(self):
assert zengge.encode_power(True) == bytes((0xCC, 0x23, 0x33))
assert zengge.encode_power(False) == bytes((0xCC, 0x24, 0x33))
class TestGovee:
def test_color_frame_is_twenty_bytes_with_checksum(self):
frame = govee.encode_color(255, 0, 0)
assert len(frame) == 20
# Verify XOR checksum
expected_checksum = 0
for i in range(19):
expected_checksum ^= frame[i]
assert frame[19] == expected_checksum
def test_color_frame_header(self):
# 33 05 02 RR GG BB ...
frame = govee.encode_color(0x10, 0x20, 0x30)
assert frame[0] == 0x33
assert frame[1] == 0x05
assert frame[2] == 0x02
assert frame[3:6] == bytes((0x10, 0x20, 0x30))
def test_brightness_scales(self):
frame = govee.encode_color(100, 100, 100, brightness=0)
assert frame[3:6] == bytes((0, 0, 0))
def test_power_frames_have_valid_checksum(self):
for state in (True, False):
frame = govee.encode_power(state)
assert len(frame) == 20
checksum = 0
for i in range(19):
checksum ^= frame[i]
assert frame[19] == checksum
class TestRegistry:
def test_all_four_families_registered(self):
families = set(all_protocols().keys())
assert families == {"sp110e", "triones", "zengge", "govee"}
def test_get_protocol_raises_on_unknown(self):
with pytest.raises(ValueError, match="Unknown BLE family"):
get_protocol("not-a-real-family")
def test_get_protocol_returns_correct_family(self):
assert get_protocol("sp110e").family == "sp110e"
assert get_protocol("triones").family == "triones"
def test_identify_family_by_name(self):
assert identify_family("SP110E_A1B2C3") == "sp110e"
assert identify_family("Triones-ABCDEF") == "triones"
assert identify_family("Zengge-123456") == "zengge"
assert identify_family("ihoment_H6008_xxxx") == "govee"
def test_identify_family_returns_none_for_unknown(self):
assert identify_family("SomeRandomDevice") is None
assert identify_family("") is None
+24 -3
View File
@@ -1,5 +1,7 @@
"""Tests for configuration management."""
from pathlib import Path
import pytest
import yaml
@@ -18,10 +20,23 @@ class TestDefaultConfig:
assert config.server.port == 8080
assert config.server.log_level == "INFO"
def test_default_storage_paths(self):
def test_default_storage_paths(self, monkeypatch):
monkeypatch.delenv("LEDGRAB_DATA_DIR", raising=False)
config = Config()
assert config.storage.database_file == "data/ledgrab.db"
def test_data_dir_env_override(self, monkeypatch, tmp_path):
monkeypatch.setenv("LEDGRAB_DATA_DIR", str(tmp_path / "custom"))
# default_data_dir reads the env var, but the module-level default
# was evaluated at import time — so re-import paths() value via the
# helper to confirm the contract.
from importlib import reload
from ledgrab import paths as paths_mod
reload(paths_mod)
assert paths_mod.default_data_dir() == Path(str(tmp_path / "custom"))
def test_default_mqtt_disabled(self):
config = Config()
assert config.mqtt.enabled is False
@@ -71,9 +86,15 @@ class TestServerConfig:
class TestDemoMode:
def test_demo_rewrites_storage_paths(self):
config = Config(demo=True)
assert config.storage.database_file.startswith("data/demo/")
db_path = Path(config.storage.database_file)
assert db_path.parent.name == "demo"
assert db_path.name == "ledgrab.db"
assets_path = Path(config.assets.assets_dir)
assert assets_path.parent.name == "demo"
assert assets_path.name == "assets"
def test_non_demo_keeps_original_paths(self):
def test_non_demo_keeps_original_paths(self, monkeypatch):
monkeypatch.delenv("LEDGRAB_DATA_DIR", raising=False)
config = Config(demo=False)
assert config.storage.database_file == "data/ledgrab.db"