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