diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4734561..542aef3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2904181..0e19c63 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,25 @@ - + + + + + + + + + + + @@ -30,9 +50,10 @@ diff --git a/android/app/src/main/java/com/ledgrab/android/BleBridge.kt b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt new file mode 100644 index 0000000..71aa5de --- /dev/null +++ b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt @@ -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() + + // Write completion futures, keyed by handle. Only populated for + // WRITE_TYPE_DEFAULT (with-response) writes. + private val pendingWrites = ConcurrentHashMap>() + + /** 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 { + 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()) + 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() + + 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() + 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) + } +} diff --git a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt index ea12c06..2a5a8e5 100644 --- a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt +++ b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt @@ -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() } diff --git a/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt b/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt index e615a4e..ee543b0 100644 --- a/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt +++ b/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt @@ -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-.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" } } diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt index 188ab68..fa81d8a 100644 --- a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt +++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt @@ -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, + ) + } + } + } } diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..6823bd8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f42375d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..3ee993b --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,11 @@ + + + LedGrab + Фоновая подсветка для телевизора + Начать захват + Стоп + Работает + Адрес веб-интерфейса + Сканируйте для настройки + QR-код для веб-интерфейса + diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..be004f6 --- /dev/null +++ b/android/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,11 @@ + + + LedGrab + 电视氛围灯光 + 开始捕获 + 停止 + 运行中 + Web界面地址 + 扫码配置 + Web界面二维码 + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..07a18cf --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/contexts/server-operations.md b/contexts/server-operations.md index 009339d..f632f63 100644 --- a/contexts/server-operations.md +++ b/contexts/server-operations.md @@ -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 -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 ` / `-Module ` — override the target (default: 8080 / `ledgrab`). +- `-StartupTimeoutSec ` — how long to wait for the new server to bind the port (default: 30). +- `-ShutdownTimeoutSec ` — 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`) diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index 4e88006..7f720d0 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -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 diff --git a/server/pyproject.toml b/server/pyproject.toml index d3e6dc1..f0d21f5 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -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" diff --git a/server/restart.ps1 b/server/restart.ps1 index 55cde4b..7c3c418 100644 --- a/server/restart.ps1 +++ b/server/restart.ps1 @@ -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 ` or an exact token + $launchesModule = $cl -match ('-m\s+' + [regex]::Escape($ModuleName) + '(\s|$|\.)') + if (-not $launchesModule) { return $false } + # Exclude obvious tooling false-positives + if ($cl -match '(vscode|isort|pip[-\s]|flake8|ruff|mypy|pylint|black)') { + return $false + } + return $true } - if ($inKeys -and $line -match '^\S') { break } # left the api_keys block +} + +function Test-PortOpen { + param([int]$Port) + try { + $listener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop + return [bool]$listener + } catch { + return $false } } -# Find running server processes -$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | - Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' } +$existing = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot -if ($procs) { - # Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save) - $shutdownOk = $false - if ($apiKey) { - Write-Host "Requesting graceful shutdown..." - try { - $headers = @{ Authorization = "Bearer $apiKey" } - Invoke-RestMethod -Uri 'http://localhost:8080/api/v1/system/shutdown' ` - -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null - $shutdownOk = $true - } catch { - Write-Host " API shutdown failed ($($_.Exception.Message)), falling back to process kill" +# ---- Graceful shutdown (if the target is currently up) --------------------- + +if ($existing) { + $apiKey = $null + if ($resolvedConfig) { + # Pull the first api_keys entry — good enough for the local shutdown + # endpoint; production deploys don't use this script. + $inKeys = $false + foreach ($line in Get-Content $resolvedConfig) { + if ($line -match '^\s*api_keys:') { $inKeys = $true; continue } + if ($inKeys -and $line -match '^\s+\w+:\s*"(.+)"') { + $apiKey = $Matches[1]; break + } + if ($inKeys -and $line -match '^\S') { break } } } - if ($shutdownOk) { - # Step 2: Wait for the server to exit gracefully (up to 15 seconds) - # The server needs time to stop processors, disconnect devices, and persist stores. - Write-Host "Waiting for graceful shutdown..." + $shutdownRequested = $false + if ($apiKey) { + Write-Info 'Requesting graceful shutdown...' + try { + $headers = @{ Authorization = "Bearer $apiKey" } + Invoke-RestMethod -Uri "http://localhost:$Port/api/v1/system/shutdown" ` + -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null + $shutdownRequested = $true + } catch { + Write-Info " API shutdown failed ($($_.Exception.Message)); will force-kill" + } + } + + if ($shutdownRequested) { + Write-Info 'Waiting for graceful shutdown...' $waited = 0 - while ($waited -lt 15) { + while ($waited -lt $ShutdownTimeoutSec) { Start-Sleep -Seconds 1 $waited++ - $still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | - Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' } - if (-not $still) { - Write-Host " Server exited cleanly after ${waited}s" + if (-not (Get-ServerProcesses -ModuleName $Module -Root $ServerRoot)) { + Write-Info " Exited cleanly after ${waited}s" break } } - # Step 3: Force-kill stragglers - $still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" | - Where-Object { $_.CommandLine -like '*ledgrab*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' } - if ($still) { - Write-Host " Force-killing remaining processes..." - foreach ($p in $still) { - Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue - } - Start-Sleep -Seconds 1 - } - } else { - # No API key or API call failed — force-kill directly - foreach ($p in $procs) { - Write-Host "Stopping server (PID $($p.ProcessId))..." + } + + $still = Get-ServerProcesses -ModuleName $Module -Root $ServerRoot + if ($still) { + Write-Info ' Force-killing remaining processes...' + foreach ($p in $still) { + Write-Info " Stop PID $($p.ProcessId)" Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } - Start-Sleep -Seconds 2 + } + + # Wait for Windows to release the TCP socket before we rebind. A fixed + # 1–2 s sleep isn't enough on machines where the kernel lingers in + # CLOSE_WAIT; poll the port state instead. + $portDeadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $portDeadline -and (Test-PortOpen -Port $Port)) { + Start-Sleep -Milliseconds 250 } } -# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible +# ---- Merge per-user PATH (captures tools installed after the shell started) ---- + $regUser = [Environment]::GetEnvironmentVariable('PATH', 'User') if ($regUser) { $currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') } @@ -83,25 +212,132 @@ if ($regUser) { } } -# Start server detached (set WLED_RESTART=1 to skip browser open) -Write-Host "Starting server..." -$env:LEDGRAB_RESTART = "1" -$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source -if (-not $pythonExe) { - # Fallback to known install location - $pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe" +# ---- Locate a Python interpreter ------------------------------------------- + +# We need the Python that actually has the target module installed. Naively +# resolving `python` on PATH can pick up 3.11 or another version that doesn't +# have `ledgrab` in its site-packages, so prefer an explicit interpreter in +# this priority order: +# 1. -PythonExe (caller override) +# 2. `py -` via the Windows Python launcher +# 3. A Python 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 -` + $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 diff --git a/server/src/ledgrab/android_entry.py b/server/src/ledgrab/android_entry.py index ce6d17c..fab1d14 100644 --- a/server/src/ledgrab/android_entry.py +++ b/server/src/ledgrab/android_entry.py @@ -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 diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 3d33bb4..51dc5db 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -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, ) diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index e55f761..fffcaeb 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -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, ) diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index e1d460c..db25b0a 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -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" ) diff --git a/server/src/ledgrab/api/schemas/system.py b/server/src/ledgrab/api/schemas/system.py index f373236..703fe32 100644 --- a/server/src/ledgrab/api/schemas/system.py +++ b/server/src/ledgrab/api/schemas/system.py @@ -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") diff --git a/server/src/ledgrab/config.py b/server/src/ledgrab/config.py index 78b3c84..bd0e548 100644 --- a/server/src/ledgrab/config.py +++ b/server/src/ledgrab/config.py @@ -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 + ``/ledgrab.db`` becomes ``/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": diff --git a/server/src/ledgrab/core/devices/android_ble_transport.py b/server/src/ledgrab/core/devices/android_ble_transport.py new file mode 100644 index 0000000..e971b1b --- /dev/null +++ b/server/src/ledgrab/core/devices/android_ble_transport.py @@ -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") diff --git a/server/src/ledgrab/core/devices/ble_client.py b/server/src/ledgrab/core/devices/ble_client.py new file mode 100644 index 0000000..d068850 --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_client.py @@ -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://
`` where ``
`` 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://
`` 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://
`` 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, + ) diff --git a/server/src/ledgrab/core/devices/ble_protocols/__init__.py b/server/src/ledgrab/core/devices/ble_protocols/__init__.py new file mode 100644 index 0000000..e827fd6 --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_protocols/__init__.py @@ -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() diff --git a/server/src/ledgrab/core/devices/ble_protocols/govee.py b/server/src/ledgrab/core/devices/ble_protocols/govee.py new file mode 100644 index 0000000..836dd9c --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_protocols/govee.py @@ -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"), +) diff --git a/server/src/ledgrab/core/devices/ble_protocols/sp110e.py b/server/src/ledgrab/core/devices/ble_protocols/sp110e.py new file mode 100644 index 0000000..bddb395 --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_protocols/sp110e.py @@ -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"), +) diff --git a/server/src/ledgrab/core/devices/ble_protocols/triones.py b/server/src/ledgrab/core/devices/ble_protocols/triones.py new file mode 100644 index 0000000..d524faf --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_protocols/triones.py @@ -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"), +) diff --git a/server/src/ledgrab/core/devices/ble_protocols/zengge.py b/server/src/ledgrab/core/devices/ble_protocols/zengge.py new file mode 100644 index 0000000..d41c937 --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_protocols/zengge.py @@ -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"), +) diff --git a/server/src/ledgrab/core/devices/ble_provider.py b/server/src/ledgrab/core/devices/ble_provider.py new file mode 100644 index 0000000..92f0d84 --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_provider.py @@ -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://
``. 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://
'") + 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 diff --git a/server/src/ledgrab/core/devices/ble_transport.py b/server/src/ledgrab/core/devices/ble_transport.py new file mode 100644 index 0000000..60bee1c --- /dev/null +++ b/server/src/ledgrab/core/devices/ble_transport.py @@ -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, + ) diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index e04f084..3fd2805 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -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): diff --git a/server/src/ledgrab/core/processing/value_stream.py b/server/src/ledgrab/core/processing/value_stream.py index decb9ec..f33b2f3 100644 --- a/server/src/ledgrab/core/processing/value_stream.py +++ b/server/src/ledgrab/core/processing/value_stream.py @@ -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 diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index 0895f94..bced30b 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -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 ``/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) diff --git a/server/src/ledgrab/paths.py b/server/src/ledgrab/paths.py new file mode 100644 index 0000000..8b64505 --- /dev/null +++ b/server/src/ledgrab/paths.py @@ -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", + ] diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index bfe36e8..9492744 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -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; +} + + diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 8e4adda..f92dd60 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -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(() => { diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index fe62f95..8c74ac4 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -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; diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index 7734209..7c14bac 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -38,6 +38,7 @@ export const star = ''; export const camera = ''; export const bellRing = ''; +export const bluetooth = ''; export const wrench = ''; export const music = ''; export const search = ''; diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index 79d9171..e054ea8 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -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); diff --git a/server/src/ledgrab/static/js/core/state.ts b/server/src/ledgrab/static/js/core/state.ts index f058e6f..c901e4d 100644 --- a/server/src/ledgrab/static/js/core/state.ts +++ b/server/src/ledgrab/static/js/core/state.ts @@ -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 | null = null; export function setRefreshInterval(v: ReturnType | null) { refreshInterval = v; } diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index 8954865..08df03f 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -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://
), 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