diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cfec31a..4734561 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -45,7 +45,19 @@ android { buildTypes { release { + // TODO(minify): keep R8 disabled until Chaquopy reflection is + // verified end-to-end. Chaquopy resolves Kotlin classes & static + // methods (PythonBridge, UsbSerialBridge, Root) by name from + // Python via PyObject — silent stripping breaks the app at + // runtime, after release. proguard-rules.pro contains keep + // rules covering the known entry points, but until we have + // a release smoke test that exercises every PyObject path we + // do NOT ship a minified release. isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) signingConfig = if (hasCiSigning) { signingConfigs.getByName("release") } else { @@ -97,6 +109,7 @@ chaquopy { // and falls back to numpy/Pillow alternatives on Android. install("Pillow") install("websockets") + install("cryptography") // AES-GCM secret-box for HA/MQTT credentials } } } @@ -114,6 +127,8 @@ dependencies { implementation("androidx.leanback:leanback:1.0.0") implementation("com.google.android.material:material:1.12.0") implementation("androidx.lifecycle:lifecycle-service:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") // QR code generation for displaying server URL on TV implementation("com.google.zxing:core:3.5.3") // USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..2c49eda --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,27 @@ +# LedGrab ProGuard / R8 rules. +# +# IMPORTANT: Chaquopy resolves Java/Kotlin classes and static methods by +# name from Python (e.g. UsbSerialBridge.INSTANCE.listDevices()) via +# reflection. Anything reachable through PyObject must be kept by name +# or the release build will throw NoSuchMethod / ClassNotFound at +# runtime — silently, only on the user's device. +# +# Keep ALL of com.ledgrab.android.* members for safety. The app is +# small enough that the size win from stripping these isn't worth the +# fragility. +-keep class com.ledgrab.android.** { *; } + +# Chaquopy runtime itself. +-keep class com.chaquo.python.** { *; } +-dontwarn com.chaquo.python.** + +# usb-serial-for-android — driver classes are loaded via the prober's +# default device-id list, which uses reflection in some chip drivers. +-keep class com.hoho.android.usbserial.driver.** { *; } +-dontwarn com.hoho.android.usbserial.** + +# Kotlin coroutines — keep the debug agent off and the metadata intact. +-dontwarn kotlinx.coroutines.** + +# Standard Android best-practice keeps. +-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation* diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 485b4bd..2904181 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,7 @@ + val newBridge = PythonBridge(this).also { b -> b.configureRootCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT) b.startServer(SERVER_PORT) } + bridge = newBridge val pipeline = RootScreenrecord( - bridge = bridge!!, + bridge = newBridge, width = CAPTURE_WIDTH, height = CAPTURE_HEIGHT, fps = CAPTURE_FPS, @@ -130,28 +131,29 @@ class CaptureService : Service() { val projectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - mediaProjection = projectionManager.getMediaProjection(resultCode, resultData) - - if (mediaProjection == null) { + val projection = projectionManager.getMediaProjection(resultCode, resultData) + if (projection == null) { Log.e(TAG, "Failed to create MediaProjection") stopSelf() return } + mediaProjection = projection val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager val metrics = DisplayMetrics() @Suppress("DEPRECATION") windowManager.defaultDisplay.getRealMetrics(metrics) - bridge = PythonBridge(this).also { b -> + val newBridge = PythonBridge(this).also { b -> b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT) b.startServer(SERVER_PORT) } + bridge = newBridge screenCapture = ScreenCapture( - projection = mediaProjection!!, + projection = projection, metrics = metrics, - bridge = bridge!!, + bridge = newBridge, targetWidth = CAPTURE_WIDTH, targetHeight = CAPTURE_HEIGHT, targetFps = CAPTURE_FPS, diff --git a/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt b/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt index c6b886d..e615a4e 100644 --- a/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt +++ b/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt @@ -1,6 +1,7 @@ package com.ledgrab.android import android.app.Application +import android.util.Log import com.chaquo.python.Python import com.chaquo.python.android.AndroidPlatform @@ -13,10 +14,23 @@ import com.chaquo.python.android.AndroidPlatform */ class LedGrabApp : Application() { + /** Set if [Python.start] threw — surfaced by MainActivity. */ + @Volatile + var initError: Throwable? = null + private set + override fun onCreate() { super.onCreate() - if (!Python.isStarted()) { - Python.start(AndroidPlatform(this)) + try { + if (!Python.isStarted()) { + Python.start(AndroidPlatform(this)) + } + } 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) + initError = t + return } // Bind application context for the USB-serial bridge so Python // can enumerate and open USB-to-TTL adapters without needing diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt index 4292911..188ab68 100644 --- a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt +++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt @@ -1,6 +1,5 @@ package com.ledgrab.android -import android.app.Activity import android.content.Intent import android.graphics.Bitmap import android.media.projection.MediaProjectionManager @@ -10,8 +9,15 @@ import android.view.View import android.widget.Button import android.widget.ImageView import android.widget.TextView +import android.app.Activity import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Main (and only) Activity for the Android TV app. @@ -21,6 +27,11 @@ import com.google.zxing.qrcode.QRCodeWriter */ class MainActivity : Activity() { + // Activity-scoped coroutine scope. We don't depend on AppCompat / + // androidx.lifecycle's lifecycleScope here because the TV launcher + // theme inherits from Leanback (non-AppCompat). + private val uiScope: CoroutineScope = MainScope() + companion object { private const val TAG = "MainActivity" private const val SERVER_PORT = 8080 @@ -39,6 +50,14 @@ class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // Surface fatal Python init errors instead of crashing. + val initError = (application as? LedGrabApp)?.initError + if (initError != null) { + showFatalErrorScreen(initError) + return + } + setContentView(R.layout.activity_main) stoppedPanel = findViewById(R.id.stopped_panel) @@ -52,7 +71,7 @@ class MainActivity : Activity() { val versionName = packageManager .getPackageInfo(packageName, 0).versionName - versionText.text = "v$versionName" + versionText.text = "v${versionName ?: "?"}" toggleButton.setOnClickListener { startCapture() } stopButtonRunning.setOnClickListener { stopCaptureService() } @@ -67,12 +86,28 @@ class MainActivity : Activity() { * on the UI thread is acceptable because we're responding to a * button press and we want to block until the user answers. */ + override fun onDestroy() { + uiScope.cancel() + super.onDestroy() + } + private fun startCapture() { - if (Root.requestGrant()) { - Log.i(TAG, "Root available — skipping MediaProjection consent") - startRootCaptureService() - } else { - requestMediaProjection() + // `su -c id` can block for seconds while Magisk shows its grant + // dialog; running it on the Main thread caused ANRs. + toggleButton.isEnabled = false + statusText.text = "Checking root access…" + uiScope.launch(Dispatchers.IO) { + val rooted = Root.requestGrant() + withContext(Dispatchers.Main) { + toggleButton.isEnabled = true + statusText.text = "" + if (rooted) { + Log.i(TAG, "Root available — skipping MediaProjection consent") + startRootCaptureService() + } else { + requestMediaProjection() + } + } } } @@ -120,7 +155,17 @@ class MainActivity : Activity() { val url = "http://$localIp:$SERVER_PORT" urlText.text = url - qrImage.setImageBitmap(generateQrCode(url)) + qrImage.setImageBitmap(null) + // Build the bitmap pixels off the Main thread — encode + 313k + // setPixel calls were noticeably janky on slow TV boxes. + uiScope.launch(Dispatchers.Default) { + val bitmap = generateQrCode(url) + withContext(Dispatchers.Main) { + if (serviceRunning && urlText.text == url) { + qrImage.setImageBitmap(bitmap) + } + } + } stoppedPanel.visibility = View.GONE versionText.visibility = View.GONE @@ -140,12 +185,54 @@ class MainActivity : Activity() { private fun generateQrCode(text: String): Bitmap { val size = 560 val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size) - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565) - for (x in 0 until size) { - for (y in 0 until size) { - bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()) + val pixels = IntArray(size * size) + for (y in 0 until size) { + val rowOffset = y * size + for (x in 0 until size) { + pixels[rowOffset + x] = + if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() } } + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565) + bitmap.setPixels(pixels, 0, size, 0, 0, size, size) return bitmap } + + /** + * Minimal failure UI shown when Python.start() (Chaquopy) blew up. + * Rendered programmatically so we don't depend on the regular layout + * (which itself may reference resources affected by the failure). + */ + private fun showFatalErrorScreen(error: Throwable) { + Log.e(TAG, "Fatal init error — showing error screen", error) + val stackText = android.util.Log.getStackTraceString(error) + val container = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.VERTICAL + setPadding(48, 48, 48, 48) + } + val title = TextView(this).apply { + text = "LedGrab failed to start" + textSize = 22f + } + val body = TextView(this).apply { + text = "Python runtime initialization failed:\n\n$stackText" + textSize = 12f + setTextIsSelectable(true) + } + val copyBtn = Button(this).apply { + text = "Copy log" + setOnClickListener { + val cm = getSystemService(CLIPBOARD_SERVICE) + as android.content.ClipboardManager + cm.setPrimaryClip( + android.content.ClipData.newPlainText("LedGrab error", stackText) + ) + } + } + val scroll = android.widget.ScrollView(this).apply { addView(body) } + container.addView(title) + container.addView(copyBtn) + container.addView(scroll) + setContentView(container) + } } diff --git a/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt b/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt index ddc4dfd..87919e0 100644 --- a/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt +++ b/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt @@ -2,6 +2,7 @@ package com.ledgrab.android import android.content.Context import android.util.Log +import com.chaquo.python.PyObject import com.chaquo.python.Python /** @@ -19,6 +20,14 @@ class PythonBridge(private val context: Context) { private var serverThread: Thread? = null @Volatile private var running = false + // Cached PyObject handles for the per-frame fast path. Looking these + // up via Python.getInstance().getModule(...) every frame was a real + // measurable cost (~1ms/frame on TV boxes). Cached once at configure + // time and read on the capture thread — @Volatile is enough for the + // single-writer/single-reader pattern we have here. + @Volatile private var mediaProjectionEngine: PyObject? = null + @Volatile private var rootEngine: PyObject? = null + /** * Configure the MediaProjection engine with screen dimensions. * Must be called before [startServer]. @@ -27,6 +36,7 @@ class PythonBridge(private val context: Context) { val py = Python.getInstance() val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine") engine.callAttr("configure", width, height) + mediaProjectionEngine = engine Log.i(TAG, "MediaProjection engine configured: ${width}x${height}") } @@ -38,6 +48,7 @@ class PythonBridge(private val context: Context) { val py = Python.getInstance() val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine") engine.callAttr("configure", width, height) + rootEngine = engine Log.i(TAG, "Root screenrecord engine configured: ${width}x${height}") } @@ -99,10 +110,9 @@ class PythonBridge(private val context: Context) { */ fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) { if (!running) return + val engine = mediaProjectionEngine ?: return try { - val py = Python.getInstance() - val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine") engine.callAttr("push_frame", rgbaBytes, width, height) } catch (e: Exception) { Log.w(TAG, "Failed to push frame: ${e.message}") @@ -118,10 +128,9 @@ class PythonBridge(private val context: Context) { */ fun pushRootFrame(rgbaBytes: ByteArray, width: Int, height: Int) { if (!running) return + val engine = rootEngine ?: return try { - val py = Python.getInstance() - val engine = py.getModule("ledgrab.core.capture_engines.root_screenrecord_engine") engine.callAttr("push_frame", rgbaBytes, width, height) } catch (e: Exception) { Log.w(TAG, "Failed to push root frame: ${e.message}") diff --git a/android/app/src/main/java/com/ledgrab/android/Root.kt b/android/app/src/main/java/com/ledgrab/android/Root.kt index 34fe5bb..718a6b2 100644 --- a/android/app/src/main/java/com/ledgrab/android/Root.kt +++ b/android/app/src/main/java/com/ledgrab/android/Root.kt @@ -50,20 +50,45 @@ object Root { } val granted = try { - val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "id")) + // redirectErrorStream merges stderr into stdout so a single + // drain thread is enough — avoids the classic pipe-buffer + // deadlock where waitFor() blocks because stderr filled up. + val process = ProcessBuilder("su", "-c", "id") + .redirectErrorStream(true) + .start() + val outputBuilder = StringBuilder() + val drain = Thread({ + try { + BufferedReader(InputStreamReader(process.inputStream)).use { r -> + val buf = CharArray(512) + while (true) { + val n = r.read(buf) + if (n < 0) break + synchronized(outputBuilder) { outputBuilder.append(buf, 0, n) } + } + } + } catch (_: Exception) { + // Process gone — drain ends. + } + }, "Root-su-drain").apply { isDaemon = true; start() } + val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) if (!finished) { process.destroyForcibly() + drain.join(500) Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s") false - } else if (process.exitValue() != 0) { - Log.w(TAG, "su -c id exited with ${process.exitValue()}") - false } else { - val output = BufferedReader(InputStreamReader(process.inputStream)).readText() - val rooted = output.contains("uid=0") - Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted") - rooted + drain.join(500) + val output = synchronized(outputBuilder) { outputBuilder.toString() } + if (process.exitValue() != 0) { + Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'") + false + } else { + val rooted = output.contains("uid=0") + Log.i(TAG, "su -c id → '${output.trim()}' → rooted=$rooted") + rooted + } } } catch (e: Exception) { Log.w(TAG, "su invocation failed: ${e.message}") @@ -74,6 +99,35 @@ object Root { return granted } + /** + * Run an `su -c ` command. Returns true on exit-zero. Failure + * invalidates the cached grant so the next [requestGrant] re-checks + * (covers cases like Magisk grant being revoked mid-session). + */ + @JvmStatic + fun runAsRoot(cmd: String, timeoutSeconds: Long = 5): Boolean { + return try { + val process = ProcessBuilder("su", "-c", cmd) + .redirectErrorStream(true) + .start() + val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + cachedGranted = null + false + } else if (process.exitValue() != 0) { + cachedGranted = null + false + } else { + true + } + } catch (e: Exception) { + Log.w(TAG, "runAsRoot('$cmd') failed: ${e.message}") + cachedGranted = null + false + } + } + /** Forget the cached grant result — useful if Magisk permission was revoked. */ @JvmStatic fun invalidateCache() { diff --git a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt index d4d5d48..30e1f7d 100644 --- a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt +++ b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt @@ -39,7 +39,7 @@ class RootScreenrecord( private const val INPUT_CHUNK = 64 * 1024 } - private var process: Process? = null + @Volatile private var process: Process? = null private var decoder: MediaCodec? = null private var imageReader: ImageReader? = null private var readerThread: HandlerThread? = null @@ -47,6 +47,7 @@ class RootScreenrecord( private var outputThread: Thread? = null @Volatile private var running = false @Volatile private var framesDelivered = 0 + @Volatile private var stopped = false /** True once at least one frame has reached the Python bridge. */ val hasProducedFrame: Boolean get() = framesDelivered > 0 @@ -80,27 +81,44 @@ class RootScreenrecord( } /** Stop everything and release resources. Idempotent. */ + @Synchronized fun stop() { + if (stopped) return + stopped = true + // Order matters: signal first so worker loops drop out, then + // stop the codec on the thread that created it (this one), then + // join workers BEFORE releasing the codec/ImageReader they may + // still be touching, then kill the external screenrecord process. running = false + runCatching { decoder?.stop() } + inputThread?.interrupt() outputThread?.interrupt() + runCatching { inputThread?.join(500) } + runCatching { outputThread?.join(500) } inputThread = null outputThread = null - runCatching { process?.destroy() } - process = null + // Best-effort: kill the screenrecord child before reaping `su`, + // otherwise screenrecord can outlive su as an orphan and keep + // the GPU encoder busy. Fire-and-forget; ignore failures. + runCatching { Root.runAsRoot("pkill -TERM screenrecord", timeoutSeconds = 2) } - runCatching { decoder?.stop() } runCatching { decoder?.release() } decoder = null + runCatching { imageReader?.setOnImageAvailableListener(null, null) } runCatching { imageReader?.close() } imageReader = null readerThread?.quitSafely() + runCatching { readerThread?.join(500) } readerThread = null + runCatching { process?.destroy() } + process = null + Log.i(TAG, "Root capture pipeline stopped (frames delivered: $framesDelivered)") } @@ -169,15 +187,35 @@ class RootScreenrecord( } } - private fun startInputPump(stream: InputStream, codec: MediaCodec) { + private fun startInputPump(initialStream: InputStream, codec: MediaCodec) { inputThread = Thread({ val buf = ByteArray(INPUT_CHUNK) + var stream: InputStream = initialStream try { - while (running) { - val n = stream.read(buf) + outer@ while (running) { + val n = try { + stream.read(buf) + } catch (e: Exception) { + if (!running) break + Log.w(TAG, "screenrecord read error: ${e.message}") + -1 + } if (n <= 0) { - Log.w(TAG, "screenrecord stdout closed (EOF)") - break + if (!running) break + // screenrecord caps at --time-limit=180s. When it + // exits cleanly we respawn so capture survives + // long sessions instead of freezing after ~3min. + Log.i(TAG, "screenrecord EOF — respawning") + runCatching { process?.destroy() } + val next = spawnScreenrecord() + if (next == null) { + // Avoid a tight loop if `su` is suddenly unhappy. + try { Thread.sleep(500) } catch (_: InterruptedException) { break } + continue@outer + } + process = next + stream = next.inputStream + continue@outer } var offset = 0 while (offset < n && running) { diff --git a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt index 8c6023a..6e3806c 100644 --- a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt +++ b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt @@ -128,13 +128,22 @@ class ScreenCapture( */ fun stop() { running = false - virtualDisplay?.release() + // Order matters: detach the listener BEFORE releasing the + // VirtualDisplay so the handler can't be re-entered with stale + // resources, then quit & join the handler thread, only then + // close the ImageReader. + runCatching { imageReader?.setOnImageAvailableListener(null, null) } + runCatching { virtualDisplay?.release() } virtualDisplay = null - imageReader?.close() - imageReader = null + captureThread?.quitSafely() + runCatching { captureThread?.join(500) } captureThread = null captureHandler = null + + runCatching { imageReader?.close() } + imageReader = null + Log.i(TAG, "Screen capture stopped") } } diff --git a/android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt b/android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt index 20f0d28..1de00d5 100644 --- a/android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt +++ b/android/app/src/main/java/com/ledgrab/android/UsbSerialBridge.kt @@ -11,7 +11,13 @@ import android.util.Log import com.hoho.android.usbserial.driver.UsbSerialDriver import com.hoho.android.usbserial.driver.UsbSerialPort import com.hoho.android.usbserial.driver.UsbSerialProber +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.TimeoutCancellationException /** * USB-serial bridge exposed to the Python server via Chaquopy. @@ -35,22 +41,32 @@ object UsbSerialBridge { private val handleSeq = AtomicInteger(1) private val openPorts = HashMap() + private val initialized = AtomicBoolean(false) + private val pendingPermissions = ConcurrentHashMap>() /** Called once from [LedGrabApp.onCreate] so we can resolve services. */ @JvmStatic fun init(context: Context) { val app = context.applicationContext appContext = app + // Idempotent: re-entrant init() must not double-register the + // receiver (which would leak listeners and double-fire callbacks). + if (!initialized.compareAndSet(false, true)) return val filter = IntentFilter(ACTION_USB_PERMISSION) val receiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { - // We just log; the next open() call checks hasPermission() again. val granted = intent.getBooleanExtra( UsbManager.EXTRA_PERMISSION_GRANTED, false, ) - Log.i(TAG, "USB permission broadcast: granted=$granted") + val device = intent.getParcelableExtra( + UsbManager.EXTRA_DEVICE, + ) + Log.i(TAG, "USB permission broadcast: granted=$granted device=${device?.deviceName}") + device?.deviceName?.let { name -> + pendingPermissions.remove(name)?.complete(granted) + } } } // Android 14 requires RECEIVER_NOT_EXPORTED for non-system broadcasts. @@ -183,6 +199,75 @@ object UsbSerialBridge { .onFailure { Log.w(TAG, "close($handle): ${it.message}") } } + /** + * Block until the user grants (or denies) USB permission for the + * device with [deviceName] (e.g. "/dev/bus/usb/001/004"). Returns + * true if granted within [timeoutMs], false otherwise. Safe to call + * from a Python thread via Chaquopy. + */ + @JvmStatic + @JvmOverloads + fun requestPermissionBlocking(deviceName: String, timeoutMs: Long = 15_000L): Boolean { + val context = ctx() + val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager) + .firstOrNull { it.device.deviceName == deviceName } + ?: return false + if (manager.hasPermission(driver.device)) return true + + // Coalesce concurrent requests for the same device — only the + // first caller actually fires the system dialog. + val deferred = pendingPermissions.computeIfAbsent(deviceName) { + CompletableDeferred().also { + requestPermission(context, manager, driver) + } + } + + return try { + runBlocking { + withTimeout(timeoutMs) { deferred.await() } + } + } catch (_: TimeoutCancellationException) { + pendingPermissions.remove(deviceName) + Log.w(TAG, "Permission request timed out for $deviceName") + false + } catch (e: Exception) { + pendingPermissions.remove(deviceName) + Log.w(TAG, "Permission request failed for $deviceName: ${e.message}") + false + } + } + + /** + * Like [open] but blocks for permission first. Use this from Python + * instead of relying on the open()/retry pattern. + */ + @JvmStatic + @JvmOverloads + fun openWithPermission( + vendorId: Int, + productId: Int, + serial: String, + baud: Int, + timeoutMs: Long = 15_000L, + ): Int { + val context = ctx() + val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val driver = UsbSerialProber.getDefaultProber().findAllDrivers(manager) + .firstOrNull { d -> + val dev = d.device + dev.vendorId == vendorId && + dev.productId == productId && + (serial.isEmpty() || safeSerial(d) == serial) + } ?: return -1 + + if (!manager.hasPermission(driver.device)) { + val granted = requestPermissionBlocking(driver.device.deviceName, timeoutMs) + if (!granted) return -1 + } + return open(vendorId, productId, serial, baud) + } + private fun requestPermission( context: Context, manager: UsbManager, diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index 0ee2e89..4e88006 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -8,11 +8,15 @@ server: - "http://localhost:8080" auth: - # API keys — when empty, authentication is disabled (open access). - # To enable auth, add one or more label: "api-key" entries. + # API keys — required for any non-loopback (LAN) request. + # When empty: + # - loopback (127.0.0.1, ::1, localhost) requests are allowed anonymously + # - LAN requests are REJECTED with 401 (security default) + # To enable LAN access, add one or more label: "api-key" entries below + # and send `Authorization: Bearer ` with each request. # Generate secure keys: openssl rand -hex 32 - api_keys: - dev: "development-key-change-in-production" + api_keys: {} + # dev: "replace-with-openssl-rand-hex-32" storage: database_file: "data/ledgrab.db" @@ -31,3 +35,10 @@ logging: file: "logs/ledgrab.log" max_size_mb: 100 backup_count: 5 + +updates: + # When false (default), updates without a published sha256 checksum + # (sibling .sha256 asset OR 64-hex string in release body) are aborted + # before any installer/extractor runs. NEVER set true unless you + # control the release server end-to-end. + allow_unchecked: false diff --git a/server/pyproject.toml b/server/pyproject.toml index 6c29335..d3e6dc1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.32.0", + "cryptography>=42.0.0", "httpx>=0.27.2", "mss>=9.0.2", "numpy>=2.1.3", diff --git a/server/src/ledgrab/api/auth.py b/server/src/ledgrab/api/auth.py index 9b1acb9..ec2cc02 100644 --- a/server/src/ledgrab/api/auth.py +++ b/server/src/ledgrab/api/auth.py @@ -1,10 +1,13 @@ """Authentication module for API key validation.""" +import asyncio +import json import secrets from typing import Annotated -from fastapi import Depends, HTTPException, Security, status +from fastapi import Depends, HTTPException, Request, Security, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from starlette.websockets import WebSocket, WebSocketDisconnect from ledgrab.config import get_config from ledgrab.utils import get_logger @@ -14,34 +17,69 @@ logger = get_logger(__name__) # Security scheme for Bearer token security = HTTPBearer(auto_error=False) +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) + def is_auth_enabled() -> bool: """Return True when at least one API key is configured.""" return bool(get_config().auth.api_keys) +def _is_loopback(host: str | None) -> bool: + """Return True when *host* is a loopback address.""" + if not host: + return False + # Strip IPv6 brackets and zone IDs + h = host.strip().lower() + if h.startswith("[") and h.endswith("]"): + h = h[1:-1] + h = h.split("%", 1)[0] + return h in _LOOPBACK_HOSTS + + def verify_api_key( - credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)] + request: Request, + credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)], ) -> str: """Verify API key from Authorization header. - When no API keys are configured, authentication is disabled and all - requests are allowed through as "anonymous". + Behavior: + - When no API keys are configured AND the request comes from a loopback + address, anonymous access is allowed. + - When no API keys are configured AND the request is from a non-loopback + (LAN) address, the request is REJECTED with 401 (security default — + LAN access requires an API key). + - When API keys ARE configured, valid Bearer credentials are required. Args: + request: incoming request (used to read client host) credentials: HTTP authorization credentials Returns: - Label/identifier of the authenticated client + Label/identifier of the authenticated client ("anonymous" for + loopback unauthenticated access). Raises: - HTTPException: If authentication is required but invalid + HTTPException: If authentication is required but invalid / missing. """ config = get_config() + client_host = request.client.host if request.client else None - # No keys configured → auth disabled, allow all requests if not config.auth.api_keys: - return "anonymous" + # No keys configured — allow loopback only. + if _is_loopback(client_host): + return "anonymous" + # Allow caller to authenticate explicitly even without configured keys? + # No — there are no keys to compare against. Reject. + logger.warning("Rejected LAN request from %s: no API key configured", client_host) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=( + "LAN access requires an API key. Configure auth.api_keys in " + "config.yaml (see config/default_config.yaml for the format)." + ), + headers={"WWW-Authenticate": "Bearer"}, + ) # Check if credentials are provided if not credentials: @@ -81,18 +119,184 @@ def verify_api_key( AuthRequired = Annotated[str, Depends(verify_api_key)] -def verify_ws_token(token: str) -> bool: - """Check a WebSocket query-param token against configured API keys. +def require_authenticated(label: str) -> None: + """Reject the anonymous (loopback) auth label. - When no API keys are configured, authentication is disabled and all - WebSocket connections are allowed. + Use this in endpoints that must NEVER be called anonymously even + from loopback (e.g. backup download, secret reveal). + + Raises: + HTTPException: If *label* is "anonymous". + """ + if label == "anonymous": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=( + "This endpoint requires an API key. Configure auth.api_keys " + "in config.yaml and provide a Bearer token." + ), + headers={"WWW-Authenticate": "Bearer"}, + ) + + +WS_AUTH_CLOSE_CODE = 4401 + + +async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) -> str | None: + """Accept the WebSocket, then perform first-message auth handshake. + + Convenience wrapper over :func:`verify_ws_auth` that handles + ``websocket.accept()`` and automatically closes the connection with + :data:`WS_AUTH_CLOSE_CODE` on failure. + + Returns the caller label on success, ``None`` on failure (connection + already closed). + """ + await websocket.accept() + label = await verify_ws_auth(websocket, timeout=timeout) + if label is None: + try: + await websocket.close(code=WS_AUTH_CLOSE_CODE) + except Exception: + pass + return None + return label + + +"""Close code sent when a WebSocket fails first-message auth (timeout or bad token).""" + + +def _match_api_key(token: str) -> str | None: + """Return the label matching *token* using constant-time comparison, or None.""" + config = get_config() + if not token: + return None + for label, api_key in config.auth.api_keys.items(): + if secrets.compare_digest(token, api_key): + return label + return None + + +async def verify_ws_auth( + websocket: WebSocket, + timeout: float = 3.0, +) -> str | None: + """Authenticate a WebSocket via a first-message auth handshake. + + Protocol: + 1. The caller must have already ``await websocket.accept()`` ed the + connection. + 2. This function waits up to *timeout* seconds for the first message, + which must be JSON of the form ``{"type": "auth", "token": ""}``. + ``token`` may be null/missing on loopback when no API keys are + configured. + 3. On success, sends ``{"type": "auth_ok"}`` and returns the caller + label (e.g. ``"dev"``, or ``"anonymous"`` for loopback with no + configured keys). + 4. On failure, sends ``{"type": "auth_error", "reason": ...}`` and + returns ``None``. The caller is responsible for calling + ``await websocket.close(code=WS_AUTH_CLOSE_CODE)``. + + Loopback policy mirrors :func:`verify_api_key`: + - No API keys configured + loopback client → anonymous access allowed. + If a client sends an ``auth`` message anyway, it's accepted as a + no-op so the protocol stays uniform. + - No API keys configured + non-loopback client → rejected. + - Keys configured → valid token required regardless of loopback. + + Returns: + Caller label on success, ``None`` on failure. """ config = get_config() - # No keys configured → auth disabled, allow all connections - if not config.auth.api_keys: - return True - if token: - for _label, api_key in config.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - return True - return False + client_host = websocket.client.host if websocket.client else None + loopback = _is_loopback(client_host) + keys_configured = bool(config.auth.api_keys) + + # Try to read the auth message with a timeout. + raw: str | None + try: + raw = await asyncio.wait_for(websocket.receive_text(), timeout=timeout) + except asyncio.TimeoutError: + if not keys_configured and loopback: + # Loopback anonymous: no auth message arrived, but none is required. + try: + await websocket.send_json({"type": "auth_ok"}) + except Exception: + return None + return "anonymous" + logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host) + try: + await websocket.send_json({"type": "auth_error", "reason": "auth timeout"}) + except Exception: + pass + return None + except WebSocketDisconnect: + return None + except Exception as exc: + logger.debug("WebSocket auth receive error: %s", exc) + return None + + # Parse the auth message. + try: + msg = json.loads(raw) if raw else {} + except (json.JSONDecodeError, ValueError): + try: + await websocket.send_json( + {"type": "auth_error", "reason": "invalid JSON in auth message"} + ) + except Exception: + pass + return None + + if not isinstance(msg, dict) or msg.get("type") != "auth": + try: + await websocket.send_json( + {"type": "auth_error", "reason": "first message must be {type:'auth'}"} + ) + except Exception: + pass + return None + + token = msg.get("token") + if token is not None and not isinstance(token, str): + try: + await websocket.send_json( + {"type": "auth_error", "reason": "token must be a string or null"} + ) + except Exception: + pass + return None + + # Loopback + no keys configured: accept regardless of token contents. + if not keys_configured: + if loopback: + await websocket.send_json({"type": "auth_ok"}) + return "anonymous" + logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host) + try: + await websocket.send_json( + { + "type": "auth_error", + "reason": "LAN access requires an API key", + } + ) + except Exception: + pass + return None + + # Keys configured: require a matching token. + label = _match_api_key(token or "") + if not label: + logger.warning("Invalid WebSocket auth attempt from %s", client_host) + try: + await websocket.send_json({"type": "auth_error", "reason": "invalid token"}) + except Exception: + pass + return None + + try: + await websocket.send_json({"type": "auth_ok"}) + except Exception: + return None + logger.debug("WebSocket authenticated as: %s", label) + return label diff --git a/server/src/ledgrab/api/routes/_preview_helpers.py b/server/src/ledgrab/api/routes/_preview_helpers.py index a39ee91..845f410 100644 --- a/server/src/ledgrab/api/routes/_preview_helpers.py +++ b/server/src/ledgrab/api/routes/_preview_helpers.py @@ -26,16 +26,6 @@ FINAL_JPEG_QUALITY = 90 PREVIEW_JPEG_QUALITY = 70 -def authenticate_ws_token(token: str) -> bool: - """Check a WebSocket query-param token against configured API keys. - - Delegates to the canonical implementation in auth module. - """ - from ledgrab.api.auth import verify_ws_token - - return verify_ws_token(token) - - def _encode_jpeg(image: np.ndarray, quality: int = 85) -> str: """Encode a numpy RGB image as a JPEG base64 data URI.""" return encode_jpeg_data_uri(image, quality) diff --git a/server/src/ledgrab/api/routes/assets.py b/server/src/ledgrab/api/routes/assets.py index 4f0aa9e..77a5bc3 100644 --- a/server/src/ledgrab/api/routes/assets.py +++ b/server/src/ledgrab/api/routes/assets.py @@ -108,11 +108,13 @@ async def upload_asset( ) try: + # Deliberately do NOT pass file.content_type — it is client-supplied + # and unverified. AssetStore derives the mime from the extension + # against a server-controlled allow-list. asset = store.create_asset( name=display_name, filename=file.filename or "unnamed", file_data=data, - mime_type=file.content_type, description=description, ) except ValueError as e: @@ -199,10 +201,15 @@ async def serve_asset_file( except EntityNotFoundError: raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}") + # Force download (Content-Disposition: attachment) so any HTML/SVG bytes + # that might have slipped past the upload allow-list cannot execute in + # the browser context that serves the LedGrab UI. + safe_name = (asset.filename or "asset").replace('"', "") return FileResponse( path=str(file_path), media_type=asset.mime_type, filename=asset.filename, + headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, ) diff --git a/server/src/ledgrab/api/routes/audio_sources.py b/server/src/ledgrab/api/routes/audio_sources.py index fbe6bf6..bae1d5b 100644 --- a/server/src/ledgrab/api/routes/audio_sources.py +++ b/server/src/ledgrab/api/routes/audio_sources.py @@ -203,9 +203,11 @@ async def delete_audio_source( async def test_audio_source_ws( websocket: WebSocket, source_id: str, - token: str = Query(""), ): - """WebSocket for real-time audio spectrum analysis. Auth via ?token=. + """WebSocket for real-time audio spectrum analysis. + + Auth via first-message handshake: client sends + ``{"type":"auth","token":"..."}`` within 3 s of connect. Resolves the audio source to its device and template chain, acquires a ManagedAudioStream (ref-counted — shares with running targets), and streams @@ -215,11 +217,10 @@ async def test_audio_source_ws( analysis before sending, so the WebSocket output matches what running streams see. """ - from ledgrab.api.auth import verify_ws_token + from ledgrab.api.auth import accept_and_authenticate_ws from ledgrab.core.audio.filters.pipeline import build_pipeline_from_template_ids - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return # Resolve source → device info + processing template chain @@ -267,7 +268,6 @@ async def test_audio_source_ws( await websocket.close(code=4003, reason=str(e)) return - await websocket.accept() logger.info(f"Audio test WebSocket connected for source {source_id}") last_ts = 0.0 diff --git a/server/src/ledgrab/api/routes/audio_templates.py b/server/src/ledgrab/api/routes/audio_templates.py index 2b013fa..2327838 100644 --- a/server/src/ledgrab/api/routes/audio_templates.py +++ b/server/src/ledgrab/api/routes/audio_templates.py @@ -231,19 +231,19 @@ async def list_audio_engines(_auth: AuthRequired): async def test_audio_template_ws( websocket: WebSocket, template_id: str, - token: str = Query(""), device_index: int = Query(-1), is_loopback: int = Query(1), ): """WebSocket for real-time audio spectrum test of a template with a chosen device. - Auth via ?token=. Device specified via ?device_index=N&is_loopback=0|1. + Auth via first-message handshake: + ``{"type":"auth","token":"..."}`` within 3 s of connect. + Device specified via ?device_index=N&is_loopback=0|1. Streams AudioAnalysis snapshots as JSON at ~20 Hz. """ - from ledgrab.api.auth import verify_ws_token + from ledgrab.api.auth import accept_and_authenticate_ws - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return # Resolve template @@ -267,7 +267,6 @@ async def test_audio_template_ws( await websocket.close(code=4003, reason=str(e)) return - await websocket.accept() logger.info( f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}" ) diff --git a/server/src/ledgrab/api/routes/backup.py b/server/src/ledgrab/api/routes/backup.py index 744190e..80639f8 100644 --- a/server/src/ledgrab/api/routes/backup.py +++ b/server/src/ledgrab/api/routes/backup.py @@ -15,7 +15,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import StreamingResponse -from ledgrab.api.auth import AuthRequired +from ledgrab.api.auth import AuthRequired, require_authenticated from ledgrab.api.dependencies import get_asset_store, get_auto_backup_engine, get_database from ledgrab.api.schemas.system import ( AutoBackupSettings, @@ -71,11 +71,16 @@ def _schedule_restart() -> None: @router.get("/api/v1/system/backup", tags=["System"]) def backup_config( - _: AuthRequired, + auth: AuthRequired, db: Database = Depends(get_database), asset_store: AssetStore = Depends(get_asset_store), ): - """Download a full backup as a .zip containing the database and asset files.""" + """Download a full backup as a .zip containing the database and asset files. + + Requires a non-anonymous API key (loopback-anonymous access is rejected + because backups contain sensitive credentials). + """ + require_authenticated(auth) import tempfile with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: @@ -115,7 +120,7 @@ def backup_config( @router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"]) async def restore_config( - _: AuthRequired, + auth: AuthRequired, file: UploadFile = File(...), db: Database = Depends(get_database), ): @@ -123,7 +128,11 @@ async def restore_config( ZIP backups contain the database and asset files. Plain .db backups are also supported for backward compatibility (assets are not restored). + + Requires a non-anonymous API key (loopback-anonymous access is rejected + because restore replaces all configuration including secrets). """ + require_authenticated(auth) raw = await file.read() if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets) raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)") @@ -151,15 +160,35 @@ async def restore_config( db_bytes = zf.read("ledgrab.db") - # Restore asset files + # Restore asset files (with path-traversal hardening) assets_dir = Path(get_config().assets.assets_dir) assets_dir.mkdir(parents=True, exist_ok=True) + assets_root = assets_dir.resolve() for name in names: - if name.startswith("assets/") and not name.endswith("/"): - asset_filename = name.split("/", 1)[1] - dest = assets_dir / asset_filename - dest.write_bytes(zf.read(name)) - logger.info(f"Restored asset file: {asset_filename}") + if not name.startswith("assets/") or name.endswith("/"): + continue + rel = name.split("/", 1)[1] + # Reject obvious traversal / absolute-path entries + if ( + ".." in Path(rel).parts + or rel.startswith(("/", "\\")) + or (len(rel) >= 2 and rel[1] == ":") # drive letter + ): + logger.warning("Rejected ZIP entry (path traversal): %s", name) + raise HTTPException( + status_code=400, detail="Path traversal detected in backup ZIP" + ) + dest = (assets_dir / rel).resolve() + try: + dest.relative_to(assets_root) + except ValueError: + logger.warning("Rejected ZIP entry (escape): %s -> %s", name, dest) + raise HTTPException( + status_code=400, detail="Path traversal detected in backup ZIP" + ) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(zf.read(name)) + logger.info(f"Restored asset file: {rel}") except zipfile.BadZipFile: raise HTTPException(status_code=400, detail="Invalid ZIP file") else: diff --git a/server/src/ledgrab/api/routes/color_strip_processing.py b/server/src/ledgrab/api/routes/color_strip_processing.py index 18c8a16..33910ee 100644 --- a/server/src/ledgrab/api/routes/color_strip_processing.py +++ b/server/src/ledgrab/api/routes/color_strip_processing.py @@ -197,7 +197,6 @@ async def delete_cspt( async def test_cspt_ws( websocket: WebSocket, template_id: str, - token: str = Query(""), input_source_id: str = Query(""), led_count: int = Query(100), fps: int = Query(20), @@ -205,14 +204,14 @@ async def test_cspt_ws( """WebSocket for real-time CSPT preview. Takes an input CSS source, applies the CSPT filter chain, and streams - the processed RGB frames. Auth via ``?token=``. + the processed RGB frames. Auth via first-message handshake: + ``{"type":"auth","token":"..."}`` within 3 s of connect. """ - from ledgrab.api.auth import verify_ws_token + from ledgrab.api.auth import accept_and_authenticate_ws from ledgrab.core.filters import FilterRegistry from ledgrab.core.processing.processor_manager import ProcessorManager - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return # Validate template exists @@ -262,7 +261,6 @@ async def test_cspt_ws( fps = max(1, min(60, fps)) frame_interval = 1.0 / fps - await websocket.accept() logger.info(f"CSPT test WS connected: template={template_id}, input={input_source_id}") try: diff --git a/server/src/ledgrab/api/routes/color_strip_sources.py b/server/src/ledgrab/api/routes/color_strip_sources.py deleted file mode 100644 index 6171ea8..0000000 --- a/server/src/ledgrab/api/routes/color_strip_sources.py +++ /dev/null @@ -1,1683 +0,0 @@ -"""Color strip source routes: CRUD, calibration test, preview, and API input push.""" - -import asyncio -import json as _json -import time as _time -import uuid as _uuid -from typing import Annotated - -import numpy as np -from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect - -from ledgrab.api.auth import AuthRequired -from ledgrab.api.dependencies import ( - fire_entity_event, - get_color_strip_store, - get_device_store, - get_picture_source_store, - get_output_target_store, - get_pp_template_store, - get_processor_manager, - get_template_store, -) -from ledgrab.api.schemas.color_strip_sources import ( - ApiInputCSSResponse, - AudioCSSResponse, - CandlelightCSSResponse, - ColorCycleCSSResponse, - ColorPushRequest, - ColorStop as ColorStopSchema, - ColorStripSourceCreate, - ColorStripSourceListResponse, - ColorStripSourceResponse, - ColorStripSourceUpdate, - CompositeCSSResponse, - CSSCalibrationTestRequest, - DaylightCSSResponse, - EffectCSSResponse, - GradientCSSResponse, - KeyColorsCSSResponse, - MappedCSSResponse, - MathWaveCSSResponse, - NotificationCSSResponse, - NotifyRequest, - PictureAdvancedCSSResponse, - PictureCSSResponse, - ProcessedCSSResponse, - StaticCSSResponse, - WeatherCSSResponse, -) -from ledgrab.api.schemas.devices import ( - Calibration as CalibrationSchema, - CalibrationTestModeResponse, -) -from ledgrab.core.capture.calibration import ( - calibration_from_dict, - calibration_to_dict, -) -from ledgrab.core.capture.screen_capture import get_available_displays -from ledgrab.core.processing.processor_manager import ProcessorManager -from ledgrab.storage.color_strip_source import ( - AdvancedPictureColorStripSource, - ApiInputColorStripSource, - AudioColorStripSource, - CandlelightColorStripSource, - ColorCycleColorStripSource, - CompositeColorStripSource, - DaylightColorStripSource, - EffectColorStripSource, - GradientColorStripSource, - KeyColorsColorStripSource, - MappedColorStripSource, - MathWaveColorStripSource, - NotificationColorStripSource, - PictureColorStripSource, - ProcessedColorStripSource, - StaticColorStripSource, - WeatherColorStripSource, -) -from ledgrab.storage import DeviceStore -from ledgrab.storage.color_strip_store import ColorStripStore -from ledgrab.storage.template_store import TemplateStore -from ledgrab.storage.picture_source import ( - ProcessedPictureSource, - ScreenCapturePictureSource, -) -from ledgrab.storage.picture_source_store import PictureSourceStore -from ledgrab.storage.output_target_store import OutputTargetStore -from ledgrab.utils import get_logger -from ledgrab.storage.base_store import EntityNotFoundError - -logger = get_logger(__name__) - -router = APIRouter() - - -def _common_response_kwargs(source, overlay_active: bool = False) -> dict: - """Shared response fields from any ColorStripSource.""" - return dict( - id=source.id, - name=source.name, - description=source.description, - led_count=getattr(source, "led_count", 0), - overlay_active=overlay_active, - clock_id=source.clock_id, - tags=source.tags, - created_at=source.created_at, - updated_at=source.updated_at, - ) - - -def _calibration_schema(source) -> CalibrationSchema | None: - """Convert a source's calibration to a schema object, or None.""" - cal = getattr(source, "calibration", None) - if cal is None: - return None - try: - return CalibrationSchema(**calibration_to_dict(cal)) - except Exception: - return None - - -def _stops_schema(source) -> list[ColorStopSchema] | None: - """Convert a source's stops list to schema objects, or None.""" - raw = getattr(source, "stops", None) - if raw is None: - return None - try: - return [ColorStopSchema(**dict(s)) for s in raw] - except Exception: - return None - - -# Maps storage class → response builder lambda. -_RESPONSE_MAP: dict = { - PictureColorStripSource: lambda s, kw: PictureCSSResponse( - **kw, - picture_source_id=s.picture_source_id, - smoothing=s.smoothing.to_dict(), - interpolation_mode=s.interpolation_mode, - calibration=_calibration_schema(s), - ), - AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse( - **kw, - smoothing=s.smoothing.to_dict(), - interpolation_mode=s.interpolation_mode, - calibration=_calibration_schema(s), - ), - StaticColorStripSource: lambda s, kw: StaticCSSResponse( - **kw, - color=s.color.to_dict(), - animation=s.animation, - ), - GradientColorStripSource: lambda s, kw: GradientCSSResponse( - **kw, - stops=_stops_schema(s), - animation=s.animation, - easing=s.easing, - gradient_id=s.gradient_id, - ), - ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse( - **kw, - colors=[list(c) for c in s.colors], - ), - EffectColorStripSource: lambda s, kw: EffectCSSResponse( - **kw, - effect_type=s.effect_type, - palette=s.palette, - gradient_id=s.gradient_id, - color=s.color.to_dict(), - intensity=s.intensity.to_dict(), - scale=s.scale.to_dict(), - mirror=s.mirror, - custom_palette=s.custom_palette, - ), - CompositeColorStripSource: lambda s, kw: CompositeCSSResponse( - **kw, - layers=[dict(layer) for layer in s.layers], - ), - MappedColorStripSource: lambda s, kw: MappedCSSResponse( - **kw, - zones=[dict(z) for z in s.zones], - ), - AudioColorStripSource: lambda s, kw: AudioCSSResponse( - **kw, - visualization_mode=s.visualization_mode, - audio_source_id=s.audio_source_id, - sensitivity=s.sensitivity.to_dict(), - smoothing=s.smoothing.to_dict(), - palette=s.palette, - gradient_id=s.gradient_id, - color=s.color.to_dict(), - color_peak=s.color_peak.to_dict(), - mirror=s.mirror, - beat_decay=s.beat_decay.to_dict(), - ), - ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse( - **kw, - fallback_color=s.fallback_color.to_dict(), - timeout=s.timeout.to_dict(), - interpolation=s.interpolation, - ), - NotificationColorStripSource: lambda s, kw: NotificationCSSResponse( - **kw, - notification_effect=s.notification_effect, - duration_ms=s.duration_ms.to_dict(), - default_color=s.default_color.to_dict(), - app_colors=dict(s.app_colors), - app_filter_mode=s.app_filter_mode, - app_filter_list=list(s.app_filter_list), - os_listener=s.os_listener, - sound_asset_id=s.sound_asset_id, - sound_volume=s.sound_volume.to_dict(), - app_sounds=dict(s.app_sounds), - ), - DaylightColorStripSource: lambda s, kw: DaylightCSSResponse( - **kw, - speed=s.speed.to_dict(), - use_real_time=s.use_real_time, - latitude=s.latitude, - longitude=s.longitude, - ), - CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse( - **kw, - color=s.color.to_dict(), - intensity=s.intensity.to_dict(), - num_candles=s.num_candles, - speed=s.speed.to_dict(), - wind_strength=s.wind_strength.to_dict(), - candle_type=s.candle_type, - ), - ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse( - **kw, - input_source_id=s.input_source_id, - processing_template_id=s.processing_template_id, - ), - WeatherColorStripSource: lambda s, kw: WeatherCSSResponse( - **kw, - weather_source_id=s.weather_source_id, - speed=s.speed.to_dict(), - temperature_influence=s.temperature_influence.to_dict(), - ), - KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse( - **kw, - picture_source_id=s.picture_source_id, - rectangles=[r.to_dict() for r in s.rectangles], - interpolation_mode=s.interpolation_mode, - smoothing=s.smoothing.to_dict(), - brightness=s.brightness.to_dict(), - ), - MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse( - **kw, - waves=s.waves, - speed=s.speed.to_dict(), - gradient_id=s.gradient_id, - ), -} - - -def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse: - """Convert a ColorStripSource to the matching per-type response schema.""" - kw = _common_response_kwargs(source, overlay_active) - builder = _RESPONSE_MAP.get(type(source)) - if builder is None: - # Fallback: use to_dict() and build a PictureCSSResponse - logger.warning("No response builder for %s, falling back", type(source).__name__) - return PictureCSSResponse( - **kw, - picture_source_id="", - smoothing=0.3, - interpolation_mode="average", - calibration=None, - ) - return builder(source, kw) - - -def _resolve_display_index( - picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0 -) -> int: - """Resolve display index from a picture source, following processed source chains.""" - if not picture_source_id or depth > 5: - return 0 - try: - ps = picture_source_store.get_stream(picture_source_id) - except Exception as e: - logger.debug( - "Failed to resolve display index for picture source %s: %s", picture_source_id, e - ) - return 0 - if isinstance(ps, ScreenCapturePictureSource): - return ps.display_index - if isinstance(ps, ProcessedPictureSource): - return _resolve_display_index(ps.source_stream_id, picture_source_store, depth + 1) - return 0 - - -# ===== CRUD ENDPOINTS ===== - - -@router.get( - "/api/v1/color-strip-sources", - response_model=ColorStripSourceListResponse, - tags=["Color Strip Sources"], -) -async def list_color_strip_sources( - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """List all color strip sources.""" - sources = store.get_all_sources() - responses = [_css_to_response(s, manager.is_css_overlay_active(s.id)) for s in sources] - return ColorStripSourceListResponse(sources=responses, count=len(responses)) - - -def _extract_css_kwargs(data) -> dict: - """Extract store-compatible kwargs from a Pydantic CSS create/update schema. - - Converts nested Pydantic models (calibration, stops, layers, zones, - animation) to plain dicts/lists that the store expects. - """ - # Exclude nested models that need special conversion - exclude_fields = {"source_type"} - for nested in ("calibration", "stops", "layers", "zones", "animation"): - if hasattr(data, nested): - exclude_fields.add(nested) - kwargs = data.model_dump(exclude_unset=False, exclude=exclude_fields) - - # Convert nested Pydantic models → plain dicts for the store - if hasattr(data, "calibration"): - cal = getattr(data, "calibration", None) - if cal is not None: - kwargs["calibration"] = calibration_from_dict(cal.model_dump()) - else: - kwargs["calibration"] = None - - if hasattr(data, "stops"): - stops = getattr(data, "stops", None) - kwargs["stops"] = [s.model_dump() for s in stops] if stops is not None else None - - if hasattr(data, "layers"): - layers = getattr(data, "layers", None) - kwargs["layers"] = [layer.model_dump() for layer in layers] if layers is not None else None - - if hasattr(data, "zones"): - zones = getattr(data, "zones", None) - kwargs["zones"] = [z.model_dump() for z in zones] if zones is not None else None - - if hasattr(data, "animation"): - anim = getattr(data, "animation", None) - kwargs["animation"] = anim.model_dump() if anim else None - - return kwargs - - -@router.post( - "/api/v1/color-strip-sources", - response_model=ColorStripSourceResponse, - tags=["Color Strip Sources"], - status_code=201, -) -async def create_color_strip_source( - data: Annotated[ColorStripSourceCreate, Body(discriminator="source_type")], - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), -): - """Create a new color strip source.""" - try: - kwargs = _extract_css_kwargs(data) - - # Validate nesting for composite/mapped sources before creating - if data.source_type == "composite" and kwargs.get("layers"): - child_ids = [ly.get("source_id", "") for ly in kwargs["layers"] if ly.get("source_id")] - # No parent_id yet (new source), just check depth - from ledgrab.storage.color_strip_store import MAX_COMPOSITE_DEPTH - - for cid in child_ids: - depth = store.get_nesting_depth(cid) - if 1 + depth > MAX_COMPOSITE_DEPTH: - raise ValueError( - f"Nesting depth {1 + depth} exceeds maximum of {MAX_COMPOSITE_DEPTH}" - ) - - source = store.create_source(source_type=data.source_type, **kwargs) - fire_entity_event("color_strip_source", "created", source.id) - return _css_to_response(source) - - except EntityNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error("Failed to create color strip source: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/api/v1/color-strip-sources/{source_id}", - response_model=ColorStripSourceResponse, - tags=["Color Strip Sources"], -) -async def get_color_strip_source( - source_id: str, - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Get a color strip source by ID.""" - try: - source = store.get_source(source_id) - return _css_to_response(source, manager.is_css_overlay_active(source_id)) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.put( - "/api/v1/color-strip-sources/{source_id}", - response_model=ColorStripSourceResponse, - tags=["Color Strip Sources"], -) -async def update_color_strip_source( - source_id: str, - data: Annotated[ColorStripSourceUpdate, Body(discriminator="source_type")], - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Update a color strip source and hot-reload any running streams.""" - try: - kwargs = _extract_css_kwargs(data) - - # Validate nesting for composite sources before updating - if data.source_type == "composite" and kwargs.get("layers") is not None: - child_ids = [ly.get("source_id", "") for ly in kwargs["layers"] if ly.get("source_id")] - store.validate_nesting(source_id, child_ids) - - source = store.update_source(source_id=source_id, **kwargs) - - # Hot-reload running stream (no restart needed for in-place param changes) - try: - manager.color_strip_stream_manager.update_source(source_id, source) - except Exception as e: - logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}") - - fire_entity_event("color_strip_source", "updated", source_id) - return _css_to_response(source) - - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error("Failed to update color strip source: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.delete( - "/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"] -) -async def delete_color_strip_source( - source_id: str, - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - target_store: OutputTargetStore = Depends(get_output_target_store), -): - """Delete a color strip source. Returns 409 if referenced by any LED target.""" - try: - target_names = target_store.get_targets_referencing_css(source_id) - if target_names: - names = ", ".join(target_names) - raise HTTPException( - status_code=409, - detail=f"Color strip source is referenced by target(s): {names}. " - "Delete or reassign the target(s) first.", - ) - composite_names = store.get_composites_referencing(source_id) - if composite_names: - names = ", ".join(composite_names) - raise HTTPException( - status_code=409, - detail=f"Color strip source is used as a layer in composite source(s): {names}. " - "Remove it from the composite(s) first.", - ) - mapped_names = store.get_mapped_referencing(source_id) - if mapped_names: - names = ", ".join(mapped_names) - raise HTTPException( - status_code=409, - detail=f"Color strip source is used as a zone in mapped source(s): {names}. " - "Remove it from the mapped source(s) first.", - ) - processed_names = store.get_processed_referencing(source_id) - if processed_names: - names = ", ".join(processed_names) - raise HTTPException( - status_code=409, - detail=f"Color strip source is used as input in processed source(s): {names}. " - "Delete or reassign the processed source(s) first.", - ) - store.delete_source(source_id) - fire_entity_event("color_strip_source", "deleted", source_id) - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error("Failed to delete color strip source: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - -# ===== KEY COLORS TEST ===== - - -@router.post( - "/api/v1/color-strip-sources/{source_id}/key-colors/test", - tags=["Color Strip Sources"], -) -async def test_key_colors_source( - source_id: str, - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - source_store: PictureSourceStore = Depends(get_picture_source_store), - template_store: TemplateStore = Depends(get_template_store), - processor_manager: ProcessorManager = Depends(get_processor_manager), - device_store: DeviceStore = Depends(get_device_store), - pp_template_store=Depends(get_pp_template_store), -): - """Test a key_colors source: capture a frame, extract colors from each rectangle.""" - from ledgrab.storage.color_strip_source import KeyColorsColorStripSource - from ledgrab.core.capture.screen_capture import ( - calculate_average_color, - calculate_dominant_color, - calculate_median_color, - ) - from ledgrab.core.capture_engines import EngineRegistry - from ledgrab.core.filters import FilterRegistry, ImagePool - from ledgrab.storage.picture_source import ( - ScreenCapturePictureSource, - StaticImagePictureSource, - ) - from ledgrab.utils.image_codec import encode_jpeg_data_uri - - stream = None - try: - source = store.get_source(source_id) - if not isinstance(source, KeyColorsColorStripSource): - raise HTTPException(status_code=400, detail="Source is not a key_colors type") - - if not source.rectangles: - raise HTTPException(status_code=400, detail="No screen regions configured") - if not source.picture_source_id: - raise HTTPException(status_code=400, detail="No picture source configured") - - # Resolve picture source and capture a frame - chain = source_store.resolve_stream_chain(source.picture_source_id) - raw_stream = chain["raw_stream"] - - from ledgrab.utils.image_codec import load_image_file - - if isinstance(raw_stream, StaticImagePictureSource): - from ledgrab.api.dependencies import get_asset_store as _get_asset_store - - asset_store = _get_asset_store() - image_path = ( - asset_store.get_file_path(raw_stream.image_asset_id) - if raw_stream.image_asset_id - else None - ) - if not image_path: - raise HTTPException(status_code=400, detail="Image asset not found") - image = load_image_file(image_path) - elif isinstance(raw_stream, ScreenCapturePictureSource): - capture_template = template_store.get_template(raw_stream.capture_template_id) - display_index = raw_stream.display_index - - if capture_template.engine_type not in EngineRegistry.get_available_engines(): - raise HTTPException( - status_code=400, detail=f"Engine '{capture_template.engine_type}' not available" - ) - - locked = processor_manager.get_display_lock_info(display_index) - if locked: - try: - device_name = device_store.get_device(locked).name - except Exception: - device_name = locked - raise HTTPException( - status_code=409, - detail=f"Display {display_index} is captured by '{device_name}'. Stop it first.", - ) - - stream = EngineRegistry.create_stream( - capture_template.engine_type, display_index, capture_template.engine_config - ) - stream.initialize() - sc = stream.capture_frame() - if sc is None: - raise RuntimeError("No frame captured") - image = sc.image - else: - raise HTTPException(status_code=400, detail="Unsupported picture source type") - - # Apply postprocessing filters - pp_ids = chain.get("postprocessing_template_ids", []) - if pp_ids and pp_template_store: - pool = ImagePool() - for pp_id in pp_ids: - try: - pp = pp_template_store.get_template(pp_id) - for fi in pp_template_store.resolve_filter_instances(pp.filters): - f = FilterRegistry.create_instance(fi.filter_id, fi.options) - result = f.process_image(image, pool) - if result is not None: - image = result - except Exception: - pass - - # Extract colors from each rectangle - h, w = image.shape[:2] - calc_fns = { - "average": calculate_average_color, - "median": calculate_median_color, - "dominant": calculate_dominant_color, - } - calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) - - result_rects = [] - for rect in source.rectangles: - px_x = max(0, int(rect.x * w)) - px_y = max(0, int(rect.y * h)) - px_w = max(1, int(rect.width * w)) - px_h = max(1, int(rect.height * h)) - px_x, px_y = min(px_x, w - 1), min(px_y, h - 1) - px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y) - sub_img = image[px_y : px_y + px_h, px_x : px_x + px_w] - r, g, b = calc_fn(sub_img) - result_rects.append( - { - "name": rect.name, - "x": rect.x, - "y": rect.y, - "width": rect.width, - "height": rect.height, - "color": { - "r": int(r), - "g": int(g), - "b": int(b), - "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}", - }, - } - ) - - image_data_uri = encode_jpeg_data_uri(image, quality=90) - - return { - "image": image_data_uri, - "rectangles": result_rects, - "interpolation_mode": source.interpolation_mode, - } - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error("Key colors test failed: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - finally: - if stream: - try: - stream.stop() - except Exception: - pass - - -@router.websocket("/api/v1/color-strip-sources/{source_id}/key-colors/test/ws") -async def test_key_colors_ws( - websocket: WebSocket, - source_id: str, - token: str = Query(""), - fps: int = Query(3), - preview_width: int = Query(480), -): - """WebSocket for real-time key_colors test preview with frame + rectangle overlay.""" - import json as ws_json - import time as ws_time - from ledgrab.api.auth import verify_ws_token - from ledgrab.storage.color_strip_source import KeyColorsColorStripSource - from ledgrab.core.capture.screen_capture import ( - calculate_average_color, - calculate_dominant_color, - calculate_median_color, - ) - from ledgrab.storage.picture_source import ScreenCapturePictureSource - from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down - - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") - return - - store = get_color_strip_store() - source_store = get_picture_source_store() - manager = get_processor_manager() - device_store = get_device_store() - - try: - source = store.get_source(source_id) - except ValueError as e: - await websocket.close(code=4004, reason=str(e)) - return - - if not isinstance(source, KeyColorsColorStripSource): - await websocket.close(code=4003, reason="Not a key_colors source") - return - if not source.rectangles: - await websocket.close(code=4003, reason="No regions configured") - return - if not source.picture_source_id: - await websocket.close(code=4003, reason="No picture source configured") - return - - try: - chain = source_store.resolve_stream_chain(source.picture_source_id) - except ValueError as e: - await websocket.close(code=4003, reason=str(e)) - return - - raw_stream = chain["raw_stream"] - if isinstance(raw_stream, ScreenCapturePictureSource): - locked = manager.get_display_lock_info(raw_stream.display_index) - if locked: - try: - name = device_store.get_device(locked).name - except Exception: - name = locked - await websocket.close(code=4003, reason=f"Display captured by '{name}'") - return - - fps = max(1, min(30, fps)) - preview_width = max(120, min(1920, preview_width)) - frame_interval = 1.0 / fps - - calc_fns = { - "average": calculate_average_color, - "median": calculate_median_color, - "dominant": calculate_dominant_color, - } - calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) - - await websocket.accept() - logger.info(f"KC CSS test WS connected for {source_id} (fps={fps})") - - # Use LiveStreamManager — reuses the already-running capture engine when a - # target is active. BetterCam (DXGI) only supports one session per display, - # so creating a second engine would produce no frames. - live_stream_mgr = manager._live_stream_manager - try: - live_stream = await asyncio.to_thread(live_stream_mgr.acquire, source.picture_source_id) - except Exception as e: - logger.error(f"KC test: LiveStream acquire failed: {e}") - await websocket.send_text(ws_json.dumps({"type": "error", "detail": str(e)})) - await websocket.close(code=4003, reason=str(e)) - return - - try: - prev_frame_ref = None - - while True: - loop_start = ws_time.monotonic() - try: - capture = await asyncio.to_thread(live_stream.get_latest_frame) - if capture is None or capture.image is None: - await asyncio.sleep(frame_interval) - continue - if capture is prev_frame_ref: - await asyncio.sleep(frame_interval * 0.5) - continue - prev_frame_ref = capture - cur_image = capture.image - - if not isinstance(cur_image, np.ndarray): - await asyncio.sleep(frame_interval) - continue - - # NOTE: postprocessing is already applied by the ProcessedLiveStream - # when using LiveStreamManager.acquire() — do NOT re-apply here. - - # Re-read source for hot-update support - try: - source = store.get_source(source_id) - calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) - except Exception: - pass - - h, w = cur_image.shape[:2] - result_rects = [] - for rect in source.rectangles: - px_x = max(0, int(rect.x * w)) - px_y = max(0, int(rect.y * h)) - px_w = max(1, int(rect.width * w)) - px_h = max(1, int(rect.height * h)) - px_x, px_y = min(px_x, w - 1), min(px_y, h - 1) - px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y) - sub = cur_image[px_y : px_y + px_h, px_x : px_x + px_w] - r, g, b = calc_fn(sub) - result_rects.append( - { - "name": rect.name, - "x": rect.x, - "y": rect.y, - "width": rect.width, - "height": rect.height, - "color": { - "r": int(r), - "g": int(g), - "b": int(b), - "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}", - }, - } - ) - - frame_to_encode = resize_down(cur_image, preview_width) - frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85) - - await websocket.send_text( - ws_json.dumps( - { - "type": "frame", - "image": frame_uri, - "rectangles": result_rects, - "interpolation_mode": source.interpolation_mode, - } - ) - ) - - except (WebSocketDisconnect, Exception) as inner_e: - if isinstance(inner_e, WebSocketDisconnect): - raise - logger.warning(f"KC CSS test WS frame error: {inner_e}") - - elapsed = ws_time.monotonic() - loop_start - if frame_interval - elapsed > 0: - await asyncio.sleep(frame_interval - elapsed) - - except WebSocketDisconnect: - logger.info(f"KC CSS test WS disconnected for {source_id}") - except Exception as e: - logger.error(f"KC CSS test WS error: {e}", exc_info=True) - finally: - try: - await asyncio.to_thread(live_stream_mgr.release, source.picture_source_id) - except Exception: - pass - - -# ===== CALIBRATION TEST ===== - - -@router.put( - "/api/v1/color-strip-sources/{source_id}/calibration/test", - response_model=CalibrationTestModeResponse, - tags=["Color Strip Sources"], -) -async def test_css_calibration( - source_id: str, - body: CSSCalibrationTestRequest, - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Temporarily light up LED edges to verify calibration. - - Pass a device_id and an edges dict with RGB colors. - Send an empty edges dict to exit test mode. - """ - try: - # Validate device exists in manager - if not manager.has_device(body.device_id): - raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found") - - # Validate edge names and colors - valid_edges = {"top", "right", "bottom", "left"} - for edge_name, color in body.edges.items(): - if edge_name not in valid_edges: - raise HTTPException( - status_code=400, - detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}", - ) - if len(color) != 3 or not all(0 <= c <= 255 for c in color): - raise HTTPException( - status_code=400, - detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255.", - ) - - # Get CSS calibration to send the right pixel pattern - calibration = None - if body.edges: - try: - source = store.get_source(source_id) - if not isinstance( - source, (PictureColorStripSource, AdvancedPictureColorStripSource) - ): - raise HTTPException( - status_code=400, - detail="Calibration test is only available for picture color strip sources", - ) - if source.calibration: - calibration = source.calibration - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - await manager.set_test_mode(body.device_id, body.edges, calibration) - - active_edges = list(body.edges.keys()) - logger.info( - f"CSS calibration test mode {'activated' if active_edges else 'deactivated'} " - f"for device {body.device_id} via CSS {source_id}: {active_edges}" - ) - - return CalibrationTestModeResponse( - test_mode=len(active_edges) > 0, - active_edges=active_edges, - device_id=body.device_id, - ) - - except HTTPException: - raise - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error("Failed to set CSS calibration test mode: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - -# ===== OVERLAY VISUALIZATION ===== - - -@router.post("/api/v1/color-strip-sources/{source_id}/overlay/start", tags=["Color Strip Sources"]) -async def start_css_overlay( - source_id: str, - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - picture_source_store: PictureSourceStore = Depends(get_picture_source_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Start screen overlay visualization for a color strip source.""" - try: - source = store.get_source(source_id) - if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): - raise HTTPException( - status_code=400, detail="Overlay is only supported for picture color strip sources" - ) - if not source.calibration: - raise HTTPException( - status_code=400, detail="Color strip source has no calibration configured" - ) - - ps_id = getattr(source, "picture_source_id", "") or "" - display_index = _resolve_display_index(ps_id, picture_source_store) - displays = get_available_displays() - if not displays: - raise HTTPException(status_code=409, detail="No displays available") - display_index = min(display_index, len(displays) - 1) - display_info = displays[display_index] - - await manager.start_css_overlay(source_id, display_info, source.calibration, source.name) - return {"status": "started", "source_id": source_id} - - except HTTPException: - raise - except RuntimeError as e: - raise HTTPException(status_code=409, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - logger.error("Failed to start CSS overlay: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"]) -async def stop_css_overlay( - source_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Stop screen overlay visualization for a color strip source.""" - try: - await manager.stop_css_overlay(source_id) - return {"status": "stopped", "source_id": source_id} - except Exception as e: - logger.error("Failed to stop CSS overlay: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"]) -async def get_css_overlay_status( - source_id: str, - _auth: AuthRequired, - manager: ProcessorManager = Depends(get_processor_manager), -): - """Check if overlay is active for a color strip source.""" - return {"source_id": source_id, "active": manager.is_css_overlay_active(source_id)} - - -# ===== API INPUT: COLOR PUSH ===== - - -@router.post("/api/v1/color-strip-sources/{source_id}/colors", tags=["Color Strip Sources"]) -async def push_colors( - source_id: str, - body: ColorPushRequest, - _auth: AuthRequired, - store: ColorStripStore = Depends(get_color_strip_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Push raw LED colors to an api_input color strip source. - - Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based). - The payload is forwarded to all running stream instances for this source. - """ - try: - source = store.get_source(source_id) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - if not isinstance(source, ApiInputColorStripSource): - raise HTTPException(status_code=400, detail="Source is not an api_input type") - - streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) - - if body.segments is not None: - # Segment-based path - seg_dicts = [s.model_dump() for s in body.segments] - for stream in streams: - if hasattr(stream, "push_segments"): - stream.push_segments(seg_dicts) - return { - "status": "ok", - "streams_updated": len(streams), - "segments_applied": len(body.segments), - } - else: - # Legacy flat colors path - colors_array = np.array(body.colors, dtype=np.uint8) - if colors_array.ndim != 2 or colors_array.shape[1] != 3: - raise HTTPException( - status_code=400, detail="Colors must be an array of [R,G,B] triplets" - ) - for stream in streams: - if hasattr(stream, "push_colors"): - stream.push_colors(colors_array) - return { - "status": "ok", - "streams_updated": len(streams), - "leds_received": len(body.colors), - } - - -@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"]) -async def notify_source( - source_id: str, - _auth: AuthRequired, - body: NotifyRequest = None, - store: ColorStripStore = Depends(get_color_strip_store), - manager: ProcessorManager = Depends(get_processor_manager), -): - """Trigger a notification on a notification color strip source. - - Fires a one-shot visual effect (flash, pulse, sweep) on all running - stream instances for this source. Optionally specify an app name for - color lookup or a hex color override. - """ - try: - source = store.get_source(source_id) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - if not isinstance(source, NotificationColorStripSource): - raise HTTPException(status_code=400, detail="Source is not a notification type") - - app_name = body.app if body else None - color_override = body.color if body else None - - streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) - accepted = 0 - for stream in streams: - if hasattr(stream, "fire"): - if stream.fire(app_name=app_name, color_override=color_override): - accepted += 1 - - return { - "status": "ok", - "streams_notified": accepted, - "filtered": len(streams) - accepted, - } - - -@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"]) -async def os_notification_history(_auth: AuthRequired): - """Return recent OS notification capture history (newest first).""" - from ledgrab.core.processing.os_notification_listener import ( - get_os_notification_listener, - ) - - listener = get_os_notification_listener() - if listener is None: - return {"available": False, "history": []} - return { - "available": listener.available, - "history": listener.recent_history, - } - - -# ── Transient Preview WebSocket ──────────────────────────────────────── - -_PREVIEW_ALLOWED_TYPES = { - "static", - "gradient", - "color_cycle", - "effect", - "daylight", - "candlelight", - "notification", -} - - -@router.websocket("/api/v1/color-strip-sources/preview/ws") -async def preview_color_strip_ws( - websocket: WebSocket, - token: str = Query(""), - led_count: int = Query(100), - fps: int = Query(20), -): - """Transient preview WebSocket — stream frames for an ad-hoc source config. - - Auth via ``?token=&led_count=100&fps=20``. - - After accepting, waits for a text message containing the full source config - JSON (must include ``source_type``). Responds with a JSON metadata message, - then streams binary RGB frames at the requested FPS. - - Subsequent text messages are treated as config updates: if the source_type - changed the old stream is replaced; otherwise ``update_source()`` is used. - """ - from ledgrab.api.auth import verify_ws_token - - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") - return - - await websocket.accept() - - led_count = max(1, min(1000, led_count)) - fps = max(1, min(60, fps)) - frame_interval = 1.0 / fps - - stream = None - clock_id = None - current_source_type = None - - # Helpers ──────────────────────────────────────────────────────────── - - def _get_sync_clock_manager(): - """Return the SyncClockManager if available.""" - try: - mgr = get_processor_manager() - return getattr(mgr, "_sync_clock_manager", None) - except Exception as e: - logger.debug("SyncClockManager not available: %s", e) - return None - - def _build_source(config: dict): - """Build a ColorStripSource from a raw config dict, injecting synthetic id/name.""" - from ledgrab.storage.color_strip_source import ColorStripSource - - config.setdefault("id", "__preview__") - config.setdefault("name", "__preview__") - return ColorStripSource.from_dict(config) - - def _create_stream(source): - """Instantiate and start the appropriate stream class for *source*.""" - from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP - - stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) - if not stream_cls: - raise ValueError(f"Unsupported preview source_type: {source.source_type}") - s = stream_cls(source) - # Inject gradient store for palette resolution - if hasattr(s, "set_gradient_store"): - try: - from ledgrab.api.dependencies import get_gradient_store - - s.set_gradient_store(get_gradient_store()) - except Exception: - pass - if hasattr(s, "configure"): - s.configure(led_count) - # Inject sync clock if requested - cid = getattr(source, "clock_id", None) - if cid and hasattr(s, "set_clock"): - scm = _get_sync_clock_manager() - if scm: - try: - clock_rt = scm.acquire(cid) - s.set_clock(clock_rt) - except Exception as e: - logger.warning(f"Preview: could not acquire clock {cid}: {e}") - cid = None - else: - cid = None - else: - cid = None - s.start() - return s, cid - - def _stop_stream(s, cid): - """Stop a stream and release its clock.""" - try: - s.stop() - except Exception: - pass - if cid: - scm = _get_sync_clock_manager() - if scm: - try: - scm.release(cid) - except Exception: - pass - - async def _send_meta(source_type: str): - meta = {"type": "meta", "led_count": led_count, "source_type": source_type} - await websocket.send_text(_json.dumps(meta)) - - # Wait for initial config ──────────────────────────────────────────── - - try: - initial_text = await websocket.receive_text() - except WebSocketDisconnect: - return - except Exception: - return - - try: - config = _json.loads(initial_text) - source_type = config.get("source_type") - if source_type not in _PREVIEW_ALLOWED_TYPES: - await websocket.send_text( - _json.dumps( - { - "type": "error", - "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}", - } - ) - ) - await websocket.close(code=4003, reason="Invalid source_type") - return - source = _build_source(config) - stream, clock_id = _create_stream(source) - current_source_type = source_type - except Exception as e: - logger.error(f"Preview WS: bad initial config: {e}") - await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) - await websocket.close(code=4003, reason=str(e)) - return - - await _send_meta(current_source_type) - logger.info( - f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}" - ) - - # Frame loop ───────────────────────────────────────────────────────── - - try: - while True: - # Non-blocking check for incoming config updates - try: - msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval) - except asyncio.TimeoutError: - msg = None - except WebSocketDisconnect: - break - - if msg is not None: - try: - new_config = _json.loads(msg) - - # Handle "fire" command for notification streams - if new_config.get("action") == "fire": - from ledgrab.core.processing.notification_stream import ( - NotificationColorStripStream, - ) - - if isinstance(stream, NotificationColorStripStream): - stream.fire( - app_name=new_config.get("app", ""), - color_override=new_config.get("color"), - ) - continue - - new_type = new_config.get("source_type") - if new_type not in _PREVIEW_ALLOWED_TYPES: - await websocket.send_text( - _json.dumps( - { - "type": "error", - "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}", - } - ) - ) - continue - new_source = _build_source(new_config) - if new_type != current_source_type: - # Source type changed — recreate stream - _stop_stream(stream, clock_id) - stream, clock_id = _create_stream(new_source) - current_source_type = new_type - else: - stream.update_source(new_source) - if hasattr(stream, "configure"): - stream.configure(led_count) - await _send_meta(current_source_type) - except Exception as e: - logger.warning(f"Preview WS: bad config update: {e}") - await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) - - # Send frame - colors = stream.get_latest_colors() - if colors is not None: - await websocket.send_bytes(colors.tobytes()) - else: - # Stream hasn't produced a frame yet — send black - await websocket.send_bytes(b"\x00" * led_count * 3) - - except WebSocketDisconnect: - pass - except Exception as e: - logger.error(f"Preview WS error: {e}") - finally: - if stream is not None: - _stop_stream(stream, clock_id) - logger.info("Preview WS disconnected") - - -@router.websocket("/api/v1/color-strip-sources/{source_id}/ws") -async def css_api_input_ws( - websocket: WebSocket, - source_id: str, - token: str = Query(""), -): - """WebSocket for pushing raw LED colors to an api_input source. - - Auth via ?token=. Accepts JSON frames ({"colors": [[R,G,B], ...]}) - or binary frames (raw RGBRGB... bytes, 3 bytes per LED). - """ - from ledgrab.api.auth import verify_ws_token - - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") - return - - # Validate source exists and is api_input type - manager = get_processor_manager() - try: - store = get_color_strip_store() - source = store.get_source(source_id) - except (ValueError, RuntimeError): - await websocket.close(code=4004, reason="Source not found") - return - - if not isinstance(source, ApiInputColorStripSource): - await websocket.close(code=4003, reason="Source is not api_input type") - return - - await websocket.accept() - logger.info(f"API input WebSocket connected for source {source_id}") - - try: - while True: - message = await websocket.receive() - - if message.get("type") == "websocket.disconnect": - break - - if "text" in message: - # JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]} - import json - - try: - data = json.loads(message["text"]) - except (json.JSONDecodeError, ValueError) as e: - await websocket.send_json({"error": str(e)}) - continue - - if "segments" in data: - # Segment-based path — validate and push - try: - from ledgrab.api.schemas.color_strip_sources import SegmentPayload - - seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]] - except Exception as e: - await websocket.send_json({"error": f"Invalid segment: {e}"}) - continue - streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) - for stream in streams: - if hasattr(stream, "push_segments"): - stream.push_segments(seg_dicts) - continue - - elif "colors" in data: - try: - raw_colors = data["colors"] - colors_array = np.array(raw_colors, dtype=np.uint8) - if colors_array.ndim != 2 or colors_array.shape[1] != 3: - await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"}) - continue - except (ValueError, TypeError) as e: - await websocket.send_json({"error": str(e)}) - continue - else: - await websocket.send_json( - {"error": "JSON frame must contain 'colors' or 'segments'"} - ) - continue - - elif "bytes" in message: - # Binary frame: raw RGBRGB... bytes (3 bytes per LED) - raw_bytes = message["bytes"] - if len(raw_bytes) % 3 != 0: - await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"}) - continue - colors_array = np.frombuffer(raw_bytes, dtype=np.uint8).reshape(-1, 3) - - else: - continue - - # Push to all running streams (colors_array path only reaches here) - streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) - for stream in streams: - if hasattr(stream, "push_colors"): - stream.push_colors(colors_array) - - except WebSocketDisconnect: - pass - except Exception as e: - logger.error(f"API input WebSocket error for source {source_id}: {e}") - finally: - logger.info(f"API input WebSocket disconnected for source {source_id}") - - -# ── Test / Preview WebSocket ────────────────────────────────────────── - - -@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws") -async def test_color_strip_ws( - websocket: WebSocket, - source_id: str, - token: str = Query(""), - led_count: int = Query(100), - fps: int = Query(20), -): - """WebSocket for real-time CSS source preview. Auth via ``?token=``. - - First message is JSON metadata (source_type, led_count, calibration segments). - Subsequent messages are binary RGB frames (``led_count * 3`` bytes). - """ - from ledgrab.api.auth import verify_ws_token - - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") - return - - # Validate source exists - store: ColorStripStore = get_color_strip_store() - try: - source = store.get_source(source_id) - except ValueError as e: - await websocket.close(code=4004, reason=str(e)) - return - - # Acquire stream – unique consumer ID per WS to avoid release races - manager: ProcessorManager = get_processor_manager() - csm = manager.color_strip_stream_manager - consumer_id = f"__test_{_uuid.uuid4().hex[:8]}__" - try: - stream = csm.acquire(source_id, consumer_id) - except Exception as e: - logger.error(f"CSS test: failed to acquire stream for {source_id}: {e}") - await websocket.close(code=4003, reason=str(e)) - return - - # Configure LED count for auto-sizing streams - if hasattr(stream, "configure"): - stream.configure(max(1, led_count)) - - # Reject picture sources with 0 calibration LEDs (no edges configured) - if stream.led_count <= 0: - csm.release(source_id, consumer_id) - await websocket.close( - code=4005, - reason="No LEDs configured. Open Calibration and set LED counts for each edge.", - ) - return - - # Clamp FPS to sane range - fps = max(1, min(60, fps)) - _frame_interval = 1.0 / fps - - await websocket.accept() - logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})") - - try: - from ledgrab.core.processing.composite_stream import CompositeColorStripStream - - from ledgrab.core.processing.api_input_stream import ApiInputColorStripStream - - is_api_input = isinstance(stream, ApiInputColorStripStream) - _last_push_gen = 0 # track api_input push generation to skip unchanged frames - - # Send metadata as first message - is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) - is_composite = isinstance(source, CompositeColorStripSource) - meta: dict = { - "type": "meta", - "source_type": source.source_type, - "source_name": source.name, - "led_count": stream.led_count, - } - if is_picture and stream.calibration: - cal = stream.calibration - total = cal.get_total_leds() - offset = cal.offset % total if total > 0 else 0 - edges = [] - for seg in cal.segments: - # Compute output indices matching PixelMapper logic - indices = list(range(seg.led_start, seg.led_start + seg.led_count)) - if seg.reverse: - indices = indices[::-1] - if offset > 0: - indices = [(idx + offset) % total for idx in indices] - edges.append({"edge": seg.edge, "indices": indices}) - meta["edges"] = edges - meta["border_width"] = cal.border_width - if is_composite and hasattr(source, "layers"): - # Send layer info for composite preview - enabled_layers = [layer for layer in source.layers if layer.get("enabled", True)] - layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...] - for layer in enabled_layers: - info = { - "id": layer["source_id"], - "name": layer.get("source_id", "?"), - "is_notification": False, - "has_brightness": bool(layer.get("brightness_source_id")), - } - try: - layer_src = store.get_source(layer["source_id"]) - info["name"] = layer_src.name - info["is_notification"] = isinstance(layer_src, NotificationColorStripSource) - if isinstance( - layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource) - ): - info["is_picture"] = True - if hasattr(layer_src, "calibration") and layer_src.calibration: - info["calibration_led_count"] = layer_src.calibration.get_total_leds() - except (ValueError, KeyError): - pass - layer_infos.append(info) - meta["layers"] = [li["name"] for li in layer_infos] - meta["layer_infos"] = layer_infos - await websocket.send_text(_json.dumps(meta)) - - # For api_input: send the current buffer immediately so the client - # gets a frame right away (fallback color if inactive) rather than - # leaving the canvas blank/stale until external data arrives. - if is_api_input: - initial_colors = stream.get_latest_colors() - if initial_colors is not None: - await websocket.send_bytes(initial_colors.tobytes()) - - # For picture sources, grab the live stream for frame preview - _frame_live = None - if is_picture and hasattr(stream, "live_stream"): - _frame_live = stream.live_stream - _last_aux_time = 0.0 - _AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS - _frame_dims_sent = False # send frame dimensions once with first JPEG - - # Stream binary RGB frames at ~20 Hz - while True: - # For composite sources, send per-layer data like target preview does - if is_composite and isinstance(stream, CompositeColorStripStream): - layer_colors = stream.get_layer_colors() - composite_colors = stream.get_latest_colors() - if composite_colors is not None and layer_colors and len(layer_colors) > 1: - led_count = composite_colors.shape[0] - rgb_size = led_count * 3 - # Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb] - header = bytes( - [0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF] - ) - parts = [header] - for lc in layer_colors: - if lc is not None and lc.shape[0] == led_count: - parts.append(lc.tobytes()) - else: - parts.append(b"\x00" * rgb_size) - parts.append(composite_colors.tobytes()) - await websocket.send_bytes(b"".join(parts)) - elif composite_colors is not None: - await websocket.send_bytes(composite_colors.tobytes()) - else: - # For api_input: only send when new data was pushed - if is_api_input: - gen = stream.push_generation - if gen != _last_push_gen: - _last_push_gen = gen - colors = stream.get_latest_colors() - if colors is not None: - await websocket.send_bytes(colors.tobytes()) - else: - colors = stream.get_latest_colors() - if colors is not None: - await websocket.send_bytes(colors.tobytes()) - - # Periodically send auxiliary data (frame preview, brightness) - now = _time.monotonic() - if now - _last_aux_time >= _AUX_INTERVAL: - _last_aux_time = now - - # Send brightness values for composite layers - if is_composite and isinstance(stream, CompositeColorStripStream): - try: - bri_values = stream.get_layer_brightness() - if any(v is not None for v in bri_values): - bri_msg = { - "type": "brightness", - "values": [ - round(v * 100) if v is not None else None for v in bri_values - ], - } - await websocket.send_text(_json.dumps(bri_msg)) - except Exception: - pass - - # Send JPEG frame preview for picture sources - if _frame_live: - try: - frame = _frame_live.get_latest_frame() - if frame is not None and frame.image is not None: - from ledgrab.utils.image_codec import encode_jpeg, resize_image - - img = frame.image - # Ensure 3-channel RGB (some engines may produce BGRA) - if img.ndim == 3 and img.shape[2] == 4: - img = img[:, :, :3] - h, w = img.shape[:2] - # Send frame dimensions once so client can compute border overlay - if not _frame_dims_sent: - _frame_dims_sent = True - await websocket.send_text( - _json.dumps( - { - "type": "frame_dims", - "width": w, - "height": h, - } - ) - ) - # Downscale for bandwidth - scale = min(960 / w, 540 / h, 1.0) - if scale < 1.0: - new_w = max(1, int(w * scale)) - new_h = max(1, int(h * scale)) - img = resize_image(img, new_w, new_h) - # Wire format: [0xFD] [jpeg_bytes] - await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70)) - except Exception as e: - logger.warning(f"JPEG frame preview error: {e}") - - await asyncio.sleep(_frame_interval) - except WebSocketDisconnect: - pass - except Exception as e: - logger.error(f"CSS test WebSocket error for {source_id}: {e}") - finally: - csm.release(source_id, consumer_id) - logger.info(f"CSS test WebSocket disconnected for {source_id}") diff --git a/server/src/ledgrab/api/routes/color_strip_sources/__init__.py b/server/src/ledgrab/api/routes/color_strip_sources/__init__.py new file mode 100644 index 0000000..8cdb95e --- /dev/null +++ b/server/src/ledgrab/api/routes/color_strip_sources/__init__.py @@ -0,0 +1,38 @@ +"""Color strip source routes — split into focused submodules. + +Aggregated `router` is exported so that +`from ledgrab.api.routes.color_strip_sources import router` works as before. +""" + +from fastapi import APIRouter + +from ._helpers import ( + _calibration_schema, + _common_response_kwargs, + _css_to_response, + _extract_css_kwargs, + _resolve_display_index, + _RESPONSE_MAP, + _stops_schema, +) +from .calibration import router as _calibration_router +from .crud import router as _crud_router +from .preview import router as _preview_router +from .ws_stream import router as _ws_stream_router + +router = APIRouter() +router.include_router(_crud_router) +router.include_router(_calibration_router) +router.include_router(_preview_router) +router.include_router(_ws_stream_router) + +__all__ = [ + "router", + "_calibration_schema", + "_common_response_kwargs", + "_css_to_response", + "_extract_css_kwargs", + "_resolve_display_index", + "_RESPONSE_MAP", + "_stops_schema", +] diff --git a/server/src/ledgrab/api/routes/color_strip_sources/_helpers.py b/server/src/ledgrab/api/routes/color_strip_sources/_helpers.py new file mode 100644 index 0000000..f05fcdf --- /dev/null +++ b/server/src/ledgrab/api/routes/color_strip_sources/_helpers.py @@ -0,0 +1,297 @@ +"""Shared helpers for color strip source routes.""" + +from ledgrab.api.schemas.color_strip_sources import ( + ApiInputCSSResponse, + AudioCSSResponse, + CandlelightCSSResponse, + ColorCycleCSSResponse, + ColorStop as ColorStopSchema, + ColorStripSourceResponse, + CompositeCSSResponse, + DaylightCSSResponse, + EffectCSSResponse, + GradientCSSResponse, + KeyColorsCSSResponse, + MappedCSSResponse, + MathWaveCSSResponse, + NotificationCSSResponse, + PictureAdvancedCSSResponse, + PictureCSSResponse, + ProcessedCSSResponse, + StaticCSSResponse, + WeatherCSSResponse, +) +from ledgrab.api.schemas.devices import Calibration as CalibrationSchema +from ledgrab.core.capture.calibration import ( + calibration_from_dict, + calibration_to_dict, +) +from ledgrab.storage.color_strip_source import ( + AdvancedPictureColorStripSource, + ApiInputColorStripSource, + AudioColorStripSource, + CandlelightColorStripSource, + ColorCycleColorStripSource, + CompositeColorStripSource, + DaylightColorStripSource, + EffectColorStripSource, + GradientColorStripSource, + KeyColorsColorStripSource, + MappedColorStripSource, + MathWaveColorStripSource, + NotificationColorStripSource, + PictureColorStripSource, + ProcessedColorStripSource, + StaticColorStripSource, + WeatherColorStripSource, +) +from ledgrab.storage.picture_source import ( + ProcessedPictureSource, + ScreenCapturePictureSource, +) +from ledgrab.storage.picture_source_store import PictureSourceStore +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + + +def _common_response_kwargs(source, overlay_active: bool = False) -> dict: + """Shared response fields from any ColorStripSource.""" + return dict( + id=source.id, + name=source.name, + description=source.description, + led_count=getattr(source, "led_count", 0), + overlay_active=overlay_active, + clock_id=source.clock_id, + tags=source.tags, + created_at=source.created_at, + updated_at=source.updated_at, + ) + + +def _calibration_schema(source) -> CalibrationSchema | None: + """Convert a source's calibration to a schema object, or None.""" + cal = getattr(source, "calibration", None) + if cal is None: + return None + try: + return CalibrationSchema(**calibration_to_dict(cal)) + except Exception as e: + logger.debug("calibration schema build failed: %s", e) + return None + + +def _stops_schema(source) -> list[ColorStopSchema] | None: + """Convert a source's stops list to schema objects, or None.""" + raw = getattr(source, "stops", None) + if raw is None: + return None + try: + return [ColorStopSchema(**dict(s)) for s in raw] + except Exception as e: + logger.debug("stops schema build failed: %s", e) + return None + + +# Maps storage class → response builder lambda. +_RESPONSE_MAP: dict = { + PictureColorStripSource: lambda s, kw: PictureCSSResponse( + **kw, + picture_source_id=s.picture_source_id, + smoothing=s.smoothing.to_dict(), + interpolation_mode=s.interpolation_mode, + calibration=_calibration_schema(s), + ), + AdvancedPictureColorStripSource: lambda s, kw: PictureAdvancedCSSResponse( + **kw, + smoothing=s.smoothing.to_dict(), + interpolation_mode=s.interpolation_mode, + calibration=_calibration_schema(s), + ), + StaticColorStripSource: lambda s, kw: StaticCSSResponse( + **kw, + color=s.color.to_dict(), + animation=s.animation, + ), + GradientColorStripSource: lambda s, kw: GradientCSSResponse( + **kw, + stops=_stops_schema(s), + animation=s.animation, + easing=s.easing, + gradient_id=s.gradient_id, + ), + ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse( + **kw, + colors=[list(c) for c in s.colors], + ), + EffectColorStripSource: lambda s, kw: EffectCSSResponse( + **kw, + effect_type=s.effect_type, + palette=s.palette, + gradient_id=s.gradient_id, + color=s.color.to_dict(), + intensity=s.intensity.to_dict(), + scale=s.scale.to_dict(), + mirror=s.mirror, + custom_palette=s.custom_palette, + ), + CompositeColorStripSource: lambda s, kw: CompositeCSSResponse( + **kw, + layers=[dict(layer) for layer in s.layers], + ), + MappedColorStripSource: lambda s, kw: MappedCSSResponse( + **kw, + zones=[dict(z) for z in s.zones], + ), + AudioColorStripSource: lambda s, kw: AudioCSSResponse( + **kw, + visualization_mode=s.visualization_mode, + audio_source_id=s.audio_source_id, + sensitivity=s.sensitivity.to_dict(), + smoothing=s.smoothing.to_dict(), + palette=s.palette, + gradient_id=s.gradient_id, + color=s.color.to_dict(), + color_peak=s.color_peak.to_dict(), + mirror=s.mirror, + beat_decay=s.beat_decay.to_dict(), + ), + ApiInputColorStripSource: lambda s, kw: ApiInputCSSResponse( + **kw, + fallback_color=s.fallback_color.to_dict(), + timeout=s.timeout.to_dict(), + interpolation=s.interpolation, + ), + NotificationColorStripSource: lambda s, kw: NotificationCSSResponse( + **kw, + notification_effect=s.notification_effect, + duration_ms=s.duration_ms.to_dict(), + default_color=s.default_color.to_dict(), + app_colors=dict(s.app_colors), + app_filter_mode=s.app_filter_mode, + app_filter_list=list(s.app_filter_list), + os_listener=s.os_listener, + sound_asset_id=s.sound_asset_id, + sound_volume=s.sound_volume.to_dict(), + app_sounds=dict(s.app_sounds), + ), + DaylightColorStripSource: lambda s, kw: DaylightCSSResponse( + **kw, + speed=s.speed.to_dict(), + use_real_time=s.use_real_time, + latitude=s.latitude, + longitude=s.longitude, + ), + CandlelightColorStripSource: lambda s, kw: CandlelightCSSResponse( + **kw, + color=s.color.to_dict(), + intensity=s.intensity.to_dict(), + num_candles=s.num_candles, + speed=s.speed.to_dict(), + wind_strength=s.wind_strength.to_dict(), + candle_type=s.candle_type, + ), + ProcessedColorStripSource: lambda s, kw: ProcessedCSSResponse( + **kw, + input_source_id=s.input_source_id, + processing_template_id=s.processing_template_id, + ), + WeatherColorStripSource: lambda s, kw: WeatherCSSResponse( + **kw, + weather_source_id=s.weather_source_id, + speed=s.speed.to_dict(), + temperature_influence=s.temperature_influence.to_dict(), + ), + KeyColorsColorStripSource: lambda s, kw: KeyColorsCSSResponse( + **kw, + picture_source_id=s.picture_source_id, + rectangles=[r.to_dict() for r in s.rectangles], + interpolation_mode=s.interpolation_mode, + smoothing=s.smoothing.to_dict(), + brightness=s.brightness.to_dict(), + ), + MathWaveColorStripSource: lambda s, kw: MathWaveCSSResponse( + **kw, + waves=s.waves, + speed=s.speed.to_dict(), + gradient_id=s.gradient_id, + ), +} + + +def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse: + """Convert a ColorStripSource to the matching per-type response schema.""" + kw = _common_response_kwargs(source, overlay_active) + builder = _RESPONSE_MAP.get(type(source)) + if builder is None: + # Fallback: use to_dict() and build a PictureCSSResponse + logger.warning("No response builder for %s, falling back", type(source).__name__) + return PictureCSSResponse( + **kw, + picture_source_id="", + smoothing=0.3, + interpolation_mode="average", + calibration=None, + ) + return builder(source, kw) + + +def _resolve_display_index( + picture_source_id: str, picture_source_store: PictureSourceStore, depth: int = 0 +) -> int: + """Resolve display index from a picture source, following processed source chains.""" + if not picture_source_id or depth > 5: + return 0 + try: + ps = picture_source_store.get_stream(picture_source_id) + except Exception as e: + logger.debug( + "Failed to resolve display index for picture source %s: %s", picture_source_id, e + ) + return 0 + if isinstance(ps, ScreenCapturePictureSource): + return ps.display_index + if isinstance(ps, ProcessedPictureSource): + return _resolve_display_index(ps.source_stream_id, picture_source_store, depth + 1) + return 0 + + +def _extract_css_kwargs(data) -> dict: + """Extract store-compatible kwargs from a Pydantic CSS create/update schema. + + Converts nested Pydantic models (calibration, stops, layers, zones, + animation) to plain dicts/lists that the store expects. + """ + # Exclude nested models that need special conversion + exclude_fields = {"source_type"} + for nested in ("calibration", "stops", "layers", "zones", "animation"): + if hasattr(data, nested): + exclude_fields.add(nested) + kwargs = data.model_dump(exclude_unset=False, exclude=exclude_fields) + + # Convert nested Pydantic models → plain dicts for the store + if hasattr(data, "calibration"): + cal = getattr(data, "calibration", None) + if cal is not None: + kwargs["calibration"] = calibration_from_dict(cal.model_dump()) + else: + kwargs["calibration"] = None + + if hasattr(data, "stops"): + stops = getattr(data, "stops", None) + kwargs["stops"] = [s.model_dump() for s in stops] if stops is not None else None + + if hasattr(data, "layers"): + layers = getattr(data, "layers", None) + kwargs["layers"] = [layer.model_dump() for layer in layers] if layers is not None else None + + if hasattr(data, "zones"): + zones = getattr(data, "zones", None) + kwargs["zones"] = [z.model_dump() for z in zones] if zones is not None else None + + if hasattr(data, "animation"): + anim = getattr(data, "animation", None) + kwargs["animation"] = anim.model_dump() if anim else None + + return kwargs diff --git a/server/src/ledgrab/api/routes/color_strip_sources/calibration.py b/server/src/ledgrab/api/routes/color_strip_sources/calibration.py new file mode 100644 index 0000000..eacc14d --- /dev/null +++ b/server/src/ledgrab/api/routes/color_strip_sources/calibration.py @@ -0,0 +1,95 @@ +"""Calibration test endpoint for color strip sources.""" + +from fastapi import APIRouter, Depends, HTTPException + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import get_color_strip_store, get_processor_manager +from ledgrab.api.schemas.color_strip_sources import CSSCalibrationTestRequest +from ledgrab.api.schemas.devices import CalibrationTestModeResponse +from ledgrab.core.processing.processor_manager import ProcessorManager +from ledgrab.storage.color_strip_source import ( + AdvancedPictureColorStripSource, + PictureColorStripSource, +) +from ledgrab.storage.color_strip_store import ColorStripStore +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +@router.put( + "/api/v1/color-strip-sources/{source_id}/calibration/test", + response_model=CalibrationTestModeResponse, + tags=["Color Strip Sources"], +) +async def test_css_calibration( + source_id: str, + body: CSSCalibrationTestRequest, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Temporarily light up LED edges to verify calibration. + + Pass a device_id and an edges dict with RGB colors. + Send an empty edges dict to exit test mode. + """ + try: + # Validate device exists in manager + if not manager.has_device(body.device_id): + raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found") + + # Validate edge names and colors + valid_edges = {"top", "right", "bottom", "left"} + for edge_name, color in body.edges.items(): + if edge_name not in valid_edges: + raise HTTPException( + status_code=400, + detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}", + ) + if len(color) != 3 or not all(0 <= c <= 255 for c in color): + raise HTTPException( + status_code=400, + detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255.", + ) + + # Get CSS calibration to send the right pixel pattern + calibration = None + if body.edges: + try: + source = store.get_source(source_id) + if not isinstance( + source, (PictureColorStripSource, AdvancedPictureColorStripSource) + ): + raise HTTPException( + status_code=400, + detail="Calibration test is only available for picture color strip sources", + ) + if source.calibration: + calibration = source.calibration + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await manager.set_test_mode(body.device_id, body.edges, calibration) + + active_edges = list(body.edges.keys()) + logger.info( + f"CSS calibration test mode {'activated' if active_edges else 'deactivated'} " + f"for device {body.device_id} via CSS {source_id}: {active_edges}" + ) + + return CalibrationTestModeResponse( + test_mode=len(active_edges) > 0, + active_edges=active_edges, + device_id=body.device_id, + ) + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error("Failed to set CSS calibration test mode: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/server/src/ledgrab/api/routes/color_strip_sources/crud.py b/server/src/ledgrab/api/routes/color_strip_sources/crud.py new file mode 100644 index 0000000..3638be6 --- /dev/null +++ b/server/src/ledgrab/api/routes/color_strip_sources/crud.py @@ -0,0 +1,390 @@ +"""CRUD endpoints for color strip sources, plus overlay/colors/notify control.""" + +from typing import Annotated + +import numpy as np +from fastapi import APIRouter, Body, Depends, HTTPException + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import ( + fire_entity_event, + get_color_strip_store, + get_picture_source_store, + get_output_target_store, + get_processor_manager, +) +from ledgrab.api.schemas.color_strip_sources import ( + ColorPushRequest, + ColorStripSourceCreate, + ColorStripSourceListResponse, + ColorStripSourceResponse, + ColorStripSourceUpdate, + NotifyRequest, +) +from ledgrab.core.capture.screen_capture import get_available_displays +from ledgrab.core.processing.processor_manager import ProcessorManager +from ledgrab.storage.base_store import EntityNotFoundError +from ledgrab.storage.color_strip_source import ( + AdvancedPictureColorStripSource, + ApiInputColorStripSource, + NotificationColorStripSource, + PictureColorStripSource, +) +from ledgrab.storage.color_strip_store import ColorStripStore +from ledgrab.storage.output_target_store import OutputTargetStore +from ledgrab.storage.picture_source_store import PictureSourceStore +from ledgrab.utils import get_logger + +from ._helpers import _css_to_response, _extract_css_kwargs, _resolve_display_index + +logger = get_logger(__name__) + +router = APIRouter() + + +@router.get( + "/api/v1/color-strip-sources", + response_model=ColorStripSourceListResponse, + tags=["Color Strip Sources"], +) +async def list_color_strip_sources( + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """List all color strip sources.""" + sources = store.get_all_sources() + responses = [_css_to_response(s, manager.is_css_overlay_active(s.id)) for s in sources] + return ColorStripSourceListResponse(sources=responses, count=len(responses)) + + +@router.post( + "/api/v1/color-strip-sources", + response_model=ColorStripSourceResponse, + tags=["Color Strip Sources"], + status_code=201, +) +async def create_color_strip_source( + data: Annotated[ColorStripSourceCreate, Body(discriminator="source_type")], + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), +): + """Create a new color strip source.""" + try: + kwargs = _extract_css_kwargs(data) + + # Validate nesting for composite/mapped sources before creating + if data.source_type == "composite" and kwargs.get("layers"): + child_ids = [ly.get("source_id", "") for ly in kwargs["layers"] if ly.get("source_id")] + from ledgrab.storage.color_strip_store import MAX_COMPOSITE_DEPTH + + for cid in child_ids: + depth = store.get_nesting_depth(cid) + if 1 + depth > MAX_COMPOSITE_DEPTH: + raise ValueError( + f"Nesting depth {1 + depth} exceeds maximum of {MAX_COMPOSITE_DEPTH}" + ) + + source = store.create_source(source_type=data.source_type, **kwargs) + fire_entity_event("color_strip_source", "created", source.id) + return _css_to_response(source) + + except EntityNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Failed to create color strip source: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.get( + "/api/v1/color-strip-sources/{source_id}", + response_model=ColorStripSourceResponse, + tags=["Color Strip Sources"], +) +async def get_color_strip_source( + source_id: str, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Get a color strip source by ID.""" + try: + source = store.get_source(source_id) + return _css_to_response(source, manager.is_css_overlay_active(source_id)) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put( + "/api/v1/color-strip-sources/{source_id}", + response_model=ColorStripSourceResponse, + tags=["Color Strip Sources"], +) +async def update_color_strip_source( + source_id: str, + data: Annotated[ColorStripSourceUpdate, Body(discriminator="source_type")], + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Update a color strip source and hot-reload any running streams.""" + try: + kwargs = _extract_css_kwargs(data) + + # Validate nesting for composite sources before updating + if data.source_type == "composite" and kwargs.get("layers") is not None: + child_ids = [ly.get("source_id", "") for ly in kwargs["layers"] if ly.get("source_id")] + store.validate_nesting(source_id, child_ids) + + source = store.update_source(source_id=source_id, **kwargs) + + # Hot-reload running stream (no restart needed for in-place param changes) + try: + manager.color_strip_stream_manager.update_source(source_id, source) + except Exception as e: + logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}") + + fire_entity_event("color_strip_source", "updated", source_id) + return _css_to_response(source) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error("Failed to update color strip source: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.delete( + "/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"] +) +async def delete_color_strip_source( + source_id: str, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + target_store: OutputTargetStore = Depends(get_output_target_store), +): + """Delete a color strip source. Returns 409 if referenced by any LED target.""" + try: + target_names = target_store.get_targets_referencing_css(source_id) + if target_names: + names = ", ".join(target_names) + raise HTTPException( + status_code=409, + detail=f"Color strip source is referenced by target(s): {names}. " + "Delete or reassign the target(s) first.", + ) + composite_names = store.get_composites_referencing(source_id) + if composite_names: + names = ", ".join(composite_names) + raise HTTPException( + status_code=409, + detail=f"Color strip source is used as a layer in composite source(s): {names}. " + "Remove it from the composite(s) first.", + ) + mapped_names = store.get_mapped_referencing(source_id) + if mapped_names: + names = ", ".join(mapped_names) + raise HTTPException( + status_code=409, + detail=f"Color strip source is used as a zone in mapped source(s): {names}. " + "Remove it from the mapped source(s) first.", + ) + processed_names = store.get_processed_referencing(source_id) + if processed_names: + names = ", ".join(processed_names) + raise HTTPException( + status_code=409, + detail=f"Color strip source is used as input in processed source(s): {names}. " + "Delete or reassign the processed source(s) first.", + ) + store.delete_source(source_id) + fire_entity_event("color_strip_source", "deleted", source_id) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error("Failed to delete color strip source: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +# ===== OVERLAY VISUALIZATION ===== + + +@router.post("/api/v1/color-strip-sources/{source_id}/overlay/start", tags=["Color Strip Sources"]) +async def start_css_overlay( + source_id: str, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + picture_source_store: PictureSourceStore = Depends(get_picture_source_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Start screen overlay visualization for a color strip source.""" + try: + source = store.get_source(source_id) + if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): + raise HTTPException( + status_code=400, detail="Overlay is only supported for picture color strip sources" + ) + if not source.calibration: + raise HTTPException( + status_code=400, detail="Color strip source has no calibration configured" + ) + + ps_id = getattr(source, "picture_source_id", "") or "" + display_index = _resolve_display_index(ps_id, picture_source_store) + displays = get_available_displays() + if not displays: + raise HTTPException(status_code=409, detail="No displays available") + display_index = min(display_index, len(displays) - 1) + display_info = displays[display_index] + + await manager.start_css_overlay(source_id, display_info, source.calibration, source.name) + return {"status": "started", "source_id": source_id} + + except HTTPException: + raise + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error("Failed to start CSS overlay: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"]) +async def stop_css_overlay( + source_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Stop screen overlay visualization for a color strip source.""" + try: + await manager.stop_css_overlay(source_id) + return {"status": "stopped", "source_id": source_id} + except Exception as e: + logger.error("Failed to stop CSS overlay: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"]) +async def get_css_overlay_status( + source_id: str, + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Check if overlay is active for a color strip source.""" + return {"source_id": source_id, "active": manager.is_css_overlay_active(source_id)} + + +# ===== API INPUT: COLOR PUSH ===== + + +@router.post("/api/v1/color-strip-sources/{source_id}/colors", tags=["Color Strip Sources"]) +async def push_colors( + source_id: str, + body: ColorPushRequest, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Push raw LED colors to an api_input color strip source. + + Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based). + The payload is forwarded to all running stream instances for this source. + """ + try: + source = store.get_source(source_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + if not isinstance(source, ApiInputColorStripSource): + raise HTTPException(status_code=400, detail="Source is not an api_input type") + + streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) + + if body.segments is not None: + # Segment-based path + seg_dicts = [s.model_dump() for s in body.segments] + for stream in streams: + if hasattr(stream, "push_segments"): + stream.push_segments(seg_dicts) + return { + "status": "ok", + "streams_updated": len(streams), + "segments_applied": len(body.segments), + } + else: + # Legacy flat colors path + colors_array = np.array(body.colors, dtype=np.uint8) + if colors_array.ndim != 2 or colors_array.shape[1] != 3: + raise HTTPException( + status_code=400, detail="Colors must be an array of [R,G,B] triplets" + ) + for stream in streams: + if hasattr(stream, "push_colors"): + stream.push_colors(colors_array) + return { + "status": "ok", + "streams_updated": len(streams), + "leds_received": len(body.colors), + } + + +@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"]) +async def notify_source( + source_id: str, + _auth: AuthRequired, + body: NotifyRequest = None, + store: ColorStripStore = Depends(get_color_strip_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Trigger a notification on a notification color strip source. + + Fires a one-shot visual effect (flash, pulse, sweep) on all running + stream instances for this source. Optionally specify an app name for + color lookup or a hex color override. + """ + try: + source = store.get_source(source_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + if not isinstance(source, NotificationColorStripSource): + raise HTTPException(status_code=400, detail="Source is not a notification type") + + app_name = body.app if body else None + color_override = body.color if body else None + + streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) + accepted = 0 + for stream in streams: + if hasattr(stream, "fire"): + if stream.fire(app_name=app_name, color_override=color_override): + accepted += 1 + + return { + "status": "ok", + "streams_notified": accepted, + "filtered": len(streams) - accepted, + } + + +@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"]) +async def os_notification_history(_auth: AuthRequired): + """Return recent OS notification capture history (newest first).""" + from ledgrab.core.processing.os_notification_listener import ( + get_os_notification_listener, + ) + + listener = get_os_notification_listener() + if listener is None: + return {"available": False, "history": []} + return { + "available": listener.available, + "history": listener.recent_history, + } diff --git a/server/src/ledgrab/api/routes/color_strip_sources/preview.py b/server/src/ledgrab/api/routes/color_strip_sources/preview.py new file mode 100644 index 0000000..d7153f2 --- /dev/null +++ b/server/src/ledgrab/api/routes/color_strip_sources/preview.py @@ -0,0 +1,368 @@ +"""Key-colors preview/test endpoints (REST + WebSocket).""" + +import asyncio + +import numpy as np +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import ( + get_color_strip_store, + get_device_store, + get_picture_source_store, + get_pp_template_store, + get_processor_manager, + get_template_store, +) +from ledgrab.core.processing.processor_manager import ProcessorManager +from ledgrab.storage import DeviceStore +from ledgrab.storage.color_strip_store import ColorStripStore +from ledgrab.storage.picture_source_store import PictureSourceStore +from ledgrab.storage.template_store import TemplateStore +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +@router.post( + "/api/v1/color-strip-sources/{source_id}/key-colors/test", + tags=["Color Strip Sources"], +) +async def test_key_colors_source( + source_id: str, + _auth: AuthRequired, + store: ColorStripStore = Depends(get_color_strip_store), + source_store: PictureSourceStore = Depends(get_picture_source_store), + template_store: TemplateStore = Depends(get_template_store), + processor_manager: ProcessorManager = Depends(get_processor_manager), + device_store: DeviceStore = Depends(get_device_store), + pp_template_store=Depends(get_pp_template_store), +): + """Test a key_colors source: capture a frame, extract colors from each rectangle.""" + from ledgrab.storage.color_strip_source import KeyColorsColorStripSource + from ledgrab.core.capture.screen_capture import ( + calculate_average_color, + calculate_dominant_color, + calculate_median_color, + ) + from ledgrab.core.capture_engines import EngineRegistry + from ledgrab.core.filters import FilterRegistry, ImagePool + from ledgrab.storage.picture_source import ( + ScreenCapturePictureSource, + StaticImagePictureSource, + ) + from ledgrab.utils.image_codec import encode_jpeg_data_uri + + stream = None + try: + source = store.get_source(source_id) + if not isinstance(source, KeyColorsColorStripSource): + raise HTTPException(status_code=400, detail="Source is not a key_colors type") + + if not source.rectangles: + raise HTTPException(status_code=400, detail="No screen regions configured") + if not source.picture_source_id: + raise HTTPException(status_code=400, detail="No picture source configured") + + # Resolve picture source and capture a frame + chain = source_store.resolve_stream_chain(source.picture_source_id) + raw_stream = chain["raw_stream"] + + from ledgrab.utils.image_codec import load_image_file + + if isinstance(raw_stream, StaticImagePictureSource): + from ledgrab.api.dependencies import get_asset_store as _get_asset_store + + asset_store = _get_asset_store() + image_path = ( + asset_store.get_file_path(raw_stream.image_asset_id) + if raw_stream.image_asset_id + else None + ) + if not image_path: + raise HTTPException(status_code=400, detail="Image asset not found") + image = load_image_file(image_path) + elif isinstance(raw_stream, ScreenCapturePictureSource): + capture_template = template_store.get_template(raw_stream.capture_template_id) + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, detail=f"Engine '{capture_template.engine_type}' not available" + ) + + locked = processor_manager.get_display_lock_info(display_index) + if locked: + try: + device_name = device_store.get_device(locked).name + except Exception: + device_name = locked + raise HTTPException( + status_code=409, + detail=f"Display {display_index} is captured by '{device_name}'. Stop it first.", + ) + + stream = EngineRegistry.create_stream( + capture_template.engine_type, display_index, capture_template.engine_config + ) + stream.initialize() + sc = stream.capture_frame() + if sc is None: + raise RuntimeError("No frame captured") + image = sc.image + else: + raise HTTPException(status_code=400, detail="Unsupported picture source type") + + # Apply postprocessing filters + pp_ids = chain.get("postprocessing_template_ids", []) + if pp_ids and pp_template_store: + pool = ImagePool() + for pp_id in pp_ids: + try: + pp = pp_template_store.get_template(pp_id) + for fi in pp_template_store.resolve_filter_instances(pp.filters): + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(image, pool) + if result is not None: + image = result + except Exception as e: + logger.exception("unexpected in postprocessing pp_id=%s: %s", pp_id, e) + + # Extract colors from each rectangle + h, w = image.shape[:2] + calc_fns = { + "average": calculate_average_color, + "median": calculate_median_color, + "dominant": calculate_dominant_color, + } + calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) + + result_rects = [] + for rect in source.rectangles: + px_x = max(0, int(rect.x * w)) + px_y = max(0, int(rect.y * h)) + px_w = max(1, int(rect.width * w)) + px_h = max(1, int(rect.height * h)) + px_x, px_y = min(px_x, w - 1), min(px_y, h - 1) + px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y) + sub_img = image[px_y : px_y + px_h, px_x : px_x + px_w] + r, g, b = calc_fn(sub_img) + result_rects.append( + { + "name": rect.name, + "x": rect.x, + "y": rect.y, + "width": rect.width, + "height": rect.height, + "color": { + "r": int(r), + "g": int(g), + "b": int(b), + "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}", + }, + } + ) + + image_data_uri = encode_jpeg_data_uri(image, quality=90) + + return { + "image": image_data_uri, + "rectangles": result_rects, + "interpolation_mode": source.interpolation_mode, + } + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Key colors test failed: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + finally: + if stream: + try: + stream.stop() + except Exception as e: + logger.exception("unexpected in stream.stop cleanup: %s", e) + + +@router.websocket("/api/v1/color-strip-sources/{source_id}/key-colors/test/ws") +async def test_key_colors_ws( + websocket: WebSocket, + source_id: str, + fps: int = Query(3), + preview_width: int = Query(480), +): + """WebSocket for real-time key_colors test preview with frame + rectangle overlay. + + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. + """ + import json as ws_json + import time as ws_time + from ledgrab.api.auth import accept_and_authenticate_ws + from ledgrab.storage.color_strip_source import KeyColorsColorStripSource + from ledgrab.core.capture.screen_capture import ( + calculate_average_color, + calculate_dominant_color, + calculate_median_color, + ) + from ledgrab.storage.picture_source import ScreenCapturePictureSource + from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down + + if await accept_and_authenticate_ws(websocket) is None: + return + + store = get_color_strip_store() + source_store = get_picture_source_store() + manager = get_processor_manager() + device_store = get_device_store() + + try: + source = store.get_source(source_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + if not isinstance(source, KeyColorsColorStripSource): + await websocket.close(code=4003, reason="Not a key_colors source") + return + if not source.rectangles: + await websocket.close(code=4003, reason="No regions configured") + return + if not source.picture_source_id: + await websocket.close(code=4003, reason="No picture source configured") + return + + try: + chain = source_store.resolve_stream_chain(source.picture_source_id) + except ValueError as e: + await websocket.close(code=4003, reason=str(e)) + return + + raw_stream = chain["raw_stream"] + if isinstance(raw_stream, ScreenCapturePictureSource): + locked = manager.get_display_lock_info(raw_stream.display_index) + if locked: + try: + name = device_store.get_device(locked).name + except Exception: + name = locked + await websocket.close(code=4003, reason=f"Display captured by '{name}'") + return + + fps = max(1, min(30, fps)) + preview_width = max(120, min(1920, preview_width)) + frame_interval = 1.0 / fps + + calc_fns = { + "average": calculate_average_color, + "median": calculate_median_color, + "dominant": calculate_dominant_color, + } + calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) + + logger.info(f"KC CSS test WS connected for {source_id} (fps={fps})") + + # Use LiveStreamManager — reuses the already-running capture engine when a + # target is active. BetterCam (DXGI) only supports one session per display, + # so creating a second engine would produce no frames. + live_stream_mgr = manager._live_stream_manager + try: + live_stream = await asyncio.to_thread(live_stream_mgr.acquire, source.picture_source_id) + except Exception as e: + logger.error(f"KC test: LiveStream acquire failed: {e}") + await websocket.send_text(ws_json.dumps({"type": "error", "detail": str(e)})) + await websocket.close(code=4003, reason=str(e)) + return + + try: + prev_frame_ref = None + + while True: + loop_start = ws_time.monotonic() + try: + capture = await asyncio.to_thread(live_stream.get_latest_frame) + if capture is None or capture.image is None: + await asyncio.sleep(frame_interval) + continue + if capture is prev_frame_ref: + await asyncio.sleep(frame_interval * 0.5) + continue + prev_frame_ref = capture + cur_image = capture.image + + if not isinstance(cur_image, np.ndarray): + await asyncio.sleep(frame_interval) + continue + + # NOTE: postprocessing is already applied by the ProcessedLiveStream + # when using LiveStreamManager.acquire() — do NOT re-apply here. + + # Re-read source for hot-update support + try: + source = store.get_source(source_id) + calc_fn = calc_fns.get(source.interpolation_mode, calculate_average_color) + except (KeyError, ValueError, AttributeError) as e: + logger.debug("source re-read failed for %s: %s", source_id, e) + + h, w = cur_image.shape[:2] + result_rects = [] + for rect in source.rectangles: + px_x = max(0, int(rect.x * w)) + px_y = max(0, int(rect.y * h)) + px_w = max(1, int(rect.width * w)) + px_h = max(1, int(rect.height * h)) + px_x, px_y = min(px_x, w - 1), min(px_y, h - 1) + px_w, px_h = min(px_w, w - px_x), min(px_h, h - px_y) + sub = cur_image[px_y : px_y + px_h, px_x : px_x + px_w] + r, g, b = calc_fn(sub) + result_rects.append( + { + "name": rect.name, + "x": rect.x, + "y": rect.y, + "width": rect.width, + "height": rect.height, + "color": { + "r": int(r), + "g": int(g), + "b": int(b), + "hex": f"#{int(r):02x}{int(g):02x}{int(b):02x}", + }, + } + ) + + frame_to_encode = resize_down(cur_image, preview_width) + frame_uri = encode_jpeg_data_uri(frame_to_encode, quality=85) + + await websocket.send_text( + ws_json.dumps( + { + "type": "frame", + "image": frame_uri, + "rectangles": result_rects, + "interpolation_mode": source.interpolation_mode, + } + ) + ) + + except (WebSocketDisconnect, Exception) as inner_e: + if isinstance(inner_e, WebSocketDisconnect): + raise + logger.warning(f"KC CSS test WS frame error: {inner_e}") + + elapsed = ws_time.monotonic() - loop_start + if frame_interval - elapsed > 0: + await asyncio.sleep(frame_interval - elapsed) + + except WebSocketDisconnect: + logger.info(f"KC CSS test WS disconnected for {source_id}") + except Exception as e: + logger.error(f"KC CSS test WS error: {e}", exc_info=True) + finally: + try: + await asyncio.to_thread(live_stream_mgr.release, source.picture_source_id) + except Exception as e: + logger.exception("unexpected in live_stream_mgr.release: %s", e) diff --git a/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py b/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py new file mode 100644 index 0000000..e2b8926 --- /dev/null +++ b/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py @@ -0,0 +1,594 @@ +"""WebSocket streaming endpoints: preview, api-input, test.""" + +import asyncio +import json as _json +import time as _time +import uuid as _uuid + +import numpy as np +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from ledgrab.api.dependencies import ( + get_color_strip_store, + get_processor_manager, +) +from ledgrab.core.processing.processor_manager import ProcessorManager +from ledgrab.storage.color_strip_source import ( + AdvancedPictureColorStripSource, + ApiInputColorStripSource, + CompositeColorStripSource, + NotificationColorStripSource, + PictureColorStripSource, +) +from ledgrab.storage.color_strip_store import ColorStripStore +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +_PREVIEW_ALLOWED_TYPES = { + "static", + "gradient", + "color_cycle", + "effect", + "daylight", + "candlelight", + "notification", +} + + +@router.websocket("/api/v1/color-strip-sources/preview/ws") +async def preview_color_strip_ws( + websocket: WebSocket, + led_count: int = Query(100), + fps: int = Query(20), +): + """Transient preview WebSocket — stream frames for an ad-hoc source config. + + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. Use ``?led_count=100&fps=20`` for stream params. + + After auth, waits for a text message containing the full source config + JSON (must include ``source_type``). Responds with a JSON metadata message, + then streams binary RGB frames at the requested FPS. + + Subsequent text messages are treated as config updates: if the source_type + changed the old stream is replaced; otherwise ``update_source()`` is used. + """ + from ledgrab.api.auth import accept_and_authenticate_ws + + if await accept_and_authenticate_ws(websocket) is None: + return + + led_count = max(1, min(1000, led_count)) + fps = max(1, min(60, fps)) + frame_interval = 1.0 / fps + + stream = None + clock_id = None + current_source_type = None + + # Helpers ──────────────────────────────────────────────────────────── + + def _get_sync_clock_manager(): + """Return the SyncClockManager if available.""" + try: + mgr = get_processor_manager() + return getattr(mgr, "_sync_clock_manager", None) + except Exception as e: + logger.debug("SyncClockManager not available: %s", e) + return None + + def _build_source(config: dict): + """Build a ColorStripSource from a raw config dict, injecting synthetic id/name.""" + from ledgrab.storage.color_strip_source import ColorStripSource + + config.setdefault("id", "__preview__") + config.setdefault("name", "__preview__") + return ColorStripSource.from_dict(config) + + def _create_stream(source): + """Instantiate and start the appropriate stream class for *source*.""" + from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP + + stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) + if not stream_cls: + raise ValueError(f"Unsupported preview source_type: {source.source_type}") + s = stream_cls(source) + # Inject gradient store for palette resolution + if hasattr(s, "set_gradient_store"): + try: + from ledgrab.api.dependencies import get_gradient_store + + s.set_gradient_store(get_gradient_store()) + except Exception as e: + logger.exception("unexpected in gradient_store injection: %s", e) + if hasattr(s, "configure"): + s.configure(led_count) + # Inject sync clock if requested + cid = getattr(source, "clock_id", None) + if cid and hasattr(s, "set_clock"): + scm = _get_sync_clock_manager() + if scm: + try: + clock_rt = scm.acquire(cid) + s.set_clock(clock_rt) + except Exception as e: + logger.warning(f"Preview: could not acquire clock {cid}: {e}") + cid = None + else: + cid = None + else: + cid = None + s.start() + return s, cid + + def _stop_stream(s, cid): + """Stop a stream and release its clock.""" + try: + s.stop() + except Exception as e: + logger.exception("unexpected in _stop_stream s.stop: %s", e) + if cid: + scm = _get_sync_clock_manager() + if scm: + try: + scm.release(cid) + except Exception as e: + logger.exception("unexpected in _stop_stream clock release: %s", e) + + async def _send_meta(source_type: str): + meta = {"type": "meta", "led_count": led_count, "source_type": source_type} + await websocket.send_text(_json.dumps(meta)) + + # Wait for initial config ──────────────────────────────────────────── + + try: + initial_text = await websocket.receive_text() + except WebSocketDisconnect: + return + except (RuntimeError, ConnectionError) as e: + logger.debug("ws closed during initial config: %s", e) + return + + try: + config = _json.loads(initial_text) + source_type = config.get("source_type") + if source_type not in _PREVIEW_ALLOWED_TYPES: + await websocket.send_text( + _json.dumps( + { + "type": "error", + "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}", + } + ) + ) + await websocket.close(code=4003, reason="Invalid source_type") + return + source = _build_source(config) + stream, clock_id = _create_stream(source) + current_source_type = source_type + except Exception as e: + logger.error(f"Preview WS: bad initial config: {e}") + await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) + await websocket.close(code=4003, reason=str(e)) + return + + await _send_meta(current_source_type) + logger.info( + f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}" + ) + + # Frame loop ───────────────────────────────────────────────────────── + + try: + while True: + # Non-blocking check for incoming config updates + try: + msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval) + except asyncio.TimeoutError: + msg = None + except WebSocketDisconnect: + break + + if msg is not None: + try: + new_config = _json.loads(msg) + + # Handle "fire" command for notification streams + if new_config.get("action") == "fire": + from ledgrab.core.processing.notification_stream import ( + NotificationColorStripStream, + ) + + if isinstance(stream, NotificationColorStripStream): + stream.fire( + app_name=new_config.get("app", ""), + color_override=new_config.get("color"), + ) + continue + + new_type = new_config.get("source_type") + if new_type not in _PREVIEW_ALLOWED_TYPES: + await websocket.send_text( + _json.dumps( + { + "type": "error", + "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}", + } + ) + ) + continue + new_source = _build_source(new_config) + if new_type != current_source_type: + # Source type changed — recreate stream + _stop_stream(stream, clock_id) + stream, clock_id = _create_stream(new_source) + current_source_type = new_type + else: + stream.update_source(new_source) + if hasattr(stream, "configure"): + stream.configure(led_count) + await _send_meta(current_source_type) + except Exception as e: + logger.warning(f"Preview WS: bad config update: {e}") + await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) + + # Send frame + colors = stream.get_latest_colors() + if colors is not None: + await websocket.send_bytes(colors.tobytes()) + else: + # Stream hasn't produced a frame yet — send black + await websocket.send_bytes(b"\x00" * led_count * 3) + + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Preview WS error: {e}") + finally: + if stream is not None: + _stop_stream(stream, clock_id) + logger.info("Preview WS disconnected") + + +@router.websocket("/api/v1/color-strip-sources/{source_id}/ws") +async def css_api_input_ws( + websocket: WebSocket, + source_id: str, +): + """WebSocket for pushing raw LED colors to an api_input source. + + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. After auth_ok, accepts JSON frames + (``{"colors": [[R,G,B], ...]}``) or binary frames (raw RGBRGB... bytes, + 3 bytes per LED). + """ + from ledgrab.api.auth import accept_and_authenticate_ws + + if await accept_and_authenticate_ws(websocket) is None: + return + + # Validate source exists and is api_input type + manager = get_processor_manager() + try: + store = get_color_strip_store() + source = store.get_source(source_id) + except (ValueError, RuntimeError): + await websocket.close(code=4004, reason="Source not found") + return + + if not isinstance(source, ApiInputColorStripSource): + await websocket.close(code=4003, reason="Source is not api_input type") + return + + logger.info(f"API input WebSocket connected for source {source_id}") + + try: + while True: + message = await websocket.receive() + + if message.get("type") == "websocket.disconnect": + break + + if "text" in message: + # JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]} + import json + + try: + data = json.loads(message["text"]) + except (json.JSONDecodeError, ValueError) as e: + await websocket.send_json({"error": str(e)}) + continue + + if "segments" in data: + # Segment-based path — validate and push + try: + from ledgrab.api.schemas.color_strip_sources import SegmentPayload + + seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]] + except Exception as e: + await websocket.send_json({"error": f"Invalid segment: {e}"}) + continue + streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) + for stream in streams: + if hasattr(stream, "push_segments"): + stream.push_segments(seg_dicts) + continue + + elif "colors" in data: + try: + raw_colors = data["colors"] + colors_array = np.array(raw_colors, dtype=np.uint8) + if colors_array.ndim != 2 or colors_array.shape[1] != 3: + await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"}) + continue + except (ValueError, TypeError) as e: + await websocket.send_json({"error": str(e)}) + continue + else: + await websocket.send_json( + {"error": "JSON frame must contain 'colors' or 'segments'"} + ) + continue + + elif "bytes" in message: + # Binary frame: raw RGBRGB... bytes (3 bytes per LED) + raw_bytes = message["bytes"] + if len(raw_bytes) % 3 != 0: + await websocket.send_json({"error": "Binary data must be multiple of 3 bytes"}) + continue + colors_array = np.frombuffer(raw_bytes, dtype=np.uint8).reshape(-1, 3) + + else: + continue + + # Push to all running streams (colors_array path only reaches here) + streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) + for stream in streams: + if hasattr(stream, "push_colors"): + stream.push_colors(colors_array) + + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"API input WebSocket error for source {source_id}: {e}") + finally: + logger.info(f"API input WebSocket disconnected for source {source_id}") + + +@router.websocket("/api/v1/color-strip-sources/{source_id}/test/ws") +async def test_color_strip_ws( + websocket: WebSocket, + source_id: str, + led_count: int = Query(100), + fps: int = Query(20), +): + """WebSocket for real-time CSS source preview. + + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. After auth_ok, server sends JSON metadata + (source_type, led_count, calibration segments), then binary RGB frames + (``led_count * 3`` bytes). + """ + from ledgrab.api.auth import accept_and_authenticate_ws + + if await accept_and_authenticate_ws(websocket) is None: + return + + # Validate source exists + store: ColorStripStore = get_color_strip_store() + try: + source = store.get_source(source_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + # Acquire stream – unique consumer ID per WS to avoid release races + manager: ProcessorManager = get_processor_manager() + csm = manager.color_strip_stream_manager + consumer_id = f"__test_{_uuid.uuid4().hex[:8]}__" + try: + stream = csm.acquire(source_id, consumer_id) + except Exception as e: + logger.error(f"CSS test: failed to acquire stream for {source_id}: {e}") + await websocket.close(code=4003, reason=str(e)) + return + + # Configure LED count for auto-sizing streams + if hasattr(stream, "configure"): + stream.configure(max(1, led_count)) + + # Reject picture sources with 0 calibration LEDs (no edges configured) + if stream.led_count <= 0: + csm.release(source_id, consumer_id) + await websocket.close( + code=4005, + reason="No LEDs configured. Open Calibration and set LED counts for each edge.", + ) + return + + # Clamp FPS to sane range + fps = max(1, min(60, fps)) + _frame_interval = 1.0 / fps + + logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})") + + try: + from ledgrab.core.processing.composite_stream import CompositeColorStripStream + + from ledgrab.core.processing.api_input_stream import ApiInputColorStripStream + + is_api_input = isinstance(stream, ApiInputColorStripStream) + _last_push_gen = 0 # track api_input push generation to skip unchanged frames + + # Send metadata as first message + is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) + is_composite = isinstance(source, CompositeColorStripSource) + meta: dict = { + "type": "meta", + "source_type": source.source_type, + "source_name": source.name, + "led_count": stream.led_count, + } + if is_picture and stream.calibration: + cal = stream.calibration + total = cal.get_total_leds() + offset = cal.offset % total if total > 0 else 0 + edges = [] + for seg in cal.segments: + # Compute output indices matching PixelMapper logic + indices = list(range(seg.led_start, seg.led_start + seg.led_count)) + if seg.reverse: + indices = indices[::-1] + if offset > 0: + indices = [(idx + offset) % total for idx in indices] + edges.append({"edge": seg.edge, "indices": indices}) + meta["edges"] = edges + meta["border_width"] = cal.border_width + if is_composite and hasattr(source, "layers"): + # Send layer info for composite preview + enabled_layers = [layer for layer in source.layers if layer.get("enabled", True)] + layer_infos = [] # [{name, id, is_notification, has_brightness, ...}, ...] + for layer in enabled_layers: + info = { + "id": layer["source_id"], + "name": layer.get("source_id", "?"), + "is_notification": False, + "has_brightness": bool(layer.get("brightness_source_id")), + } + try: + layer_src = store.get_source(layer["source_id"]) + info["name"] = layer_src.name + info["is_notification"] = isinstance(layer_src, NotificationColorStripSource) + if isinstance( + layer_src, (PictureColorStripSource, AdvancedPictureColorStripSource) + ): + info["is_picture"] = True + if hasattr(layer_src, "calibration") and layer_src.calibration: + info["calibration_led_count"] = layer_src.calibration.get_total_leds() + except (ValueError, KeyError): + pass + layer_infos.append(info) + meta["layers"] = [li["name"] for li in layer_infos] + meta["layer_infos"] = layer_infos + await websocket.send_text(_json.dumps(meta)) + + # For api_input: send the current buffer immediately so the client + # gets a frame right away (fallback color if inactive) rather than + # leaving the canvas blank/stale until external data arrives. + if is_api_input: + initial_colors = stream.get_latest_colors() + if initial_colors is not None: + await websocket.send_bytes(initial_colors.tobytes()) + + # For picture sources, grab the live stream for frame preview + _frame_live = None + if is_picture and hasattr(stream, "live_stream"): + _frame_live = stream.live_stream + _last_aux_time = 0.0 + _AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS + _frame_dims_sent = False # send frame dimensions once with first JPEG + + # Stream binary RGB frames at ~20 Hz + while True: + # For composite sources, send per-layer data like target preview does + if is_composite and isinstance(stream, CompositeColorStripStream): + layer_colors = stream.get_layer_colors() + composite_colors = stream.get_latest_colors() + if composite_colors is not None and layer_colors and len(layer_colors) > 1: + led_count = composite_colors.shape[0] + rgb_size = led_count * 3 + # Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb] + header = bytes( + [0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF] + ) + parts = [header] + for lc in layer_colors: + if lc is not None and lc.shape[0] == led_count: + parts.append(lc.tobytes()) + else: + parts.append(b"\x00" * rgb_size) + parts.append(composite_colors.tobytes()) + await websocket.send_bytes(b"".join(parts)) + elif composite_colors is not None: + await websocket.send_bytes(composite_colors.tobytes()) + else: + # For api_input: only send when new data was pushed + if is_api_input: + gen = stream.push_generation + if gen != _last_push_gen: + _last_push_gen = gen + colors = stream.get_latest_colors() + if colors is not None: + await websocket.send_bytes(colors.tobytes()) + else: + colors = stream.get_latest_colors() + if colors is not None: + await websocket.send_bytes(colors.tobytes()) + + # Periodically send auxiliary data (frame preview, brightness) + now = _time.monotonic() + if now - _last_aux_time >= _AUX_INTERVAL: + _last_aux_time = now + + # Send brightness values for composite layers + if is_composite and isinstance(stream, CompositeColorStripStream): + try: + bri_values = stream.get_layer_brightness() + if any(v is not None for v in bri_values): + bri_msg = { + "type": "brightness", + "values": [ + round(v * 100) if v is not None else None for v in bri_values + ], + } + await websocket.send_text(_json.dumps(bri_msg)) + except Exception as e: + logger.exception("unexpected in brightness send: %s", e) + + # Send JPEG frame preview for picture sources + if _frame_live: + try: + frame = _frame_live.get_latest_frame() + if frame is not None and frame.image is not None: + from ledgrab.utils.image_codec import encode_jpeg, resize_image + + img = frame.image + # Ensure 3-channel RGB (some engines may produce BGRA) + if img.ndim == 3 and img.shape[2] == 4: + img = img[:, :, :3] + h, w = img.shape[:2] + # Send frame dimensions once so client can compute border overlay + if not _frame_dims_sent: + _frame_dims_sent = True + await websocket.send_text( + _json.dumps( + { + "type": "frame_dims", + "width": w, + "height": h, + } + ) + ) + # Downscale for bandwidth + scale = min(960 / w, 540 / h, 1.0) + if scale < 1.0: + new_w = max(1, int(w * scale)) + new_h = max(1, int(h * scale)) + img = resize_image(img, new_w, new_h) + # Wire format: [0xFD] [jpeg_bytes] + await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70)) + except Exception as e: + logger.warning(f"JPEG frame preview error: {e}") + + await asyncio.sleep(_frame_interval) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"CSS test WebSocket error for {source_id}: {e}") + finally: + csm.release(source_id, consumer_id) + logger.info(f"CSS test WebSocket disconnected for {source_id}") diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index e5f7ad9..3d33bb4 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -735,17 +735,16 @@ async def set_device_power( async def device_ws_stream( websocket: WebSocket, device_id: str, - token: str = Query(""), ): """WebSocket stream of LED pixel data for WS device type. Wire format: [brightness_byte][R G B R G B ...] - Auth via ?token=. + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. """ - from ledgrab.api.auth import verify_ws_token + from ledgrab.api.auth import accept_and_authenticate_ws - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return store = get_device_store() @@ -758,8 +757,6 @@ async def device_ws_stream( await websocket.close(code=4003, reason="Device is not a WebSocket device") return - await websocket.accept() - from ledgrab.core.devices.ws_client import get_ws_broadcaster broadcaster = get_ws_broadcaster() diff --git a/server/src/ledgrab/api/routes/home_assistant.py b/server/src/ledgrab/api/routes/home_assistant.py index 47c43eb..49c6955 100644 --- a/server/src/ledgrab/api/routes/home_assistant.py +++ b/server/src/ledgrab/api/routes/home_assistant.py @@ -3,9 +3,9 @@ import asyncio import json -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query -from ledgrab.api.auth import AuthRequired +from ledgrab.api.auth import AuthRequired, require_authenticated from ledgrab.api.dependencies import ( fire_entity_event, get_ha_manager, @@ -34,10 +34,17 @@ logger = get_logger(__name__) router = APIRouter() +_REDACTED_TOKEN = "***" + + def _to_response( - source: HomeAssistantSource, manager: HomeAssistantManager + source: HomeAssistantSource, + manager: HomeAssistantManager, + *, + include_secrets: bool = False, ) -> HomeAssistantSourceResponse: runtime = manager.get_runtime(source.id) + token_field = source.token if include_secrets else (_REDACTED_TOKEN if source.token else None) return HomeAssistantSourceResponse( id=source.id, name=source.name, @@ -50,6 +57,7 @@ def _to_response( tags=source.tags, created_at=source.created_at, updated_at=source.updated_at, + token=token_field, ) @@ -59,13 +67,19 @@ def _to_response( tags=["Home Assistant"], ) async def list_ha_sources( - _auth: AuthRequired, + auth: AuthRequired, + include_secrets: bool = Query( + False, + description="Include plaintext access tokens (requires non-anonymous auth)", + ), store: HomeAssistantStore = Depends(get_ha_store), manager: HomeAssistantManager = Depends(get_ha_manager), ): + if include_secrets: + require_authenticated(auth) sources = store.get_all_sources() return HomeAssistantSourceListResponse( - sources=[_to_response(s, manager) for s in sources], + sources=[_to_response(s, manager, include_secrets=include_secrets) for s in sources], count=len(sources), ) @@ -105,15 +119,21 @@ async def create_ha_source( ) async def get_ha_source( source_id: str, - _auth: AuthRequired, + auth: AuthRequired, + include_secrets: bool = Query( + False, + description="Include plaintext access token (requires non-anonymous auth)", + ), store: HomeAssistantStore = Depends(get_ha_store), manager: HomeAssistantManager = Depends(get_ha_manager), ): + if include_secrets: + require_authenticated(auth) try: source = store.get_source(source_id) except EntityNotFoundError: raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found") - return _to_response(source, manager) + return _to_response(source, manager, include_secrets=include_secrets) @router.put( diff --git a/server/src/ledgrab/api/routes/output_targets_control.py b/server/src/ledgrab/api/routes/output_targets_control.py index f5b90e8..794b9d6 100644 --- a/server/src/ledgrab/api/routes/output_targets_control.py +++ b/server/src/ledgrab/api/routes/output_targets_control.py @@ -3,7 +3,7 @@ Extracted from output_targets.py to keep files under 800 lines. """ -from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect from ledgrab.api.auth import AuthRequired from ledgrab.api.dependencies import ( @@ -205,17 +205,17 @@ async def get_target_metrics( @router.websocket("/api/v1/events/ws") async def events_ws( websocket: WebSocket, - token: str = Query(""), ): - """WebSocket for real-time state change events. Auth via ?token=.""" - from ledgrab.api.auth import verify_ws_token + """WebSocket for real-time state change events. - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + Auth via first-message handshake: client sends + ``{"type":"auth","token":"..."}`` within 3 s of connect. + """ + from ledgrab.api.auth import accept_and_authenticate_ws + + if await accept_and_authenticate_ws(websocket) is None: return - await websocket.accept() - manager = get_processor_manager() queue = manager.subscribe_events() @@ -225,8 +225,8 @@ async def events_ws( await websocket.send_json(event) except WebSocketDisconnect: pass - except Exception: - pass + except (RuntimeError, ConnectionError) as e: + logger.debug("ws closed in events stream: %s", e) finally: manager.unsubscribe_events(queue) @@ -341,17 +341,15 @@ async def get_overlay_status( async def ha_light_colors_ws( websocket: WebSocket, target_id: str, - token: str = Query(""), ): """WebSocket for live HA light entity color preview. Streams: {"type": "colors_update", "colors": {entity_id: {r,g,b,hex}, ...}} - at the target's update_rate. + at the target's update_rate. Auth via first-message handshake. """ - from ledgrab.api.auth import verify_ws_token + from ledgrab.api.auth import accept_and_authenticate_ws - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return manager: ProcessorManager = get_processor_manager() @@ -365,8 +363,6 @@ async def ha_light_colors_ws( await websocket.close(code=4004, reason=str(e)) return - await websocket.accept() - try: manager.add_ha_light_ws_client(target_id, websocket) while True: @@ -374,8 +370,8 @@ async def ha_light_colors_ws( await websocket.receive_text() except WebSocketDisconnect: pass - except Exception: - pass + except (RuntimeError, ConnectionError) as e: + logger.debug("ws closed in ha-light client: %s", e) finally: manager.remove_ha_light_ws_client(target_id, websocket) @@ -387,17 +383,16 @@ async def ha_light_colors_ws( async def led_preview_ws( websocket: WebSocket, target_id: str, - token: str = Query(""), ): - """WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=.""" - from ledgrab.api.auth import verify_ws_token + """WebSocket for real-time LED strip preview. Sends binary RGB frames. - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + Auth via first-message handshake. + """ + from ledgrab.api.auth import accept_and_authenticate_ws + + if await accept_and_authenticate_ws(websocket) is None: return - await websocket.accept() - manager = get_processor_manager() try: diff --git a/server/src/ledgrab/api/routes/picture_sources.py b/server/src/ledgrab/api/routes/picture_sources.py index 4954ae1..6e7bab6 100644 --- a/server/src/ledgrab/api/routes/picture_sources.py +++ b/server/src/ledgrab/api/routes/picture_sources.py @@ -142,18 +142,17 @@ async def validate_image( """Validate an image source (URL or file path) and return a preview thumbnail.""" try: - from ledgrab.utils.safe_source import validate_image_path, validate_image_url + from ledgrab.utils.safe_source import validate_image_path source = data.image_source.strip() if not source: return ImageValidateResponse(valid=False, error="Image source is empty") if source.startswith(("http://", "https://")): - validate_image_url(source) - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - response = await client.get(source) - response.raise_for_status() - img_bytes = response.content + from ledgrab.utils.safe_source import safe_fetch + + response = await safe_fetch(source, timeout=15.0) + img_bytes = response.content else: path = validate_image_path(source) if not path.exists(): @@ -195,18 +194,38 @@ async def validate_image( async def get_full_image( _auth: AuthRequired, source: str = Query(..., description="Image URL or local file path"), + store: PictureSourceStore = Depends(get_picture_source_store), ): - """Serve the full-resolution image for lightbox preview.""" + """Serve the full-resolution image for lightbox preview. - from ledgrab.utils.safe_source import validate_image_path, validate_image_url + To prevent open-proxy / SSRF abuse, the requested *source* must match + a URL or asset path already used by an existing picture source. + """ + + from ledgrab.utils.safe_source import safe_fetch, validate_image_path + + # Build the allow-list of URLs / paths referenced by stored picture sources. + allowed: set[str] = set() + try: + for s in store.get_all_streams(): + for attr in ("url", "image_url", "image_path", "file_path"): + val = getattr(s, attr, None) + if isinstance(val, str) and val: + allowed.add(val) + except Exception: + # Don't leak store errors; treat as empty allow-list (will reject). + allowed = set() + + if source not in allowed: + raise HTTPException( + status_code=403, + detail="Source URL/path is not referenced by any picture source", + ) try: if source.startswith(("http://", "https://")): - validate_image_url(source) - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - response = await client.get(source) - response.raise_for_status() - img_bytes = response.content + response = await safe_fetch(source, timeout=15.0) + img_bytes = response.content else: path = validate_image_path(source) if not path.exists(): @@ -394,9 +413,7 @@ async def get_video_thumbnail( if not video_path: raise HTTPException(status_code=400, detail="Video asset not found or missing file") - frame = await asyncio.get_event_loop().run_in_executor( - None, extract_thumbnail, str(video_path), source.resolution_limit - ) + frame = await asyncio.to_thread(extract_thumbnail, str(video_path), source.resolution_limit) if frame is None: raise HTTPException(status_code=404, detail="Could not extract thumbnail") @@ -623,23 +640,23 @@ async def test_picture_source( async def test_picture_source_ws( websocket: WebSocket, stream_id: str, - token: str = Query(""), duration: float = Query(5.0), preview_width: int = Query(0), ): - """WebSocket for picture source test with intermediate frame previews.""" - from ledgrab.api.routes._preview_helpers import ( - authenticate_ws_token, - stream_capture_test, - ) + """WebSocket for picture source test with intermediate frame previews. + + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. + """ + from ledgrab.api.auth import accept_and_authenticate_ws + from ledgrab.api.routes._preview_helpers import stream_capture_test from ledgrab.api.dependencies import ( get_picture_source_store as _get_ps_store, get_template_store as _get_t_store, get_pp_template_store as _get_pp_store, ) - if not authenticate_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return store = _get_ps_store() @@ -675,7 +692,6 @@ async def test_picture_source_ws( await websocket.close(code=4004, reason="Video asset not found or missing file") return - await websocket.accept() logger.info(f"Video source test WS connected for {stream_id} ({duration}s)") video_stream = VideoCaptureLiveStream( @@ -698,7 +714,7 @@ async def test_picture_source_ws( return encode_jpeg_data_uri(image, quality=80), w, h try: - await asyncio.get_event_loop().run_in_executor(None, video_stream.start) + await asyncio.to_thread(video_stream.start) import time as _time fps = min(raw_stream.target_fps or 30, 30) @@ -711,8 +727,7 @@ async def test_picture_source_ws( if frame is not None and frame.image is not None and frame is not last_frame: last_frame = frame frame_count += 1 - thumb, w, h = await asyncio.get_event_loop().run_in_executor( - None, + thumb, w, h = await asyncio.to_thread( _encode_video_frame, frame.image, preview_width or None, @@ -731,8 +746,7 @@ async def test_picture_source_ws( await asyncio.sleep(frame_time) # Send final result if last_frame is not None: - full_img, fw, fh = await asyncio.get_event_loop().run_in_executor( - None, + full_img, fw, fh = await asyncio.to_thread( _encode_video_frame, last_frame.image, None, @@ -802,7 +816,6 @@ async def test_picture_source_ws( s.initialize() return s - await websocket.accept() logger.info(f"Picture source test WS connected for {stream_id} ({duration}s)") try: diff --git a/server/src/ledgrab/api/routes/postprocessing.py b/server/src/ledgrab/api/routes/postprocessing.py index 8000d53..abe5efe 100644 --- a/server/src/ledgrab/api/routes/postprocessing.py +++ b/server/src/ledgrab/api/routes/postprocessing.py @@ -377,24 +377,24 @@ async def test_pp_template( async def test_pp_template_ws( websocket: WebSocket, template_id: str, - token: str = Query(""), duration: float = Query(5.0), source_stream_id: str = Query(""), preview_width: int = Query(0), ): - """WebSocket for PP template test with intermediate frame previews.""" - from ledgrab.api.routes._preview_helpers import ( - authenticate_ws_token, - stream_capture_test, - ) + """WebSocket for PP template test with intermediate frame previews. + + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. + """ + from ledgrab.api.auth import accept_and_authenticate_ws + from ledgrab.api.routes._preview_helpers import stream_capture_test from ledgrab.api.dependencies import ( get_picture_source_store as _get_ps_store, get_template_store as _get_t_store, get_pp_template_store as _get_pp_store, ) - if not authenticate_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return if not source_stream_id: @@ -456,7 +456,6 @@ async def test_pp_template_ws( s.initialize() return s - await websocket.accept() logger.info(f"PP template test WS connected for {template_id} ({duration}s)") try: diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index a38269f..e55f761 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -291,8 +291,8 @@ def get_system_performance(_: AuthRequired): if proc_info.pid == pid and proc_info.usedGpuMemory: app_gpu_mem = round(proc_info.usedGpuMemory / 1024 / 1024, 1) break - except Exception: - pass # not all drivers support per-process queries + except Exception as e: + logger.debug("nvml per-process query unsupported: %s", e) gpu = GpuInfo( name=_nvml.nvmlDeviceGetName(_nvml_handle), diff --git a/server/src/ledgrab/api/routes/system_settings.py b/server/src/ledgrab/api/routes/system_settings.py index 4538dd7..fcfd55b 100644 --- a/server/src/ledgrab/api/routes/system_settings.py +++ b/server/src/ledgrab/api/routes/system_settings.py @@ -7,7 +7,7 @@ import asyncio import logging import re -from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from pydantic import BaseModel from ledgrab.api.auth import AuthRequired @@ -158,22 +158,19 @@ async def update_external_url( @router.websocket("/api/v1/system/logs/ws") async def logs_ws( websocket: WebSocket, - token: str = Query(""), ): """WebSocket that streams server log lines in real time. - Auth via ``?token=``. On connect, sends the last ~500 buffered - lines as individual text messages, then pushes new lines as they appear. + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. On connect, sends the last ~500 buffered lines + as individual text messages, then pushes new lines as they appear. """ - from ledgrab.api.auth import verify_ws_token + from ledgrab.api.auth import accept_and_authenticate_ws from ledgrab.utils import log_broadcaster - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return - await websocket.accept() - # Ensure the broadcaster knows the event loop (may be first connection) log_broadcaster.ensure_loop() diff --git a/server/src/ledgrab/api/routes/templates.py b/server/src/ledgrab/api/routes/templates.py index e7b7afb..c45b9ac 100644 --- a/server/src/ledgrab/api/routes/templates.py +++ b/server/src/ledgrab/api/routes/templates.py @@ -3,7 +3,7 @@ import time import numpy as np -from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect from ledgrab.api.auth import AuthRequired from ledgrab.api.dependencies import ( @@ -398,24 +398,19 @@ def test_template( @router.websocket("/api/v1/capture-templates/test/ws") async def test_template_ws( websocket: WebSocket, - token: str = Query(""), ): """WebSocket for capture template test with intermediate frame previews. - Config is sent as the first client message (JSON with engine_type, - engine_config, display_index, capture_duration). + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. Config is sent as the *second* client message + (JSON with engine_type, engine_config, display_index, capture_duration). """ - from ledgrab.api.routes._preview_helpers import ( - authenticate_ws_token, - stream_capture_test, - ) + from ledgrab.api.auth import accept_and_authenticate_ws + from ledgrab.api.routes._preview_helpers import stream_capture_test - if not authenticate_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return - await websocket.accept() - # Read config from first client message try: config = await websocket.receive_json() diff --git a/server/src/ledgrab/api/routes/value_sources.py b/server/src/ledgrab/api/routes/value_sources.py index 30fef40..760b5ce 100644 --- a/server/src/ledgrab/api/routes/value_sources.py +++ b/server/src/ledgrab/api/routes/value_sources.py @@ -363,17 +363,18 @@ async def delete_value_source( async def test_value_source_ws( websocket: WebSocket, source_id: str, - token: str = Query(""), ): - """WebSocket for real-time value source output. Auth via ?token=. + """WebSocket for real-time value source output. + + Auth via first-message handshake: ``{"type":"auth","token":"..."}`` + within 3 s of connect. Acquires a ValueStream for the given source, polls get_value() at ~20 Hz, and streams {value: float} JSON to the client. """ - from ledgrab.api.auth import verify_ws_token + from ledgrab.api.auth import accept_and_authenticate_ws - if not verify_ws_token(token): - await websocket.close(code=4001, reason="Unauthorized") + if await accept_and_authenticate_ws(websocket) is None: return # Validate source exists @@ -397,7 +398,6 @@ async def test_value_source_ws( await websocket.close(code=4003, reason=str(e)) return - await websocket.accept() logger.info(f"Value source test WebSocket connected for {source_id}") # Detect if this stream produces colors diff --git a/server/src/ledgrab/api/routes/webhooks.py b/server/src/ledgrab/api/routes/webhooks.py index 6965eda..bccd717 100644 --- a/server/src/ledgrab/api/routes/webhooks.py +++ b/server/src/ledgrab/api/routes/webhooks.py @@ -55,6 +55,24 @@ class WebhookPayload(BaseModel): action: str = Field(description="'activate' or 'deactivate'") +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + + +def _rate_limit_key(request: Request) -> str: + """Pick a stable client identifier for rate-limiting. + + When the immediate peer is loopback (assumed reverse-proxy), use the + first ``X-Forwarded-For`` entry; otherwise use the peer's IP. This + prevents a single proxied IP from masking many distinct callers. + """ + peer = request.client.host if request.client else "unknown" + if peer in _LOOPBACK_HOSTS: + xff = request.headers.get("x-forwarded-for", "") + if xff: + return xff.split(",", 1)[0].strip() or peer + return peer + + @router.post( "/api/v1/webhooks/{token}", tags=["Webhooks"], @@ -67,12 +85,14 @@ async def handle_webhook( engine: AutomationEngine = Depends(get_automation_engine), ): """Receive a webhook call and set the corresponding condition state.""" - _check_rate_limit(request.client.host if request.client else "unknown") + _check_rate_limit(_rate_limit_key(request)) if body.action not in ("activate", "deactivate"): raise HTTPException(status_code=400, detail="action must be 'activate' or 'deactivate'") - # Find the automation that owns this token + # Find the automation that owns this token. Do NOT log token-derived + # values until after a successful match (avoids leaking partial tokens + # for failed lookups). for automation in store.get_all_automations(): for condition in automation.conditions: if isinstance(condition, WebhookCondition) and secrets.compare_digest( @@ -81,8 +101,7 @@ async def handle_webhook( active = body.action == "activate" await engine.set_webhook_state(token, active) logger.info( - "Webhook %s: automation '%s' (%s) → %s", - token[:8], + "Webhook matched automation '%s' (%s) → %s", automation.name, automation.id, body.action, diff --git a/server/src/ledgrab/api/schemas/home_assistant.py b/server/src/ledgrab/api/schemas/home_assistant.py index b33e277..74e8424 100644 --- a/server/src/ledgrab/api/schemas/home_assistant.py +++ b/server/src/ledgrab/api/schemas/home_assistant.py @@ -46,6 +46,13 @@ class HomeAssistantSourceResponse(BaseModel): tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") + token: Optional[str] = Field( + None, + description=( + "Long-Lived Access Token. Redacted as '***' unless the request " + "passes ?include_secrets=true with a non-anonymous API key." + ), + ) class HomeAssistantSourceListResponse(BaseModel): diff --git a/server/src/ledgrab/config.py b/server/src/ledgrab/config.py index ac6782f..78b3c84 100644 --- a/server/src/ledgrab/config.py +++ b/server/src/ledgrab/config.py @@ -76,7 +76,12 @@ class StorageConfig(BaseSettings): class MQTTConfig(BaseSettings): - """MQTT broker configuration.""" + """MQTT broker configuration. + + The ``password`` field accepts either plaintext or an ``ENC:v1:`` envelope + (see :mod:`ledgrab.utils.secret_box`). Use :func:`resolve_mqtt_password` + to obtain the plaintext value at runtime. + """ enabled: bool = False broker_host: str = "localhost" @@ -87,6 +92,37 @@ class MQTTConfig(BaseSettings): base_topic: str = "ledgrab" +def resolve_mqtt_password(cfg: "Config | None" = None) -> str: + """Return the plaintext MQTT password. + + Accepts either an ``ENC:v1:`` envelope or legacy plaintext. If + plaintext is detected, a warning is logged once per process start + so the user knows to migrate. + """ + from ledgrab.utils import get_logger, secret_box + + log = get_logger(__name__) + cfg = cfg or get_config() + pw = cfg.mqtt.password or "" + if not pw: + return "" + if secret_box.is_encrypted(pw): + try: + return secret_box.decrypt(pw) + except Exception as exc: + log.error("Failed to decrypt MQTT password: %s", exc) + return "" + # Plaintext — warn (once) + if not getattr(resolve_mqtt_password, "_warned", False): + log.warning( + "MQTT password in config.yaml is stored in plaintext. " + "Replace with an encrypted envelope (ENC:v1:...) — see " + "ledgrab.utils.secret_box.encrypt()." + ) + resolve_mqtt_password._warned = True # type: ignore[attr-defined] + return pw + + class LoggingConfig(BaseSettings): """Logging configuration.""" @@ -96,6 +132,17 @@ class LoggingConfig(BaseSettings): backup_count: int = 5 +class UpdatesConfig(BaseSettings): + """Auto-update configuration. + + ``allow_unchecked`` enables installs of update artifacts that have no + published sha256 checksum. Default is False — leave it that way unless + you control the release server end-to-end. + """ + + allow_unchecked: bool = False + + class Config(BaseSettings): """Main application configuration.""" @@ -113,6 +160,7 @@ class Config(BaseSettings): assets: AssetsConfig = Field(default_factory=AssetsConfig) mqtt: MQTTConfig = Field(default_factory=MQTTConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig) + updates: UpdatesConfig = Field(default_factory=UpdatesConfig) def model_post_init(self, __context: object) -> None: """Override storage and assets paths when demo mode is active.""" diff --git a/server/src/ledgrab/core/automations/automation_engine.py b/server/src/ledgrab/core/automations/automation_engine.py index 15d25d1..3c4a2cf 100644 --- a/server/src/ledgrab/core/automations/automation_engine.py +++ b/server/src/ledgrab/core/automations/automation_engine.py @@ -265,7 +265,6 @@ class AutomationEngine: needs_fullscreen = "fullscreen" in match_types_used # Single executor call for all platform detection - loop = asyncio.get_event_loop() ( running_procs, topmost_proc, @@ -273,8 +272,7 @@ class AutomationEngine: fullscreen_procs, idle_seconds, display_state, - ) = await loop.run_in_executor( - None, + ) = await asyncio.to_thread( self._detect_all_sync, needs_running, needs_topmost, diff --git a/server/src/ledgrab/core/automations/platform_detector.py b/server/src/ledgrab/core/automations/platform_detector.py index 5fcfcc8..5a0760e 100644 --- a/server/src/ledgrab/core/automations/platform_detector.py +++ b/server/src/ledgrab/core/automations/platform_detector.py @@ -439,15 +439,12 @@ class PlatformDetector: async def get_running_processes(self) -> Set[str]: """Get set of lowercase process names (async-safe).""" - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, self._get_running_processes_sync) + return await asyncio.to_thread(self._get_running_processes_sync) async def get_topmost_process(self) -> tuple: """Get (process_name, is_fullscreen) of the foreground window (async-safe).""" - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, self._get_topmost_process_sync) + return await asyncio.to_thread(self._get_topmost_process_sync) async def get_fullscreen_processes(self) -> Set[str]: """Get set of process names that have a fullscreen window (async-safe).""" - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, self._get_fullscreen_processes_sync) + return await asyncio.to_thread(self._get_fullscreen_processes_sync) diff --git a/server/src/ledgrab/core/capture_engines/scrcpy_engine.py b/server/src/ledgrab/core/capture_engines/scrcpy_engine.py index 2e1be5a..881641b 100644 --- a/server/src/ledgrab/core/capture_engines/scrcpy_engine.py +++ b/server/src/ledgrab/core/capture_engines/scrcpy_engine.py @@ -39,6 +39,19 @@ logger = get_logger(__name__) # --------------------------------------------------------------------------- _ADB_TIMEOUT = 10 # seconds for ADB commands +_SAFE_SERIAL_RE = re.compile(r"^[A-Za-z0-9._:-]+$") + + +def _validate_serial(serial: str) -> str: + """Reject ADB serials that contain shell-unsafe characters. + + ADB serials are nominally alphanumeric (USB) or ``host:port`` (TCP/IP). + We allow only that character set so a malicious-looking value cannot + smuggle command-line flags or path separators into ``adb -s ``. + """ + if not serial or not _SAFE_SERIAL_RE.match(serial): + raise ValueError(f"Unsafe ADB serial: {serial!r}") + return serial def _find_adb() -> str: @@ -105,6 +118,11 @@ def _list_adb_devices() -> List[Dict[str, Any]]: continue serial = parts[0] + try: + _validate_serial(serial) + except ValueError as exc: + logger.warning("Skipping unsafe ADB serial %r: %s", serial, exc) + continue # Extract model name from '-l' output (e.g. model:Pixel_6) model = serial @@ -197,7 +215,9 @@ class ScrcpyCaptureStream(CaptureStream): ) device = devices[self.display_index] - self._device_serial = device["serial"] + # Defensive: even though _list_adb_devices filters now, re-validate + # in case a serial was injected via cached/persisted state. + self._device_serial = _validate_serial(device["serial"]) logger.info( f"ADB screencap: initializing capture for device " f"{self._device_serial} ({device['model']}, " diff --git a/server/src/ledgrab/core/devices/adalight_provider.py b/server/src/ledgrab/core/devices/adalight_provider.py index 45a55be..8368371 100644 --- a/server/src/ledgrab/core/devices/adalight_provider.py +++ b/server/src/ledgrab/core/devices/adalight_provider.py @@ -1,21 +1,11 @@ """Adalight device provider — serial LED controller using Adalight protocol.""" -from ledgrab.core.devices.led_client import LEDClient +from ledgrab.core.devices.adalight_client import AdalightClient from ledgrab.core.devices.serial_provider import SerialDeviceProvider class AdalightDeviceProvider(SerialDeviceProvider): """Provider for Adalight serial LED controllers.""" - @property - def device_type(self) -> str: - return "adalight" - - def create_client(self, url: str, **kwargs) -> LEDClient: - from ledgrab.core.devices.adalight_client import AdalightClient - - return AdalightClient( - url, - led_count=kwargs.get("led_count", 0), - baud_rate=kwargs.get("baud_rate"), - ) + client_cls = AdalightClient + _device_type = "adalight" diff --git a/server/src/ledgrab/core/devices/ambiled_provider.py b/server/src/ledgrab/core/devices/ambiled_provider.py index b21899e..afd1ca8 100644 --- a/server/src/ledgrab/core/devices/ambiled_provider.py +++ b/server/src/ledgrab/core/devices/ambiled_provider.py @@ -1,21 +1,11 @@ """AmbiLED device provider — serial LED controller using AmbiLED protocol.""" -from ledgrab.core.devices.led_client import LEDClient +from ledgrab.core.devices.ambiled_client import AmbiLEDClient from ledgrab.core.devices.serial_provider import SerialDeviceProvider class AmbiLEDDeviceProvider(SerialDeviceProvider): """Provider for AmbiLED serial LED controllers.""" - @property - def device_type(self) -> str: - return "ambiled" - - def create_client(self, url: str, **kwargs) -> LEDClient: - from ledgrab.core.devices.ambiled_client import AmbiLEDClient - - return AmbiLEDClient( - url, - led_count=kwargs.get("led_count", 0), - baud_rate=kwargs.get("baud_rate"), - ) + client_cls = AmbiLEDClient + _device_type = "ambiled" diff --git a/server/src/ledgrab/core/devices/ddp_client.py b/server/src/ledgrab/core/devices/ddp_client.py index 1800ce4..034484c 100644 --- a/server/src/ledgrab/core/devices/ddp_client.py +++ b/server/src/ledgrab/core/devices/ddp_client.py @@ -61,7 +61,7 @@ class DDPClient: async def connect(self): """Establish UDP connection.""" try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() self._transport, self._protocol = await loop.create_datagram_endpoint( asyncio.DatagramProtocol, remote_addr=(self.host, self.port) ) diff --git a/server/src/ledgrab/core/devices/dmx_client.py b/server/src/ledgrab/core/devices/dmx_client.py index 289bb06..6a7f1d1 100644 --- a/server/src/ledgrab/core/devices/dmx_client.py +++ b/server/src/ledgrab/core/devices/dmx_client.py @@ -98,7 +98,7 @@ class DMXClient(LEDClient): async def connect(self) -> bool: try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() self._transport, self._protocol_obj = await loop.create_datagram_endpoint( asyncio.DatagramProtocol, remote_addr=(self.host, self.port), diff --git a/server/src/ledgrab/core/devices/espnow_client.py b/server/src/ledgrab/core/devices/espnow_client.py index b812385..d9f69d1 100644 --- a/server/src/ledgrab/core/devices/espnow_client.py +++ b/server/src/ledgrab/core/devices/espnow_client.py @@ -134,12 +134,8 @@ class ESPNowClient(LEDClient): ) -> bool: if not self.is_connected: return False - loop = asyncio.get_event_loop() try: - await loop.run_in_executor( - None, - lambda: self.send_pixels_fast(pixels, brightness), - ) + await asyncio.to_thread(self.send_pixels_fast, pixels, brightness) return True except Exception as e: logger.error("ESP-NOW async send failed: %s", e) diff --git a/server/src/ledgrab/core/devices/hue_client.py b/server/src/ledgrab/core/devices/hue_client.py index 9c4c12e..f53accd 100644 --- a/server/src/ledgrab/core/devices/hue_client.py +++ b/server/src/ledgrab/core/devices/hue_client.py @@ -85,8 +85,6 @@ class HueClient(LEDClient): self._dtls_sock = None async def connect(self) -> bool: - loop = asyncio.get_event_loop() - # Activate entertainment streaming via REST API await self._activate_streaming(True) @@ -109,10 +107,7 @@ class HueClient(LEDClient): psk = bytes.fromhex(self._client_key) ctx.set_psk_client_callback(lambda hint: (self._username.encode(), psk)) self._dtls_sock = ctx.wrap_socket(self._sock, server_hostname=self._bridge_ip) - await loop.run_in_executor( - None, - lambda: self._dtls_sock.connect((self._bridge_ip, HUE_ENT_PORT)), - ) + await asyncio.to_thread(self._dtls_sock.connect, (self._bridge_ip, HUE_ENT_PORT)) logger.info("Hue DTLS connection established to %s", self._bridge_ip) except (ImportError, Exception) as e: # Fall back to plain UDP (works for local testing / older bridges) @@ -207,11 +202,7 @@ class HueClient(LEDClient): ) -> bool: if not self._connected: return False - loop = asyncio.get_event_loop() - await loop.run_in_executor( - None, - lambda: self.send_pixels_fast(pixels, brightness), - ) + await asyncio.to_thread(self.send_pixels_fast, pixels, brightness) return True @classmethod diff --git a/server/src/ledgrab/core/devices/openrgb_client.py b/server/src/ledgrab/core/devices/openrgb_client.py index f82f996..4232a25 100644 --- a/server/src/ledgrab/core/devices/openrgb_client.py +++ b/server/src/ledgrab/core/devices/openrgb_client.py @@ -412,13 +412,14 @@ class OpenRGBLEDClient(LEDClient): re-downloading all device data every health check cycle (~30s). """ host, port, device_index, _zones = parse_openrgb_url(url) - start = asyncio.get_event_loop().time() + loop = asyncio.get_running_loop() + start = loop.time() try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3.0) await asyncio.to_thread(sock.connect, (host, port)) sock.close() - latency = (asyncio.get_event_loop().time() - start) * 1000 + latency = (loop.time() - start) * 1000 # Preserve cached device metadata from previous health check device_name = prev_health.device_name if prev_health else None diff --git a/server/src/ledgrab/core/devices/serial_provider.py b/server/src/ledgrab/core/devices/serial_provider.py index 60a660f..eef7a74 100644 --- a/server/src/ledgrab/core/devices/serial_provider.py +++ b/server/src/ledgrab/core/devices/serial_provider.py @@ -24,7 +24,32 @@ logger = get_logger(__name__) class SerialDeviceProvider(LEDDeviceProvider): - """Base provider for serial LED controllers.""" + """Base provider for serial LED controllers. + + Subclasses declare ``client_cls`` and ``_device_type`` as class attributes + instead of overriding ``create_client`` / ``device_type`` directly. + Subclasses that need custom client construction may still override + ``create_client``. + """ + + # Class-level config — subclasses override these. + client_cls = None # e.g. AdalightClient + _device_type: str = "" + + @property + def device_type(self) -> str: + return self._device_type + + def create_client(self, url: str, **kwargs): + if self.client_cls is None: + raise NotImplementedError( + f"{type(self).__name__} must set client_cls or override create_client" + ) + return self.client_cls( + url, + led_count=kwargs.get("led_count", 0), + baud_rate=kwargs.get("baud_rate"), + ) @property def capabilities(self) -> set: diff --git a/server/src/ledgrab/core/devices/spi_client.py b/server/src/ledgrab/core/devices/spi_client.py index bcef16e..776f80d 100644 --- a/server/src/ledgrab/core/devices/spi_client.py +++ b/server/src/ledgrab/core/devices/spi_client.py @@ -77,12 +77,10 @@ class SPIClient(LEDClient): self._connected = False async def connect(self) -> bool: - loop = asyncio.get_event_loop() - if self._config["method"] == "gpio": - await loop.run_in_executor(None, self._connect_rpi_ws281x) + await asyncio.to_thread(self._connect_rpi_ws281x) else: - await loop.run_in_executor(None, self._connect_spidev) + await asyncio.to_thread(self._connect_spidev) self._connected = True logger.info( @@ -132,7 +130,6 @@ class SPIClient(LEDClient): self._spi.mode = 0 async def close(self) -> None: - loop = asyncio.get_event_loop() if self._strip: # Turn off all LEDs def _clear(): @@ -140,10 +137,10 @@ class SPIClient(LEDClient): self._strip.setPixelColor(i, 0) self._strip.show() - await loop.run_in_executor(None, _clear) + await asyncio.to_thread(_clear) self._strip = None if self._spi: - await loop.run_in_executor(None, self._spi.close) + await asyncio.to_thread(self._spi.close) self._spi = None self._connected = False logger.info("SPI client closed") @@ -213,12 +210,8 @@ class SPIClient(LEDClient): ) -> bool: if not self._connected: return False - loop = asyncio.get_event_loop() try: - await loop.run_in_executor( - None, - lambda: self.send_pixels_fast(pixels, brightness), - ) + await asyncio.to_thread(self.send_pixels_fast, pixels, brightness) return True except Exception as e: logger.error("SPI send failed: %s", e) diff --git a/server/src/ledgrab/core/devices/usbhid_client.py b/server/src/ledgrab/core/devices/usbhid_client.py index 9e9b866..07ff008 100644 --- a/server/src/ledgrab/core/devices/usbhid_client.py +++ b/server/src/ledgrab/core/devices/usbhid_client.py @@ -56,15 +56,13 @@ class USBHIDClient(LEDClient): except ImportError: raise RuntimeError("hidapi is required for USB HID devices: pip install hidapi") - loop = asyncio.get_event_loop() - def _open(): device = hid.device() device.open(self._vid, self._pid) device.set_nonblocking(True) return device - self._device = await loop.run_in_executor(None, _open) + self._device = await asyncio.to_thread(_open) self._connected = True manufacturer = self._device.get_manufacturer_string() or "Unknown" @@ -81,8 +79,7 @@ class USBHIDClient(LEDClient): async def close(self) -> None: if self._device: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._device.close) + await asyncio.to_thread(self._device.close) self._device = None self._connected = False logger.info("USB HID client closed: %04X:%04X", self._vid, self._pid) @@ -130,14 +127,12 @@ class USBHIDClient(LEDClient): reports.append(bytes(report)) offset += len(chunk) - loop = asyncio.get_event_loop() - def _send_all(): for report in reports: self._device.write(report) try: - await loop.run_in_executor(None, _send_all) + await asyncio.to_thread(_send_all) return True except Exception as e: logger.error("USB HID send failed: %s", e) diff --git a/server/src/ledgrab/core/devices/wled_provider.py b/server/src/ledgrab/core/devices/wled_provider.py index 4b4b028..0ec757a 100644 --- a/server/src/ledgrab/core/devices/wled_provider.py +++ b/server/src/ledgrab/core/devices/wled_provider.py @@ -1,7 +1,9 @@ """WLED device provider — consolidates all WLED-specific dispatch logic.""" import asyncio -from typing import List, Optional, Tuple +import json +import time +from typing import Dict, List, Optional, Tuple import httpx from zeroconf import ServiceStateChange @@ -13,12 +15,26 @@ from ledgrab.core.devices.led_client import ( LEDClient, LEDDeviceProvider, ) +from ledgrab.core.devices.wled_client import WLEDClient from ledgrab.utils import get_logger logger = get_logger(__name__) WLED_MDNS_TYPE = "_wled._tcp.local." DEFAULT_SCAN_TIMEOUT = 3.0 +_STATE_CACHE_TTL_SEC = 0.25 + + +def _normalize_url(url: str) -> str: + """Strip trailing slashes from a base WLED URL once.""" + return url.rstrip("/") + + +def _join(base: str, path: str) -> str: + """Build a full WLED URL from a normalised base and a path starting with /.""" + if not path.startswith("/"): + path = "/" + path + return base + path class WLEDDeviceProvider(LEDDeviceProvider): @@ -27,6 +43,8 @@ class WLEDDeviceProvider(LEDDeviceProvider): def __init__(self): self._http_client: Optional[httpx.AsyncClient] = None self._client_lock = asyncio.Lock() + # Per-base-URL state cache: base -> (expires_at, json_state_dict) + self._state_cache: Dict[str, tuple] = {} async def _get_client(self) -> httpx.AsyncClient: """Return a shared HTTP client (connection-pooled, thread-safe init).""" @@ -59,16 +77,12 @@ class WLEDDeviceProvider(LEDDeviceProvider): } def create_client(self, url: str, **kwargs) -> LEDClient: - from ledgrab.core.devices.wled_client import WLEDClient - return WLEDClient( url, use_ddp=kwargs.get("use_ddp", False), ) async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: - from ledgrab.core.devices.wled_client import WLEDClient - return await WLEDClient.check_health(url, http_client, prev_health) async def validate_device(self, url: str) -> dict: @@ -82,9 +96,9 @@ class WLEDDeviceProvider(LEDDeviceProvider): httpx.TimeoutException: Connection timed out. ValueError: Invalid LED count. """ - url = url.rstrip("/") + url = _normalize_url(url) async with httpx.AsyncClient(timeout=5) as client: - response = await client.get(f"{url}/json/info") + response = await client.get(_join(url, "/json/info")) response.raise_for_status() wled_info = response.json() led_count = wled_info.get("leds", {}).get("count") @@ -108,6 +122,8 @@ class WLEDDeviceProvider(LEDDeviceProvider): state_change = kwargs.get("state_change") if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated): discovered[name] = AsyncServiceInfo(service_type, name) + elif state_change == ServiceStateChange.Removed: + discovered.pop(name, None) aiozc = AsyncZeroconf() browser = AsyncServiceBrowser(aiozc.zeroconf, WLED_MDNS_TYPE, handlers=[on_state_change]) @@ -155,70 +171,82 @@ class WLEDDeviceProvider(LEDDeviceProvider): logger.info(f"mDNS scan found {len(results)} WLED device(s)") return results - @staticmethod async def _enrich_device( - url: str, fallback_name: str + self, url: str, fallback_name: str ) -> tuple[str, Optional[str], Optional[int], str]: - """Probe a WLED device's /json/info to get name, version, LED count, MAC.""" + """Probe a WLED device's /json/info to get name, version, LED count, MAC. + + Reuses the shared HTTP client so discovery probes share connection-pool state. + """ try: - async with httpx.AsyncClient(timeout=2) as client: - resp = await client.get(f"{url}/json/info") - resp.raise_for_status() - data = resp.json() - return ( - data.get("name", fallback_name), - data.get("ver"), - data.get("leds", {}).get("count"), - data.get("mac", ""), - ) - except Exception as e: + client = await self._get_client() + resp = await client.get(_join(_normalize_url(url), "/json/info"), timeout=2.0) + resp.raise_for_status() + data = resp.json() + return ( + data.get("name", fallback_name), + data.get("ver"), + data.get("leds", {}).get("count"), + data.get("mac", ""), + ) + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError) as e: logger.debug(f"Could not fetch WLED info from {url}: {e}") return fallback_name, None, None, "" - # ===== BRIGHTNESS ===== + # ===== STATE ===== - async def get_brightness(self, url: str) -> int: - url = url.rstrip("/") + async def get_state(self, url: str) -> dict: + """Fetch /json/state with a brief TTL cache to coalesce close calls.""" + base = _normalize_url(url) + now = time.monotonic() + cached = self._state_cache.get(base) + if cached and cached[0] > now: + return cached[1] client = await self._get_client() - resp = await client.get(f"{url}/json/state") + resp = await client.get(_join(base, "/json/state")) resp.raise_for_status() state = resp.json() + self._state_cache[base] = (now + _STATE_CACHE_TTL_SEC, state) + return state + + def _invalidate_state(self, url: str) -> None: + """Drop the cached state for a base URL after a write that may have changed it.""" + self._state_cache.pop(_normalize_url(url), None) + + # ===== BRIGHTNESS / POWER / COLOR ===== + + async def get_brightness(self, url: str) -> int: + state = await self.get_state(url) return state.get("bri", 255) async def set_brightness(self, url: str, brightness: int) -> None: - url = url.rstrip("/") + base = _normalize_url(url) client = await self._get_client() - resp = await client.post( - f"{url}/json/state", - json={"bri": brightness}, - ) + resp = await client.post(_join(base, "/json/state"), json={"bri": brightness}) resp.raise_for_status() + self._invalidate_state(base) async def get_power(self, url: str, **kwargs) -> bool: - url = url.rstrip("/") - client = await self._get_client() - resp = await client.get(f"{url}/json/state") - resp.raise_for_status() - return resp.json().get("on", False) + state = await self.get_state(url) + return state.get("on", False) async def set_power(self, url: str, on: bool, **kwargs) -> None: - url = url.rstrip("/") + base = _normalize_url(url) client = await self._get_client() - resp = await client.post( - f"{url}/json/state", - json={"on": on}, - ) + resp = await client.post(_join(base, "/json/state"), json={"on": on}) resp.raise_for_status() + self._invalidate_state(base) async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: """Set WLED to a solid color using the native segment color API.""" - url = url.rstrip("/") + base = _normalize_url(url) client = await self._get_client() resp = await client.post( - f"{url}/json/state", + _join(base, "/json/state"), json={ "on": True, "seg": [{"col": [[color[0], color[1], color[2]]], "fx": 0}], }, ) resp.raise_for_status() + self._invalidate_state(base) diff --git a/server/src/ledgrab/core/processing/audio_stream.py b/server/src/ledgrab/core/processing/audio_stream.py index dbd156e..ad25af3 100644 --- a/server/src/ledgrab/core/processing/audio_stream.py +++ b/server/src/ledgrab/core/processing/audio_stream.py @@ -27,6 +27,7 @@ from ledgrab.core.audio.music_analyzer import MusicAnalyzer from ledgrab.core.processing.color_strip_stream import ColorStripStream from ledgrab.core.processing.effect_stream import _build_palette_lut from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter from ledgrab.utils.timer import high_resolution_timer logger = get_logger(__name__) @@ -317,10 +318,12 @@ class AudioColorStripStream(ColorStripStream): "strobe_on_drop": self._render_strobe_on_drop, } + limiter = FrameLimiter(self._fps) + try: with high_resolution_timer(): while self._running: - loop_start = time.perf_counter() + limiter.begin() frame_time = self._frame_time try: n = self._led_count @@ -386,8 +389,7 @@ class AudioColorStripStream(ColorStripStream): except Exception as e: logger.error(f"AudioColorStripStream render error: {e}") - elapsed = time.perf_counter() - loop_start - time.sleep(max(frame_time - elapsed, 0.001)) + limiter.wait(frame_time) except Exception as e: logger.error(f"Fatal AudioColorStripStream loop error: {e}", exc_info=True) finally: diff --git a/server/src/ledgrab/core/processing/candlelight_stream.py b/server/src/ledgrab/core/processing/candlelight_stream.py index 7d0db5c..e13494b 100644 --- a/server/src/ledgrab/core/processing/candlelight_stream.py +++ b/server/src/ledgrab/core/processing/candlelight_stream.py @@ -20,6 +20,7 @@ import numpy as np from ledgrab.core.processing.color_strip_stream import ColorStripStream from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter from ledgrab.utils.timer import high_resolution_timer logger = get_logger(__name__) @@ -167,9 +168,12 @@ class CandlelightColorStripStream(ColorStripStream): _buf_a = _buf_b = None _use_a = True + limiter = FrameLimiter(self._fps) + try: with high_resolution_timer(): while self._running: + limiter.begin() wall_start = time.perf_counter() frame_time = self._frame_time try: @@ -205,8 +209,7 @@ class CandlelightColorStripStream(ColorStripStream): except Exception as e: logger.error(f"CandlelightColorStripStream animation error: {e}") - elapsed = time.perf_counter() - wall_start - time.sleep(max(frame_time - elapsed, 0.001)) + limiter.wait(frame_time) except Exception as e: logger.error(f"Fatal CandlelightColorStripStream loop error: {e}", exc_info=True) finally: diff --git a/server/src/ledgrab/core/processing/color_strip/__init__.py b/server/src/ledgrab/core/processing/color_strip/__init__.py new file mode 100644 index 0000000..2f85db3 --- /dev/null +++ b/server/src/ledgrab/core/processing/color_strip/__init__.py @@ -0,0 +1,24 @@ +"""Color strip stream subpackage — split from the original color_strip_stream.py. + +All public class names are re-exported here so external imports of the form +``from ledgrab.core.processing.color_strip_stream import X`` continue to work +via the shim module ``color_strip_stream.py``. +""" + +from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise +from .cycle import ColorCycleColorStripStream +from .gradient import GradientColorStripStream +from .helpers import _compute_gradient_colors +from .picture import PictureColorStripStream +from .static import StaticColorStripStream + +__all__ = [ + "ColorStripStream", + "PictureColorStripStream", + "StaticColorStripStream", + "ColorCycleColorStripStream", + "GradientColorStripStream", + "_compute_gradient_colors", + "_SimpleNoise1D", + "_gradient_noise", +] diff --git a/server/src/ledgrab/core/processing/color_strip/base.py b/server/src/ledgrab/core/processing/color_strip/base.py new file mode 100644 index 0000000..86fa0df --- /dev/null +++ b/server/src/ledgrab/core/processing/color_strip/base.py @@ -0,0 +1,128 @@ +"""Base ColorStripStream ABC and shared noise utility.""" + +from abc import ABC, abstractmethod +from typing import Optional + +import numpy as np + +from ledgrab.core.capture.calibration import CalibrationConfig +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + + +class _SimpleNoise1D: + """Minimal 1-D value noise for gradient perturbation (avoids circular import).""" + + def __init__(self, seed: int = 99): + rng = np.random.RandomState(seed) + self._table = rng.random(512).astype(np.float32) + + def noise(self, x: np.ndarray) -> np.ndarray: + size = len(self._table) + xi = np.floor(x).astype(np.int64) + frac = x - np.floor(x) + t = frac * frac * (3.0 - 2.0 * frac) + a = self._table[xi % size] + b = self._table[(xi + 1) % size] + return a + t * (b - a) + + +# Module-level noise for gradient perturbation +_gradient_noise = _SimpleNoise1D(seed=99) + + +class ColorStripStream(ABC): + """Abstract base: a runtime source of LED color arrays. + + Produces a continuous stream of np.ndarray (led_count, 3) uint8 values. + Consumers call get_latest_colors() (non-blocking) to read the most recent + computed frame. + """ + + @property + @abstractmethod + def target_fps(self) -> int: + """Target processing rate.""" + + @property + @abstractmethod + def led_count(self) -> int: + """Number of LEDs this stream produces colors for.""" + + @property + def is_animated(self) -> bool: + """Whether this stream is actively producing new frames. + + Used by the processor to adjust polling rate: animated streams are + polled at 5 ms (SKIP_REPOLL) to stay in sync with the animation + thread, while static streams are polled at frame_time rate. + """ + return True + + @property + def display_index(self) -> Optional[int]: + """Display index of the underlying capture, or None.""" + return None + + @property + def calibration(self) -> Optional[CalibrationConfig]: + """Calibration config, or None if not applicable.""" + return None + + @abstractmethod + def start(self) -> None: + """Start producing colors.""" + + @abstractmethod + def stop(self) -> None: + """Stop producing colors and release resources.""" + + @abstractmethod + def get_latest_colors(self) -> Optional[np.ndarray]: + """Get the most recent LED color array (led_count, 3) uint8, or None.""" + + def get_last_timing(self) -> dict: + """Return per-stage timing from the last processed frame (ms).""" + return {} + + def update_source(self, source) -> None: + """Hot-update processing parameters. No-op by default.""" + + # ── BindableFloat value stream resolution ── + + _value_streams: dict = None # property_name → ValueStream + + def set_value_stream(self, prop: str, stream) -> None: + """Inject a ValueStream for a bindable property.""" + if self._value_streams is None: + self._value_streams = {} + self._value_streams[prop] = stream + + def remove_value_stream(self, prop: str) -> None: + """Remove a ValueStream for a bindable property.""" + if self._value_streams: + self._value_streams.pop(prop, None) + + def resolve(self, prop: str, static: float) -> float: + """Resolve a bindable property: ValueStream value if bound, else static.""" + if self._value_streams: + vs = self._value_streams.get(prop) + if vs is not None: + try: + return vs.get_value() + except Exception: + pass + return static + + def resolve_color(self, prop: str, static: list) -> list: + """Resolve a bindable color: ValueStream color if bound, else static [R,G,B].""" + if self._value_streams: + vs = self._value_streams.get(prop) + if vs is not None: + try: + c = vs.get_color() + return [c[0], c[1], c[2]] + except Exception: + pass + return static diff --git a/server/src/ledgrab/core/processing/color_strip/cycle.py b/server/src/ledgrab/core/processing/color_strip/cycle.py new file mode 100644 index 0000000..9b2f036 --- /dev/null +++ b/server/src/ledgrab/core/processing/color_strip/cycle.py @@ -0,0 +1,185 @@ +"""Color cycle stream — smoothly cycles through user-defined colors.""" + +import threading +import time +from typing import Optional + +import numpy as np + +from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter +from ledgrab.utils.timer import high_resolution_timer + +from .base import ColorStripStream + +logger = get_logger(__name__) + + +class ColorCycleColorStripStream(ColorStripStream): + """Color strip stream that smoothly cycles through a user-defined color list. + + All LEDs receive the same solid color at any moment, continuously interpolating + between the configured colors in a loop. + + LED count auto-sizes from the connected device when led_count == 0 in + the source config; configure(device_led_count) is called by + WledTargetProcessor on start. + """ + + def __init__(self, source): + self._colors_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._fps = 30 + self._frame_time = 1.0 / 30 + self._clock = None # optional SyncClockRuntime + self._update_from_source(source) + + def _update_from_source(self, source) -> None: + raw = source.colors if isinstance(source.colors, list) else [] + default = [ + [255, 0, 0], + [255, 255, 0], + [0, 255, 0], + [0, 255, 255], + [0, 0, 255], + [255, 0, 255], + ] + self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default + _lc = getattr(source, "led_count", 0) + self._auto_size = not _lc + self._led_count = _lc if _lc > 0 else 1 + self._rebuild_colors() + + def _rebuild_colors(self) -> None: + pixel = np.array(self._color_list[0], dtype=np.uint8) + colors = np.tile(pixel, (self._led_count, 1)) + with self._colors_lock: + self._colors = colors + + def configure(self, device_led_count: int) -> None: + """Size to device LED count when led_count was 0 (auto-size).""" + if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: + self._led_count = device_led_count + self._rebuild_colors() + logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs") + + @property + def target_fps(self) -> int: + return self._fps + + @property + def led_count(self) -> int: + return self._led_count + + def set_capture_fps(self, fps: int) -> None: + """Update animation loop rate. Thread-safe (read atomically by the loop).""" + fps = max(1, min(90, fps)) + self._fps = fps + self._frame_time = 1.0 / fps + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._animate_loop, + name="css-color-cycle", + daemon=True, + ) + self._thread.start() + logger.info( + f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})" + ) + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning( + "ColorCycleColorStripStream animate thread did not terminate within 5s" + ) + self._thread = None + logger.info("ColorCycleColorStripStream stopped") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._colors + + def update_source(self, source) -> None: + from ledgrab.storage.color_strip_source import ColorCycleColorStripSource + + if isinstance(source, ColorCycleColorStripSource): + prev_led_count = self._led_count if self._auto_size else None + self._update_from_source(source) + if prev_led_count and self._auto_size: + self._led_count = prev_led_count + self._rebuild_colors() + logger.info("ColorCycleColorStripStream params updated in-place") + + def set_clock(self, clock) -> None: + """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" + self._clock = clock + + def _animate_loop(self) -> None: + """Background thread: interpolate between colors at target fps. + + Uses double-buffered output arrays to avoid per-frame allocations. + """ + _pool_n = 0 + _buf_a = _buf_b = None + _use_a = True + + limiter = FrameLimiter(self._fps) + + try: + with high_resolution_timer(): + while self._running: + limiter.begin() + wall_start = time.perf_counter() + frame_time = self._frame_time + try: + color_list = self._color_list + clock = self._clock + if clock: + if not clock.is_running: + time.sleep(0.1) + continue + speed = clock.speed + t = clock.get_time() + else: + speed = 1.0 + t = wall_start + n = self._led_count + num = len(color_list) + if num >= 2: + if n != _pool_n: + _pool_n = n + _buf_a = np.empty((n, 3), dtype=np.uint8) + _buf_b = np.empty((n, 3), dtype=np.uint8) + + buf = _buf_a if _use_a else _buf_b + _use_a = not _use_a + + # 0.05 factor → one full cycle every 20s at speed=1.0 + cycle_pos = (speed * t * 0.05) % 1.0 + seg = cycle_pos * num + idx = int(seg) % num + t_i = seg - int(seg) + c1 = color_list[idx] + c2 = color_list[(idx + 1) % num] + buf[:] = ( + min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)), + min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)), + min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)), + ) + with self._colors_lock: + self._colors = buf + except Exception as e: + logger.error(f"ColorCycleColorStripStream animation error: {e}") + limiter.wait(frame_time) + except Exception as e: + logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True) + finally: + self._running = False diff --git a/server/src/ledgrab/core/processing/color_strip/gradient.py b/server/src/ledgrab/core/processing/color_strip/gradient.py new file mode 100644 index 0000000..f287fc1 --- /dev/null +++ b/server/src/ledgrab/core/processing/color_strip/gradient.py @@ -0,0 +1,447 @@ +"""Gradient color strip stream — pre-computed gradient with optional animation.""" + +import math +import threading +import time +from typing import Optional + +import numpy as np + +from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter +from ledgrab.utils.timer import high_resolution_timer + +from .base import ColorStripStream, _gradient_noise +from .helpers import _compute_gradient_colors + +logger = get_logger(__name__) + + +class GradientColorStripStream(ColorStripStream): + """Color strip stream that distributes a gradient across all LEDs. + + Produces a pre-computed (led_count, 3) uint8 array from user-defined + color stops. When animation is enabled a background thread applies + dynamic effects (breathing, gradient_shift, wave). + + LED count auto-sizes from the connected device when led_count == 0 in + the source config; configure(device_led_count) is called by + WledTargetProcessor on start. + """ + + def __init__(self, source): + self._colors_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._fps = 30 + self._frame_time = 1.0 / 30 + self._clock = None # optional SyncClockRuntime + self._gradient_store = None # injected by stream manager + self._update_from_source(source) + + def set_gradient_store(self, gradient_store) -> None: + """Inject gradient store for resolving gradient_id to stops.""" + self._gradient_store = gradient_store + # Re-resolve stops if gradient_id is set + gradient_id = getattr(self, "_gradient_id", None) + if gradient_id and self._gradient_store: + stops = self._gradient_store.resolve_stops(gradient_id) + if stops: + self._stops = stops + self._rebuild_colors() + + def _update_from_source(self, source) -> None: + self._gradient_id = getattr(source, "gradient_id", None) + self._stops = list(source.stops) if source.stops else [] + # Override inline stops with gradient entity if set + if self._gradient_id and self._gradient_store: + resolved = self._gradient_store.resolve_stops(self._gradient_id) + if resolved: + self._stops = resolved + _lc = getattr(source, "led_count", 0) + self._auto_size = not _lc + led_count = _lc if _lc and _lc > 0 else 1 + self._led_count = led_count + self._animation = source.animation # dict or None; read atomically by _animate_loop + self._easing = getattr(source, "easing", "linear") or "linear" + self._rebuild_colors() + + def _rebuild_colors(self) -> None: + colors = _compute_gradient_colors(self._stops, self._led_count, self._easing) + with self._colors_lock: + self._colors = colors + + def configure(self, device_led_count: int) -> None: + """Size to device LED count when led_count was 0 (auto-size). + + When multiple targets share this stream, uses the maximum LED count + so the gradient covers enough resolution for all consumers. Targets + with fewer LEDs get a properly resampled gradient via _fit_to_device. + """ + if self._auto_size and device_led_count > 0: + new_count = max(self._led_count, device_led_count) + if new_count != self._led_count: + self._led_count = new_count + self._rebuild_colors() + logger.debug(f"GradientColorStripStream auto-sized to {new_count} LEDs") + + @property + def target_fps(self) -> int: + return self._fps + + @property + def is_animated(self) -> bool: + anim = self._animation + return bool(anim and anim.get("enabled")) + + @property + def led_count(self) -> int: + return self._led_count + + def set_capture_fps(self, fps: int) -> None: + """Update animation loop rate. Thread-safe (read atomically by the loop).""" + fps = max(1, min(90, fps)) + self._fps = fps + self._frame_time = 1.0 / fps + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._animate_loop, + name="css-gradient-animate", + daemon=True, + ) + self._thread.start() + logger.info( + f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})" + ) + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning( + "GradientColorStripStream animate thread did not terminate within 5s" + ) + self._thread = None + logger.info("GradientColorStripStream stopped") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._colors + + def update_source(self, source) -> None: + from ledgrab.storage.color_strip_source import GradientColorStripSource + + if isinstance(source, GradientColorStripSource): + prev_led_count = self._led_count if self._auto_size else None + self._update_from_source(source) + # Preserve runtime LED count across hot-updates when auto-sized + if prev_led_count and self._auto_size: + self._led_count = prev_led_count + self._rebuild_colors() + logger.info("GradientColorStripStream params updated in-place") + + def set_clock(self, clock) -> None: + """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" + self._clock = clock + + def _animate_loop(self) -> None: + """Background thread: apply animation effects at target fps when animation is active. + + Uses double-buffered output arrays plus a uint16 scratch buffer for + integer-math brightness scaling, avoiding per-frame numpy allocations. + """ + _cached_base: Optional[np.ndarray] = None + _cached_n: int = 0 + _cached_stops: Optional[list] = None + _cached_easing: str = "" + # Double-buffer pool + uint16 scratch for brightness math + _pool_n = 0 + _buf_a = _buf_b = _scratch_u16 = None + _use_a = True + _wave_i = None # cached np.arange for wave animation + _wave_factors = None # float32 scratch for wave sin result + _wave_u16 = None # uint16 scratch for wave int factors + + limiter = FrameLimiter(self._fps) + + try: + with high_resolution_timer(): + while self._running: + limiter.begin() + wall_start = time.perf_counter() + frame_time = self._frame_time + try: + anim = self._animation + if anim and anim.get("enabled"): + clock = self._clock + if clock: + if not clock.is_running: + time.sleep(0.1) + continue + speed = clock.speed + t = clock.get_time() + else: + speed = 1.0 + t = wall_start + atype = anim.get("type", "breathing") + n = self._led_count + stops = self._stops + colors = None + + # Recompute base gradient only when stops, led_count, or easing change + easing = self._easing + if ( + _cached_base is None + or _cached_n != n + or _cached_stops is not stops + or _cached_easing != easing + ): + _cached_base = _compute_gradient_colors(stops, n, easing) + _cached_n = n + _cached_stops = stops + _cached_easing = easing + base = _cached_base + + # Re-allocate pool only when LED count changes + if n != _pool_n: + _pool_n = n + _buf_a = np.empty((n, 3), dtype=np.uint8) + _buf_b = np.empty((n, 3), dtype=np.uint8) + _scratch_u16 = np.empty((n, 3), dtype=np.uint16) + _wave_i = np.arange(n, dtype=np.float32) + _wave_factors = np.empty(n, dtype=np.float32) + _wave_u16 = np.empty(n, dtype=np.uint16) + + buf = _buf_a if _use_a else _buf_b + _use_a = not _use_a + + if atype == "breathing": + int_f = max( + 0, + min( + 256, + int( + 0.5 + * (1 + math.sin(2 * math.pi * speed * t * 0.5)) + * 256 + ), + ), + ) + np.copyto(_scratch_u16, base) + _scratch_u16 *= int_f + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting="unsafe") + colors = buf + + elif atype == "gradient_shift": + shift = int(speed * t * 10) % max(n, 1) + if shift > 0: + buf[: n - shift] = base[shift:] + buf[n - shift :] = base[:shift] + else: + np.copyto(buf, base) + colors = buf + + elif atype == "wave": + if n > 1: + np.sin( + 2 * math.pi * _wave_i / n - 2 * math.pi * speed * t * 0.25, + out=_wave_factors, + ) + _wave_factors *= 0.5 + _wave_factors += 0.5 + np.multiply(_wave_factors, 256, out=_wave_factors) + np.clip(_wave_factors, 0, 256, out=_wave_factors) + np.copyto(_wave_u16, _wave_factors, casting="unsafe") + np.copyto(_scratch_u16, base) + _scratch_u16 *= _wave_u16[:, None] + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting="unsafe") + colors = buf + else: + np.copyto(buf, base) + colors = buf + + elif atype == "strobe": + if math.sin(2 * math.pi * speed * t * 2.0) >= 0: + np.copyto(buf, base) + else: + buf[:] = 0 + colors = buf + + elif atype == "sparkle": + np.copyto(buf, base) + density = min(0.5, 0.1 * speed) + mask = np.random.random(n) < density + buf[mask] = (255, 255, 255) + colors = buf + + elif atype == "pulse": + phase = (speed * t * 1.0) % 1.0 + if phase < 0.1: + factor = phase / 0.1 + else: + factor = math.exp(-5.0 * (phase - 0.1)) + int_f = max(0, min(256, int(factor * 256))) + np.copyto(_scratch_u16, base) + _scratch_u16 *= int_f + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting="unsafe") + colors = buf + + elif atype == "candle": + base_factor = 0.75 + flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7) + flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3) + flicker += 0.10 * (np.random.random() - 0.5) + factor = max(0.2, min(1.0, base_factor + flicker)) + int_f = int(factor * 256) + np.copyto(_scratch_u16, base) + _scratch_u16 *= int_f + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting="unsafe") + colors = buf + + elif atype == "rainbow_fade": + h_shift = (speed * t * 0.1) % 1.0 + # Vectorized RGB->HSV shift->RGB (no per-LED colorsys) + rgb_f = base.astype(np.float32) * (1.0 / 255.0) + r_f = rgb_f[:, 0] + g_f = rgb_f[:, 1] + b_f = rgb_f[:, 2] + cmax = np.maximum(np.maximum(r_f, g_f), b_f) + cmin = np.minimum(np.minimum(r_f, g_f), b_f) + delta = cmax - cmin + # Hue + h_arr = np.zeros(n, dtype=np.float32) + mask_r = (delta > 0) & (cmax == r_f) + mask_g = (delta > 0) & (cmax == g_f) & ~mask_r + mask_b = (delta > 0) & ~mask_r & ~mask_g + h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0 + h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0 + h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0 + h_arr *= 1.0 / 6.0 + h_arr %= 1.0 + # Saturation & Value with clamping + s_arr = np.where(cmax > 0, delta / cmax, np.float32(0)) + np.maximum(s_arr, 0.5, out=s_arr) + v_arr = cmax.copy() + np.maximum(v_arr, 0.3, out=v_arr) + # Shift hue + h_arr += h_shift + h_arr %= 1.0 + # Vectorized HSV->RGB + h6 = h_arr * 6.0 + hi = h6.astype(np.int32) % 6 + f_arr = h6 - np.floor(h6) + p = v_arr * (1.0 - s_arr) + q = v_arr * (1.0 - s_arr * f_arr) + tt = v_arr * (1.0 - s_arr * (1.0 - f_arr)) + ro = np.empty(n, dtype=np.float32) + go = np.empty(n, dtype=np.float32) + bo = np.empty(n, dtype=np.float32) + for sxt, rv, gv, bv in ( + (0, v_arr, tt, p), + (1, q, v_arr, p), + (2, p, v_arr, tt), + (3, p, q, v_arr), + (4, tt, p, v_arr), + (5, v_arr, p, q), + ): + m = hi == sxt + ro[m] = rv[m] + go[m] = gv[m] + bo[m] = bv[m] + buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8) + buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8) + buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8) + colors = buf + + elif atype == "noise_perturb": + # Perturb gradient stop positions with value noise + perturbed = [] + for si, s in enumerate(stops): + noise_val = _gradient_noise.noise( + np.array([si * 10.0 + t * speed], dtype=np.float32) + )[0] + new_pos = min( + 1.0, + max( + 0.0, + float(s.get("position", 0)) + (noise_val - 0.5) * 0.2, + ), + ) + perturbed.append(dict(s, position=new_pos)) + buf[:] = _compute_gradient_colors(perturbed, n, easing) + colors = buf + + elif atype == "hue_rotate": + # Rotate hue while preserving original S/V + h_shift = (speed * t * 0.1) % 1.0 + rgb_f = base.astype(np.float32) * (1.0 / 255.0) + r_f = rgb_f[:, 0] + g_f = rgb_f[:, 1] + b_f = rgb_f[:, 2] + cmax = np.maximum(np.maximum(r_f, g_f), b_f) + cmin = np.minimum(np.minimum(r_f, g_f), b_f) + delta = cmax - cmin + # Hue + h_arr = np.zeros(n, dtype=np.float32) + mask_r = (delta > 0) & (cmax == r_f) + mask_g = (delta > 0) & (cmax == g_f) & ~mask_r + mask_b = (delta > 0) & ~mask_r & ~mask_g + h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0 + h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0 + h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0 + h_arr *= 1.0 / 6.0 + h_arr %= 1.0 + # S and V — preserve original values (no clamping) + s_arr = np.where(cmax > 0, delta / cmax, np.float32(0)) + v_arr = cmax + # Shift hue + h_arr += h_shift + h_arr %= 1.0 + # HSV->RGB + h6 = h_arr * 6.0 + hi = h6.astype(np.int32) % 6 + f_arr = h6 - np.floor(h6) + p = v_arr * (1.0 - s_arr) + q = v_arr * (1.0 - s_arr * f_arr) + tt = v_arr * (1.0 - s_arr * (1.0 - f_arr)) + ro = np.empty(n, dtype=np.float32) + go = np.empty(n, dtype=np.float32) + bo = np.empty(n, dtype=np.float32) + for sxt, rv, gv, bv in ( + (0, v_arr, tt, p), + (1, q, v_arr, p), + (2, p, v_arr, tt), + (3, p, q, v_arr), + (4, tt, p, v_arr), + (5, v_arr, p, q), + ): + m = hi == sxt + ro[m] = rv[m] + go[m] = gv[m] + bo[m] = bv[m] + buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8) + buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8) + buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8) + colors = buf + + if colors is not None: + with self._colors_lock: + self._colors = colors + except Exception as e: + logger.error(f"GradientColorStripStream animation error: {e}") + + sleep_target = frame_time if anim and anim.get("enabled") else 0.25 + limiter.wait(sleep_target) + except Exception as e: + logger.error(f"Fatal GradientColorStripStream loop error: {e}", exc_info=True) + finally: + self._running = False diff --git a/server/src/ledgrab/core/processing/color_strip/helpers.py b/server/src/ledgrab/core/processing/color_strip/helpers.py new file mode 100644 index 0000000..71eb572 --- /dev/null +++ b/server/src/ledgrab/core/processing/color_strip/helpers.py @@ -0,0 +1,85 @@ +"""Shared helpers for color strip streams (gradient computation, etc.).""" + +import numpy as np + + +def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear") -> np.ndarray: + """Compute an (led_count, 3) uint8 array from gradient color stops. + + Each stop: {"position": float 0–1, "color": [R,G,B], "color_right": [R,G,B] | absent} + + Interpolation: + Sort stops by position. For each LED at relative position p = i/(N-1): + p ≤ first stop → first stop primary color + p ≥ last stop → last stop right color (if bidirectional) else primary + else find surrounding stops A (≤p) and B (>p): + left_color = A["color_right"] if present, else A["color"] + right_color = B["color"] + t = (p - A.pos) / (B.pos - A.pos) + color = lerp(left_color, right_color, eased(t)) + """ + if led_count <= 0: + led_count = 1 + + if not stops: + return np.zeros((led_count, 3), dtype=np.uint8) + + sorted_stops = sorted(stops, key=lambda s: float(s.get("position", 0))) + + def _color(stop: dict, side: str = "left") -> np.ndarray: + if side == "right": + cr = stop.get("color_right") + if cr and isinstance(cr, list) and len(cr) == 3: + return np.array(cr, dtype=np.float32) + c = stop.get("color", [255, 255, 255]) + return np.array( + c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32 + ) + + # Vectorized: compute all LED positions at once + positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0]) + result = np.zeros((led_count, 3), dtype=np.float32) + + # Extract stop positions and colors into arrays + n_stops = len(sorted_stops) + stop_positions = np.array([float(s.get("position", 0)) for s in sorted_stops], dtype=np.float32) + + # Pre-compute left/right colors for each stop + left_colors = np.array([_color(s, "left") for s in sorted_stops], dtype=np.float32) + right_colors = np.array([_color(s, "right") for s in sorted_stops], dtype=np.float32) + + # LEDs before first stop + mask_before = positions <= stop_positions[0] + result[mask_before] = left_colors[0] + + # LEDs after last stop + mask_after = positions >= stop_positions[-1] + result[mask_after] = right_colors[-1] + + # LEDs between stops — vectorized per segment + mask_between = ~mask_before & ~mask_after + if np.any(mask_between): + between_pos = positions[mask_between] + # np.searchsorted finds the right stop index for each LED + idx = np.searchsorted(stop_positions, between_pos, side="right") - 1 + idx = np.clip(idx, 0, n_stops - 2) + + a_pos = stop_positions[idx] + b_pos = stop_positions[idx + 1] + span = b_pos - a_pos + t = np.where(span > 0, (between_pos - a_pos) / span, 0.0) + + # Apply easing to interpolation parameter + if easing == "ease_in_out": + t = t * t * (3.0 - 2.0 * t) + elif easing == "cubic": + t = np.where(t < 0.5, 4.0 * t * t * t, 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0) + elif easing == "step": + steps = float(max(2, n_stops)) + t = np.round(t * steps) / steps + + a_colors = right_colors[idx] # A's right color + b_colors = left_colors[idx + 1] # B's left color + result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors) + + return np.clip(result, 0, 255).astype(np.uint8) diff --git a/server/src/ledgrab/core/processing/color_strip/picture.py b/server/src/ledgrab/core/processing/color_strip/picture.py new file mode 100644 index 0000000..161a4dd --- /dev/null +++ b/server/src/ledgrab/core/processing/color_strip/picture.py @@ -0,0 +1,287 @@ +"""Picture color strip stream — extracts LED colors from captured frames.""" + +import threading +import time +from typing import Optional + +import numpy as np + +from ledgrab.core.capture.calibration import ( + AdvancedPixelMapper, + CalibrationConfig, + create_pixel_mapper, +) +from ledgrab.core.capture.screen_capture import extract_border_pixels +from ledgrab.storage.bindable import bfloat +from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter +from ledgrab.utils.timer import high_resolution_timer + +from .base import ColorStripStream + +logger = get_logger(__name__) + + +class PictureColorStripStream(ColorStripStream): + """Color strip stream backed by a LiveStream (picture source). + + Runs a background thread that: + 1. Reads the latest frame from the LiveStream + 2. Extracts border pixels using the calibration's border_width + 3. Maps border pixels to LED colors via PixelMapper + 4. Applies temporal smoothing + 5. Caches the result for lock-free consumer reads + + Processing parameters can be hot-updated via update_source() without + restarting the thread (except when the underlying LiveStream changes). + """ + + def __init__(self, live_stream, source): + """ + Args: + live_stream: Acquired LiveStream or Dict[str, LiveStream] for advanced mode. + Lifecycle managed by ColorStripStreamManager. + source: PictureColorStripSource config + """ + + # Support both single LiveStream and dict of streams (advanced mode) + if isinstance(live_stream, dict): + self._live_streams = live_stream + self._live_stream = next(iter(live_stream.values())) + else: + self._live_streams = {} + self._live_stream = live_stream + + self._fps: int = 30 # internal capture rate (send FPS is on the target) + self._frame_time: float = 1.0 / 30 + self._smoothing: float = bfloat(source.smoothing, 0.3) + self._interpolation_mode: str = source.interpolation_mode + self._calibration: CalibrationConfig = source.calibration + self._pixel_mapper = create_pixel_mapper( + self._calibration, interpolation_mode=self._interpolation_mode + ) + cal_leds = self._calibration.get_total_leds() + self._led_count: int = source.led_count if source.led_count > 0 else cal_leds + + # Thread-safe color cache + self._latest_colors: Optional[np.ndarray] = None + self._colors_lock = threading.Lock() + self._previous_colors: Optional[np.ndarray] = None + + self._running = False + self._thread: Optional[threading.Thread] = None + self._last_timing: dict = {} + + @property + def live_stream(self): + """Public accessor for the underlying LiveStream (used by preview WebSocket).""" + return self._live_stream + + @property + def target_fps(self) -> int: + return self._fps + + @property + def led_count(self) -> int: + return self._led_count + + @property + def display_index(self) -> Optional[int]: + if self._live_streams: + return None # multi-source, ambiguous + return self._live_stream.display_index + + @property + def calibration(self) -> Optional[CalibrationConfig]: + return self._calibration + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._processing_loop, + name="css-picture-stream", + daemon=True, + ) + self._thread.start() + logger.info(f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})") + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning("PictureColorStripStream thread did not terminate within 5s") + self._thread = None + self._latest_colors = None + self._previous_colors = None + logger.info("PictureColorStripStream stopped") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._latest_colors + + def get_last_timing(self) -> dict: + return dict(self._last_timing) + + def set_capture_fps(self, fps: int) -> None: + """Update the internal capture rate. Thread-safe (read atomically by the loop).""" + fps = max(1, min(90, fps)) + if fps != self._fps: + self._fps = fps + self._frame_time = 1.0 / fps + logger.info(f"PictureColorStripStream capture FPS set to {fps}") + + def update_source(self, source) -> None: + """Hot-update processing parameters. Thread-safe for scalar params. + + PixelMapper is rebuilt atomically if calibration or interpolation_mode changed. + """ + from ledgrab.storage.color_strip_source import ( + AdvancedPictureColorStripSource, + PictureColorStripSource, + ) + + if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): + return + + self._smoothing = bfloat(source.smoothing, 0.3) + + if ( + source.interpolation_mode != self._interpolation_mode + or source.calibration != self._calibration + or source.led_count != self._led_count + ): + self._interpolation_mode = source.interpolation_mode + self._calibration = source.calibration + cal_leds = source.calibration.get_total_leds() + self._led_count = source.led_count if source.led_count > 0 else cal_leds + self._pixel_mapper = create_pixel_mapper( + source.calibration, interpolation_mode=source.interpolation_mode + ) + self._previous_colors = None # Reset smoothing history on calibration change + + logger.info("PictureColorStripStream params updated in-place") + + def _processing_loop(self) -> None: + """Background thread: poll source, process, cache colors.""" + cached_frame = None + + # Scratch buffer pool (pre-allocated, resized when LED count changes) + _pool_n = 0 + _frame_a = _frame_b = None # double-buffered uint8 output + _use_a = True + _u16_a = _u16_b = None # uint16 scratch for smoothing blending + + def _blend_u16(a, b, alpha_b, out): + """Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8. + + Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b). + """ + nonlocal _u16_a, _u16_b + np.copyto(_u16_a, a, casting="unsafe") + np.copyto(_u16_b, b, casting="unsafe") + _u16_a *= 256 - alpha_b + _u16_b *= alpha_b + _u16_a += _u16_b + _u16_a >>= 8 + np.copyto(out, _u16_a, casting="unsafe") + + limiter = FrameLimiter(self._fps) + + try: + with high_resolution_timer(): + while self._running: + limiter.begin() + frame_time = self._frame_time + + try: + frame = self._live_stream.get_latest_frame() + + if frame is None or frame is cached_frame: + limiter.wait(frame_time) + continue + + cached_frame = frame + + t0 = time.perf_counter() + + calibration = self._calibration + mapper = self._pixel_mapper + + if isinstance(mapper, AdvancedPixelMapper): + # Advanced mode: gather frames from all live streams + frames_dict = {} + for ps_id, ls in self._live_streams.items(): + f = ls.get_latest_frame() + if f is not None: + frames_dict[ps_id] = f + t1 = time.perf_counter() + led_colors = mapper.map_lines_to_leds(frames_dict) + else: + border_pixels = extract_border_pixels(frame, calibration.border_width) + t1 = time.perf_counter() + led_colors = mapper.map_border_to_leds(border_pixels) + t2 = time.perf_counter() + + # Ensure scratch pool is sized for this frame + target_count = self._led_count + _n = target_count if target_count > 0 else len(led_colors) + if _n > 0 and _n != _pool_n: + _pool_n = _n + _frame_a = np.empty((_n, 3), dtype=np.uint8) + _frame_b = np.empty((_n, 3), dtype=np.uint8) + _u16_a = np.empty((_n, 3), dtype=np.uint16) + _u16_b = np.empty((_n, 3), dtype=np.uint16) + self._previous_colors = None + + # Copy/pad into double-buffered frame (avoids per-frame allocations) + frame_buf = _frame_a if _use_a else _frame_b + _use_a = not _use_a + n_leds = len(led_colors) + if _pool_n > 0: + if n_leds < _pool_n: + frame_buf[:n_leds] = led_colors + frame_buf[n_leds:] = 0 + elif n_leds > _pool_n: + frame_buf[:] = led_colors[:_pool_n] + else: + frame_buf[:] = led_colors + led_colors = frame_buf + + # Temporal smoothing (pre-allocated uint16 scratch) + smoothing = self.resolve("smoothing", self._smoothing) + if ( + self._previous_colors is not None + and smoothing > 0 + and len(self._previous_colors) == len(led_colors) + and _u16_a is not None + ): + _blend_u16( + led_colors, self._previous_colors, int(smoothing * 256), led_colors + ) + t3 = time.perf_counter() + + self._previous_colors = led_colors + + with self._colors_lock: + self._latest_colors = led_colors + + self._last_timing = { + "extract_ms": (t1 - t0) * 1000, + "map_leds_ms": (t2 - t1) * 1000, + "smooth_ms": (t3 - t2) * 1000, + "total_ms": (t3 - t0) * 1000, + } + + except Exception as e: + logger.error( + f"PictureColorStripStream processing error: {e}", exc_info=True + ) + + limiter.wait(frame_time) + except Exception as e: + logger.error(f"Fatal PictureColorStripStream loop error: {e}", exc_info=True) + finally: + self._running = False diff --git a/server/src/ledgrab/core/processing/color_strip/static.py b/server/src/ledgrab/core/processing/color_strip/static.py new file mode 100644 index 0000000..59f6cf6 --- /dev/null +++ b/server/src/ledgrab/core/processing/color_strip/static.py @@ -0,0 +1,254 @@ +"""Static color strip stream — solid color with optional animation.""" + +import colorsys +import math +import threading +import time +from typing import Optional + +import numpy as np + +from ledgrab.storage.bindable import bcolor +from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter +from ledgrab.utils.timer import high_resolution_timer + +from .base import ColorStripStream + +logger = get_logger(__name__) + + +class StaticColorStripStream(ColorStripStream): + """Color strip stream that returns a constant single-color array. + + When animation is enabled a 30 fps background thread updates _colors with + the animated result. Parameters can be hot-updated via update_source(). + """ + + def __init__(self, source): + """ + Args: + source: StaticColorStripSource config + """ + self._colors_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._fps = 30 + self._frame_time = 1.0 / 30 + self._clock = None # optional SyncClockRuntime + self._update_from_source(source) + + def _update_from_source(self, source) -> None: + self._source_color = bcolor(source.color, [255, 255, 255]) + _lc = getattr(source, "led_count", 0) + self._auto_size = not _lc + led_count = _lc if _lc and _lc > 0 else 1 + self._led_count = led_count + self._animation = source.animation # dict or None; read atomically by _animate_loop + self._rebuild_colors() + + def _rebuild_colors(self) -> None: + colors = np.tile( + np.array(self._source_color, dtype=np.uint8), + (self._led_count, 1), + ) + with self._colors_lock: + self._colors = colors + + def configure(self, device_led_count: int) -> None: + """Set LED count from the target device (called by WledTargetProcessor on start). + + Only takes effect when led_count was 0 (auto-size). Silently ignored + when an explicit led_count was configured on the source. + """ + if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: + self._led_count = device_led_count + self._rebuild_colors() + logger.debug(f"StaticColorStripStream auto-sized to {device_led_count} LEDs") + + @property + def target_fps(self) -> int: + return self._fps + + @property + def is_animated(self) -> bool: + anim = self._animation + return bool(anim and anim.get("enabled")) + + @property + def led_count(self) -> int: + return self._led_count + + def set_capture_fps(self, fps: int) -> None: + """Update animation loop rate. Thread-safe (read atomically by the loop).""" + fps = max(1, min(90, fps)) + self._fps = fps + self._frame_time = 1.0 / fps + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._animate_loop, + name="css-static-animate", + daemon=True, + ) + self._thread.start() + logger.info(f"StaticColorStripStream started (leds={self._led_count})") + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning("StaticColorStripStream animate thread did not terminate within 5s") + self._thread = None + logger.info("StaticColorStripStream stopped") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._colors + + def update_source(self, source) -> None: + from ledgrab.storage.color_strip_source import StaticColorStripSource + + if isinstance(source, StaticColorStripSource): + prev_led_count = self._led_count if self._auto_size else None + self._update_from_source(source) + # If we were auto-sized, preserve the runtime LED count across updates + if prev_led_count and self._auto_size: + self._led_count = prev_led_count + self._rebuild_colors() + logger.info("StaticColorStripStream params updated in-place") + + def set_clock(self, clock) -> None: + """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" + self._clock = clock + + def _animate_loop(self) -> None: + """Background thread: compute animated colors at target fps when animation is active. + + Uses double-buffered output arrays (buf_a / buf_b) to avoid per-frame + numpy allocations while preserving the identity check used by the + processing loop (``colors is prev_colors``). + """ + # Double-buffer pool — re-allocated only when LED count changes + _pool_n = 0 + _buf_a = _buf_b = None + _use_a = True + + limiter = FrameLimiter(self._fps) + + try: + with high_resolution_timer(): + while self._running: + limiter.begin() + wall_start = time.perf_counter() + frame_time = self._frame_time + try: + anim = self._animation + if anim and anim.get("enabled"): + clock = self._clock + if clock: + if not clock.is_running: + time.sleep(0.1) + continue + speed = clock.speed + t = clock.get_time() + else: + speed = 1.0 + t = wall_start + atype = anim.get("type", "breathing") + n = self._led_count + + if n != _pool_n: + _pool_n = n + _buf_a = np.empty((n, 3), dtype=np.uint8) + _buf_b = np.empty((n, 3), dtype=np.uint8) + + buf = _buf_a if _use_a else _buf_b + _use_a = not _use_a + colors = None + + if atype == "breathing": + factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) + r, g, b = self.resolve_color("color", self._source_color) + buf[:] = ( + min(255, int(r * factor)), + min(255, int(g * factor)), + min(255, int(b * factor)), + ) + colors = buf + + elif atype == "strobe": + # Square wave: on for half the period, off for the other half. + # speed=1.0 → 2 flashes/sec (one full on/off cycle per 0.5s) + if math.sin(2 * math.pi * speed * t * 2.0) >= 0: + buf[:] = self.resolve_color("color", self._source_color) + else: + buf[:] = 0 + colors = buf + + elif atype == "sparkle": + # Random LEDs flash white while the rest stay the base color + buf[:] = self.resolve_color("color", self._source_color) + density = min(0.5, 0.1 * speed) + mask = np.random.random(n) < density + buf[mask] = (255, 255, 255) + colors = buf + + elif atype == "pulse": + # Sharp attack, slow exponential decay — heartbeat-like + # speed=1.0 → ~1 pulse per second + phase = (speed * t * 1.0) % 1.0 + if phase < 0.1: + factor = phase / 0.1 + else: + factor = math.exp(-5.0 * (phase - 0.1)) + r, g, b = self.resolve_color("color", self._source_color) + buf[:] = ( + min(255, int(r * factor)), + min(255, int(g * factor)), + min(255, int(b * factor)), + ) + colors = buf + + elif atype == "candle": + # Random brightness fluctuations simulating a candle flame + base_factor = 0.75 + flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7) + flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3) + flicker += 0.10 * (np.random.random() - 0.5) + factor = max(0.2, min(1.0, base_factor + flicker)) + r, g, b = self.resolve_color("color", self._source_color) + buf[:] = ( + min(255, int(r * factor)), + min(255, int(g * factor)), + min(255, int(b * factor)), + ) + colors = buf + + elif atype == "rainbow_fade": + # Shift hue continuously from the base color + r, g, b = self.resolve_color("color", self._source_color) + h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + # speed=1.0 → one full hue rotation every ~10s + h_shift = (speed * t * 0.1) % 1.0 + new_h = (h + h_shift) % 1.0 + nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3)) + buf[:] = (int(nr * 255), int(ng * 255), int(nb * 255)) + colors = buf + + if colors is not None: + with self._colors_lock: + self._colors = colors + except Exception as e: + logger.error(f"StaticColorStripStream animation error: {e}") + + sleep_target = frame_time if anim and anim.get("enabled") else 0.25 + limiter.wait(sleep_target) + except Exception as e: + logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True) + finally: + self._running = False diff --git a/server/src/ledgrab/core/processing/color_strip_stream.py b/server/src/ledgrab/core/processing/color_strip_stream.py index b7ff705..3aaa85d 100644 --- a/server/src/ledgrab/core/processing/color_strip_stream.py +++ b/server/src/ledgrab/core/processing/color_strip_stream.py @@ -1,1328 +1,28 @@ -"""Color strip stream — produces LED color arrays from a source. +"""Backward-compatibility shim — re-exports from the split color_strip subpackage. -A ColorStripStream is the runtime counterpart of ColorStripSource. -It continuously computes and caches a fresh np.ndarray (led_count, 3) uint8 -by processing frames from a LiveStream. - -Multiple WledTargetProcessors may read from the same ColorStripStream instance -(shared via ColorStripStreamManager reference counting), meaning the CPU-bound -processing — border extraction, pixel mapping, smoothing — runs only once -even when multiple devices share the same source configuration. +The original 1300+ line module was split into focused submodules under +``ledgrab.core.processing.color_strip``. This shim preserves the legacy +import path ``from ledgrab.core.processing.color_strip_stream import X``. """ -import colorsys -import math -import threading -import time -from abc import ABC, abstractmethod -from typing import Optional - -import numpy as np - -from ledgrab.core.capture.calibration import ( - CalibrationConfig, - AdvancedPixelMapper, - create_pixel_mapper, +from ledgrab.core.processing.color_strip import ( # noqa: F401 + ColorCycleColorStripStream, + ColorStripStream, + GradientColorStripStream, + PictureColorStripStream, + StaticColorStripStream, + _compute_gradient_colors, + _gradient_noise, + _SimpleNoise1D, ) -from ledgrab.core.capture.screen_capture import extract_border_pixels -from ledgrab.storage.bindable import bcolor, bfloat -from ledgrab.utils import get_logger -from ledgrab.utils.timer import high_resolution_timer - -class _SimpleNoise1D: - """Minimal 1-D value noise for gradient perturbation (avoids circular import).""" - - def __init__(self, seed: int = 99): - rng = np.random.RandomState(seed) - self._table = rng.random(512).astype(np.float32) - - def noise(self, x: np.ndarray) -> np.ndarray: - size = len(self._table) - xi = np.floor(x).astype(np.int64) - frac = x - np.floor(x) - t = frac * frac * (3.0 - 2.0 * frac) - a = self._table[xi % size] - b = self._table[(xi + 1) % size] - return a + t * (b - a) - - -# Module-level noise for gradient perturbation -_gradient_noise = _SimpleNoise1D(seed=99) - -logger = get_logger(__name__) - - -class ColorStripStream(ABC): - """Abstract base: a runtime source of LED color arrays. - - Produces a continuous stream of np.ndarray (led_count, 3) uint8 values. - Consumers call get_latest_colors() (non-blocking) to read the most recent - computed frame. - """ - - @property - @abstractmethod - def target_fps(self) -> int: - """Target processing rate.""" - - @property - @abstractmethod - def led_count(self) -> int: - """Number of LEDs this stream produces colors for.""" - - @property - def is_animated(self) -> bool: - """Whether this stream is actively producing new frames. - - Used by the processor to adjust polling rate: animated streams are - polled at 5 ms (SKIP_REPOLL) to stay in sync with the animation - thread, while static streams are polled at frame_time rate. - """ - return True - - @property - def display_index(self) -> Optional[int]: - """Display index of the underlying capture, or None.""" - return None - - @property - def calibration(self) -> Optional[CalibrationConfig]: - """Calibration config, or None if not applicable.""" - return None - - @abstractmethod - def start(self) -> None: - """Start producing colors.""" - - @abstractmethod - def stop(self) -> None: - """Stop producing colors and release resources.""" - - @abstractmethod - def get_latest_colors(self) -> Optional[np.ndarray]: - """Get the most recent LED color array (led_count, 3) uint8, or None.""" - - def get_last_timing(self) -> dict: - """Return per-stage timing from the last processed frame (ms).""" - return {} - - def update_source(self, source) -> None: - """Hot-update processing parameters. No-op by default.""" - - # ── BindableFloat value stream resolution ── - - _value_streams: dict = None # property_name → ValueStream - - def set_value_stream(self, prop: str, stream) -> None: - """Inject a ValueStream for a bindable property.""" - if self._value_streams is None: - self._value_streams = {} - self._value_streams[prop] = stream - - def remove_value_stream(self, prop: str) -> None: - """Remove a ValueStream for a bindable property.""" - if self._value_streams: - self._value_streams.pop(prop, None) - - def resolve(self, prop: str, static: float) -> float: - """Resolve a bindable property: ValueStream value if bound, else static.""" - if self._value_streams: - vs = self._value_streams.get(prop) - if vs is not None: - try: - return vs.get_value() - except Exception: - pass - return static - - def resolve_color(self, prop: str, static: list) -> list: - """Resolve a bindable color: ValueStream color if bound, else static [R,G,B].""" - if self._value_streams: - vs = self._value_streams.get(prop) - if vs is not None: - try: - c = vs.get_color() - return [c[0], c[1], c[2]] - except Exception: - pass - return static - - -class PictureColorStripStream(ColorStripStream): - """Color strip stream backed by a LiveStream (picture source). - - Runs a background thread that: - 1. Reads the latest frame from the LiveStream - 2. Extracts border pixels using the calibration's border_width - 3. Maps border pixels to LED colors via PixelMapper - 4. Applies temporal smoothing - 5. Caches the result for lock-free consumer reads - - Processing parameters can be hot-updated via update_source() without - restarting the thread (except when the underlying LiveStream changes). - """ - - def __init__(self, live_stream, source): - """ - Args: - live_stream: Acquired LiveStream or Dict[str, LiveStream] for advanced mode. - Lifecycle managed by ColorStripStreamManager. - source: PictureColorStripSource config - """ - - # Support both single LiveStream and dict of streams (advanced mode) - if isinstance(live_stream, dict): - self._live_streams = live_stream - self._live_stream = next(iter(live_stream.values())) - else: - self._live_streams = {} - self._live_stream = live_stream - - self._fps: int = 30 # internal capture rate (send FPS is on the target) - self._frame_time: float = 1.0 / 30 - self._smoothing: float = bfloat(source.smoothing, 0.3) - self._interpolation_mode: str = source.interpolation_mode - self._calibration: CalibrationConfig = source.calibration - self._pixel_mapper = create_pixel_mapper( - self._calibration, interpolation_mode=self._interpolation_mode - ) - cal_leds = self._calibration.get_total_leds() - self._led_count: int = source.led_count if source.led_count > 0 else cal_leds - - # Thread-safe color cache - self._latest_colors: Optional[np.ndarray] = None - self._colors_lock = threading.Lock() - self._previous_colors: Optional[np.ndarray] = None - - self._running = False - self._thread: Optional[threading.Thread] = None - self._last_timing: dict = {} - - @property - def live_stream(self): - """Public accessor for the underlying LiveStream (used by preview WebSocket).""" - return self._live_stream - - @property - def target_fps(self) -> int: - return self._fps - - @property - def led_count(self) -> int: - return self._led_count - - @property - def display_index(self) -> Optional[int]: - if self._live_streams: - return None # multi-source, ambiguous - return self._live_stream.display_index - - @property - def calibration(self) -> Optional[CalibrationConfig]: - return self._calibration - - def start(self) -> None: - if self._running: - return - self._running = True - self._thread = threading.Thread( - target=self._processing_loop, - name="css-picture-stream", - daemon=True, - ) - self._thread.start() - logger.info(f"PictureColorStripStream started (fps={self._fps}, leds={self._led_count})") - - def stop(self) -> None: - self._running = False - if self._thread: - self._thread.join(timeout=5.0) - if self._thread.is_alive(): - logger.warning("PictureColorStripStream thread did not terminate within 5s") - self._thread = None - self._latest_colors = None - self._previous_colors = None - logger.info("PictureColorStripStream stopped") - - def get_latest_colors(self) -> Optional[np.ndarray]: - with self._colors_lock: - return self._latest_colors - - def get_last_timing(self) -> dict: - return dict(self._last_timing) - - def set_capture_fps(self, fps: int) -> None: - """Update the internal capture rate. Thread-safe (read atomically by the loop).""" - fps = max(1, min(90, fps)) - if fps != self._fps: - self._fps = fps - self._frame_time = 1.0 / fps - logger.info(f"PictureColorStripStream capture FPS set to {fps}") - - def update_source(self, source) -> None: - """Hot-update processing parameters. Thread-safe for scalar params. - - PixelMapper is rebuilt atomically if calibration or interpolation_mode changed. - """ - from ledgrab.storage.color_strip_source import ( - PictureColorStripSource, - AdvancedPictureColorStripSource, - ) - - if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): - return - - self._smoothing = bfloat(source.smoothing, 0.3) - - if ( - source.interpolation_mode != self._interpolation_mode - or source.calibration != self._calibration - or source.led_count != self._led_count - ): - self._interpolation_mode = source.interpolation_mode - self._calibration = source.calibration - cal_leds = source.calibration.get_total_leds() - self._led_count = source.led_count if source.led_count > 0 else cal_leds - self._pixel_mapper = create_pixel_mapper( - source.calibration, interpolation_mode=source.interpolation_mode - ) - self._previous_colors = None # Reset smoothing history on calibration change - - logger.info("PictureColorStripStream params updated in-place") - - def _processing_loop(self) -> None: - """Background thread: poll source, process, cache colors.""" - cached_frame = None - - # Scratch buffer pool (pre-allocated, resized when LED count changes) - _pool_n = 0 - _frame_a = _frame_b = None # double-buffered uint8 output - _use_a = True - _u16_a = _u16_b = None # uint16 scratch for smoothing blending - - def _blend_u16(a, b, alpha_b, out): - """Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8. - - Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b). - """ - nonlocal _u16_a, _u16_b - np.copyto(_u16_a, a, casting="unsafe") - np.copyto(_u16_b, b, casting="unsafe") - _u16_a *= 256 - alpha_b - _u16_b *= alpha_b - _u16_a += _u16_b - _u16_a >>= 8 - np.copyto(out, _u16_a, casting="unsafe") - - try: - with high_resolution_timer(): - while self._running: - loop_start = time.perf_counter() - frame_time = self._frame_time - - try: - frame = self._live_stream.get_latest_frame() - - if frame is None or frame is cached_frame: - elapsed = time.perf_counter() - loop_start - time.sleep(max(frame_time - elapsed, 0.001)) - continue - - cached_frame = frame - - t0 = time.perf_counter() - - calibration = self._calibration - mapper = self._pixel_mapper - - if isinstance(mapper, AdvancedPixelMapper): - # Advanced mode: gather frames from all live streams - frames_dict = {} - for ps_id, ls in self._live_streams.items(): - f = ls.get_latest_frame() - if f is not None: - frames_dict[ps_id] = f - t1 = time.perf_counter() - led_colors = mapper.map_lines_to_leds(frames_dict) - else: - border_pixels = extract_border_pixels(frame, calibration.border_width) - t1 = time.perf_counter() - led_colors = mapper.map_border_to_leds(border_pixels) - t2 = time.perf_counter() - - # Ensure scratch pool is sized for this frame - target_count = self._led_count - _n = target_count if target_count > 0 else len(led_colors) - if _n > 0 and _n != _pool_n: - _pool_n = _n - _frame_a = np.empty((_n, 3), dtype=np.uint8) - _frame_b = np.empty((_n, 3), dtype=np.uint8) - _u16_a = np.empty((_n, 3), dtype=np.uint16) - _u16_b = np.empty((_n, 3), dtype=np.uint16) - self._previous_colors = None - - # Copy/pad into double-buffered frame (avoids per-frame allocations) - frame_buf = _frame_a if _use_a else _frame_b - _use_a = not _use_a - n_leds = len(led_colors) - if _pool_n > 0: - if n_leds < _pool_n: - frame_buf[:n_leds] = led_colors - frame_buf[n_leds:] = 0 - elif n_leds > _pool_n: - frame_buf[:] = led_colors[:_pool_n] - else: - frame_buf[:] = led_colors - led_colors = frame_buf - - # Temporal smoothing (pre-allocated uint16 scratch) - smoothing = self.resolve("smoothing", self._smoothing) - if ( - self._previous_colors is not None - and smoothing > 0 - and len(self._previous_colors) == len(led_colors) - and _u16_a is not None - ): - _blend_u16( - led_colors, self._previous_colors, int(smoothing * 256), led_colors - ) - t3 = time.perf_counter() - - self._previous_colors = led_colors - - with self._colors_lock: - self._latest_colors = led_colors - - self._last_timing = { - "extract_ms": (t1 - t0) * 1000, - "map_leds_ms": (t2 - t1) * 1000, - "smooth_ms": (t3 - t2) * 1000, - "total_ms": (t3 - t0) * 1000, - } - - except Exception as e: - logger.error( - f"PictureColorStripStream processing error: {e}", exc_info=True - ) - - elapsed = time.perf_counter() - loop_start - remaining = frame_time - elapsed - if remaining > 0: - time.sleep(remaining) - except Exception as e: - logger.error(f"Fatal PictureColorStripStream loop error: {e}", exc_info=True) - finally: - self._running = False - - -def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear") -> np.ndarray: - """Compute an (led_count, 3) uint8 array from gradient color stops. - - Each stop: {"position": float 0–1, "color": [R,G,B], "color_right": [R,G,B] | absent} - - Interpolation: - Sort stops by position. For each LED at relative position p = i/(N-1): - p ≤ first stop → first stop primary color - p ≥ last stop → last stop right color (if bidirectional) else primary - else find surrounding stops A (≤p) and B (>p): - left_color = A["color_right"] if present, else A["color"] - right_color = B["color"] - t = (p - A.pos) / (B.pos - A.pos) - color = lerp(left_color, right_color, eased(t)) - """ - if led_count <= 0: - led_count = 1 - - if not stops: - return np.zeros((led_count, 3), dtype=np.uint8) - - sorted_stops = sorted(stops, key=lambda s: float(s.get("position", 0))) - - def _color(stop: dict, side: str = "left") -> np.ndarray: - if side == "right": - cr = stop.get("color_right") - if cr and isinstance(cr, list) and len(cr) == 3: - return np.array(cr, dtype=np.float32) - c = stop.get("color", [255, 255, 255]) - return np.array( - c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32 - ) - - # Vectorized: compute all LED positions at once - positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0]) - result = np.zeros((led_count, 3), dtype=np.float32) - - # Extract stop positions and colors into arrays - n_stops = len(sorted_stops) - stop_positions = np.array([float(s.get("position", 0)) for s in sorted_stops], dtype=np.float32) - - # Pre-compute left/right colors for each stop - left_colors = np.array([_color(s, "left") for s in sorted_stops], dtype=np.float32) - right_colors = np.array([_color(s, "right") for s in sorted_stops], dtype=np.float32) - - # LEDs before first stop - mask_before = positions <= stop_positions[0] - result[mask_before] = left_colors[0] - - # LEDs after last stop - mask_after = positions >= stop_positions[-1] - result[mask_after] = right_colors[-1] - - # LEDs between stops — vectorized per segment - mask_between = ~mask_before & ~mask_after - if np.any(mask_between): - between_pos = positions[mask_between] - # np.searchsorted finds the right stop index for each LED - idx = np.searchsorted(stop_positions, between_pos, side="right") - 1 - idx = np.clip(idx, 0, n_stops - 2) - - a_pos = stop_positions[idx] - b_pos = stop_positions[idx + 1] - span = b_pos - a_pos - t = np.where(span > 0, (between_pos - a_pos) / span, 0.0) - - # Apply easing to interpolation parameter - if easing == "ease_in_out": - t = t * t * (3.0 - 2.0 * t) - elif easing == "cubic": - t = np.where(t < 0.5, 4.0 * t * t * t, 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0) - elif easing == "step": - steps = float(max(2, n_stops)) - t = np.round(t * steps) / steps - - a_colors = right_colors[idx] # A's right color - b_colors = left_colors[idx + 1] # B's left color - result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors) - - return np.clip(result, 0, 255).astype(np.uint8) - - -class StaticColorStripStream(ColorStripStream): - """Color strip stream that returns a constant single-color array. - - When animation is enabled a 30 fps background thread updates _colors with - the animated result. Parameters can be hot-updated via update_source(). - """ - - def __init__(self, source): - """ - Args: - source: StaticColorStripSource config - """ - self._colors_lock = threading.Lock() - self._running = False - self._thread: Optional[threading.Thread] = None - self._fps = 30 - self._frame_time = 1.0 / 30 - self._clock = None # optional SyncClockRuntime - self._update_from_source(source) - - def _update_from_source(self, source) -> None: - self._source_color = bcolor(source.color, [255, 255, 255]) - _lc = getattr(source, "led_count", 0) - self._auto_size = not _lc - led_count = _lc if _lc and _lc > 0 else 1 - self._led_count = led_count - self._animation = source.animation # dict or None; read atomically by _animate_loop - self._rebuild_colors() - - def _rebuild_colors(self) -> None: - colors = np.tile( - np.array(self._source_color, dtype=np.uint8), - (self._led_count, 1), - ) - with self._colors_lock: - self._colors = colors - - def configure(self, device_led_count: int) -> None: - """Set LED count from the target device (called by WledTargetProcessor on start). - - Only takes effect when led_count was 0 (auto-size). Silently ignored - when an explicit led_count was configured on the source. - """ - if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: - self._led_count = device_led_count - self._rebuild_colors() - logger.debug(f"StaticColorStripStream auto-sized to {device_led_count} LEDs") - - @property - def target_fps(self) -> int: - return self._fps - - @property - def is_animated(self) -> bool: - anim = self._animation - return bool(anim and anim.get("enabled")) - - @property - def led_count(self) -> int: - return self._led_count - - def set_capture_fps(self, fps: int) -> None: - """Update animation loop rate. Thread-safe (read atomically by the loop).""" - fps = max(1, min(90, fps)) - self._fps = fps - self._frame_time = 1.0 / fps - - def start(self) -> None: - if self._running: - return - self._running = True - self._thread = threading.Thread( - target=self._animate_loop, - name="css-static-animate", - daemon=True, - ) - self._thread.start() - logger.info(f"StaticColorStripStream started (leds={self._led_count})") - - def stop(self) -> None: - self._running = False - if self._thread: - self._thread.join(timeout=5.0) - if self._thread.is_alive(): - logger.warning("StaticColorStripStream animate thread did not terminate within 5s") - self._thread = None - logger.info("StaticColorStripStream stopped") - - def get_latest_colors(self) -> Optional[np.ndarray]: - with self._colors_lock: - return self._colors - - def update_source(self, source) -> None: - from ledgrab.storage.color_strip_source import StaticColorStripSource - - if isinstance(source, StaticColorStripSource): - prev_led_count = self._led_count if self._auto_size else None - self._update_from_source(source) - # If we were auto-sized, preserve the runtime LED count across updates - if prev_led_count and self._auto_size: - self._led_count = prev_led_count - self._rebuild_colors() - logger.info("StaticColorStripStream params updated in-place") - - def set_clock(self, clock) -> None: - """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" - self._clock = clock - - def _animate_loop(self) -> None: - """Background thread: compute animated colors at target fps when animation is active. - - Uses double-buffered output arrays (buf_a / buf_b) to avoid per-frame - numpy allocations while preserving the identity check used by the - processing loop (``colors is prev_colors``). - """ - # Double-buffer pool — re-allocated only when LED count changes - _pool_n = 0 - _buf_a = _buf_b = None - _use_a = True - - try: - with high_resolution_timer(): - while self._running: - wall_start = time.perf_counter() - frame_time = self._frame_time - try: - anim = self._animation - if anim and anim.get("enabled"): - clock = self._clock - if clock: - if not clock.is_running: - time.sleep(0.1) - continue - speed = clock.speed - t = clock.get_time() - else: - speed = 1.0 - t = wall_start - atype = anim.get("type", "breathing") - n = self._led_count - - if n != _pool_n: - _pool_n = n - _buf_a = np.empty((n, 3), dtype=np.uint8) - _buf_b = np.empty((n, 3), dtype=np.uint8) - - buf = _buf_a if _use_a else _buf_b - _use_a = not _use_a - colors = None - - if atype == "breathing": - factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) - r, g, b = self.resolve_color("color", self._source_color) - buf[:] = ( - min(255, int(r * factor)), - min(255, int(g * factor)), - min(255, int(b * factor)), - ) - colors = buf - - elif atype == "strobe": - # Square wave: on for half the period, off for the other half. - # speed=1.0 → 2 flashes/sec (one full on/off cycle per 0.5s) - if math.sin(2 * math.pi * speed * t * 2.0) >= 0: - buf[:] = self.resolve_color("color", self._source_color) - else: - buf[:] = 0 - colors = buf - - elif atype == "sparkle": - # Random LEDs flash white while the rest stay the base color - buf[:] = self.resolve_color("color", self._source_color) - density = min(0.5, 0.1 * speed) - mask = np.random.random(n) < density - buf[mask] = (255, 255, 255) - colors = buf - - elif atype == "pulse": - # Sharp attack, slow exponential decay — heartbeat-like - # speed=1.0 → ~1 pulse per second - phase = (speed * t * 1.0) % 1.0 - if phase < 0.1: - factor = phase / 0.1 - else: - factor = math.exp(-5.0 * (phase - 0.1)) - r, g, b = self.resolve_color("color", self._source_color) - buf[:] = ( - min(255, int(r * factor)), - min(255, int(g * factor)), - min(255, int(b * factor)), - ) - colors = buf - - elif atype == "candle": - # Random brightness fluctuations simulating a candle flame - base_factor = 0.75 - flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7) - flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3) - flicker += 0.10 * (np.random.random() - 0.5) - factor = max(0.2, min(1.0, base_factor + flicker)) - r, g, b = self.resolve_color("color", self._source_color) - buf[:] = ( - min(255, int(r * factor)), - min(255, int(g * factor)), - min(255, int(b * factor)), - ) - colors = buf - - elif atype == "rainbow_fade": - # Shift hue continuously from the base color - r, g, b = self.resolve_color("color", self._source_color) - h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) - # speed=1.0 → one full hue rotation every ~10s - h_shift = (speed * t * 0.1) % 1.0 - new_h = (h + h_shift) % 1.0 - nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3)) - buf[:] = (int(nr * 255), int(ng * 255), int(nb * 255)) - colors = buf - - if colors is not None: - with self._colors_lock: - self._colors = colors - except Exception as e: - logger.error(f"StaticColorStripStream animation error: {e}") - - elapsed = time.perf_counter() - wall_start - sleep_target = frame_time if anim and anim.get("enabled") else 0.25 - time.sleep(max(sleep_target - elapsed, 0.001)) - except Exception as e: - logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True) - finally: - self._running = False - - -class ColorCycleColorStripStream(ColorStripStream): - """Color strip stream that smoothly cycles through a user-defined color list. - - All LEDs receive the same solid color at any moment, continuously interpolating - between the configured colors in a loop. - - LED count auto-sizes from the connected device when led_count == 0 in - the source config; configure(device_led_count) is called by - WledTargetProcessor on start. - """ - - def __init__(self, source): - self._colors_lock = threading.Lock() - self._running = False - self._thread: Optional[threading.Thread] = None - self._fps = 30 - self._frame_time = 1.0 / 30 - self._clock = None # optional SyncClockRuntime - self._update_from_source(source) - - def _update_from_source(self, source) -> None: - raw = source.colors if isinstance(source.colors, list) else [] - default = [ - [255, 0, 0], - [255, 255, 0], - [0, 255, 0], - [0, 255, 255], - [0, 0, 255], - [255, 0, 255], - ] - self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default - _lc = getattr(source, "led_count", 0) - self._auto_size = not _lc - self._led_count = _lc if _lc > 0 else 1 - self._rebuild_colors() - - def _rebuild_colors(self) -> None: - pixel = np.array(self._color_list[0], dtype=np.uint8) - colors = np.tile(pixel, (self._led_count, 1)) - with self._colors_lock: - self._colors = colors - - def configure(self, device_led_count: int) -> None: - """Size to device LED count when led_count was 0 (auto-size).""" - if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: - self._led_count = device_led_count - self._rebuild_colors() - logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs") - - @property - def target_fps(self) -> int: - return self._fps - - @property - def led_count(self) -> int: - return self._led_count - - def set_capture_fps(self, fps: int) -> None: - """Update animation loop rate. Thread-safe (read atomically by the loop).""" - fps = max(1, min(90, fps)) - self._fps = fps - self._frame_time = 1.0 / fps - - def start(self) -> None: - if self._running: - return - self._running = True - self._thread = threading.Thread( - target=self._animate_loop, - name="css-color-cycle", - daemon=True, - ) - self._thread.start() - logger.info( - f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})" - ) - - def stop(self) -> None: - self._running = False - if self._thread: - self._thread.join(timeout=5.0) - if self._thread.is_alive(): - logger.warning( - "ColorCycleColorStripStream animate thread did not terminate within 5s" - ) - self._thread = None - logger.info("ColorCycleColorStripStream stopped") - - def get_latest_colors(self) -> Optional[np.ndarray]: - with self._colors_lock: - return self._colors - - def update_source(self, source) -> None: - from ledgrab.storage.color_strip_source import ColorCycleColorStripSource - - if isinstance(source, ColorCycleColorStripSource): - prev_led_count = self._led_count if self._auto_size else None - self._update_from_source(source) - if prev_led_count and self._auto_size: - self._led_count = prev_led_count - self._rebuild_colors() - logger.info("ColorCycleColorStripStream params updated in-place") - - def set_clock(self, clock) -> None: - """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" - self._clock = clock - - def _animate_loop(self) -> None: - """Background thread: interpolate between colors at target fps. - - Uses double-buffered output arrays to avoid per-frame allocations. - """ - _pool_n = 0 - _buf_a = _buf_b = None - _use_a = True - - try: - with high_resolution_timer(): - while self._running: - wall_start = time.perf_counter() - frame_time = self._frame_time - try: - color_list = self._color_list - clock = self._clock - if clock: - if not clock.is_running: - time.sleep(0.1) - continue - speed = clock.speed - t = clock.get_time() - else: - speed = 1.0 - t = wall_start - n = self._led_count - num = len(color_list) - if num >= 2: - if n != _pool_n: - _pool_n = n - _buf_a = np.empty((n, 3), dtype=np.uint8) - _buf_b = np.empty((n, 3), dtype=np.uint8) - - buf = _buf_a if _use_a else _buf_b - _use_a = not _use_a - - # 0.05 factor → one full cycle every 20s at speed=1.0 - cycle_pos = (speed * t * 0.05) % 1.0 - seg = cycle_pos * num - idx = int(seg) % num - t_i = seg - int(seg) - c1 = color_list[idx] - c2 = color_list[(idx + 1) % num] - buf[:] = ( - min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)), - min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)), - min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)), - ) - with self._colors_lock: - self._colors = buf - except Exception as e: - logger.error(f"ColorCycleColorStripStream animation error: {e}") - elapsed = time.perf_counter() - wall_start - time.sleep(max(frame_time - elapsed, 0.001)) - except Exception as e: - logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True) - finally: - self._running = False - - -class GradientColorStripStream(ColorStripStream): - """Color strip stream that distributes a gradient across all LEDs. - - Produces a pre-computed (led_count, 3) uint8 array from user-defined - color stops. When animation is enabled a background thread applies - dynamic effects (breathing, gradient_shift, wave). - - LED count auto-sizes from the connected device when led_count == 0 in - the source config; configure(device_led_count) is called by - WledTargetProcessor on start. - """ - - def __init__(self, source): - self._colors_lock = threading.Lock() - self._running = False - self._thread: Optional[threading.Thread] = None - self._fps = 30 - self._frame_time = 1.0 / 30 - self._clock = None # optional SyncClockRuntime - self._gradient_store = None # injected by stream manager - self._update_from_source(source) - - def set_gradient_store(self, gradient_store) -> None: - """Inject gradient store for resolving gradient_id to stops.""" - self._gradient_store = gradient_store - # Re-resolve stops if gradient_id is set - gradient_id = getattr(self, "_gradient_id", None) - if gradient_id and self._gradient_store: - stops = self._gradient_store.resolve_stops(gradient_id) - if stops: - self._stops = stops - self._rebuild_colors() - - def _update_from_source(self, source) -> None: - self._gradient_id = getattr(source, "gradient_id", None) - self._stops = list(source.stops) if source.stops else [] - # Override inline stops with gradient entity if set - if self._gradient_id and self._gradient_store: - resolved = self._gradient_store.resolve_stops(self._gradient_id) - if resolved: - self._stops = resolved - _lc = getattr(source, "led_count", 0) - self._auto_size = not _lc - led_count = _lc if _lc and _lc > 0 else 1 - self._led_count = led_count - self._animation = source.animation # dict or None; read atomically by _animate_loop - self._easing = getattr(source, "easing", "linear") or "linear" - self._rebuild_colors() - - def _rebuild_colors(self) -> None: - colors = _compute_gradient_colors(self._stops, self._led_count, self._easing) - with self._colors_lock: - self._colors = colors - - def configure(self, device_led_count: int) -> None: - """Size to device LED count when led_count was 0 (auto-size). - - When multiple targets share this stream, uses the maximum LED count - so the gradient covers enough resolution for all consumers. Targets - with fewer LEDs get a properly resampled gradient via _fit_to_device. - """ - if self._auto_size and device_led_count > 0: - new_count = max(self._led_count, device_led_count) - if new_count != self._led_count: - self._led_count = new_count - self._rebuild_colors() - logger.debug(f"GradientColorStripStream auto-sized to {new_count} LEDs") - - @property - def target_fps(self) -> int: - return self._fps - - @property - def is_animated(self) -> bool: - anim = self._animation - return bool(anim and anim.get("enabled")) - - @property - def led_count(self) -> int: - return self._led_count - - def set_capture_fps(self, fps: int) -> None: - """Update animation loop rate. Thread-safe (read atomically by the loop).""" - fps = max(1, min(90, fps)) - self._fps = fps - self._frame_time = 1.0 / fps - - def start(self) -> None: - if self._running: - return - self._running = True - self._thread = threading.Thread( - target=self._animate_loop, - name="css-gradient-animate", - daemon=True, - ) - self._thread.start() - logger.info( - f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})" - ) - - def stop(self) -> None: - self._running = False - if self._thread: - self._thread.join(timeout=5.0) - if self._thread.is_alive(): - logger.warning( - "GradientColorStripStream animate thread did not terminate within 5s" - ) - self._thread = None - logger.info("GradientColorStripStream stopped") - - def get_latest_colors(self) -> Optional[np.ndarray]: - with self._colors_lock: - return self._colors - - def update_source(self, source) -> None: - from ledgrab.storage.color_strip_source import GradientColorStripSource - - if isinstance(source, GradientColorStripSource): - prev_led_count = self._led_count if self._auto_size else None - self._update_from_source(source) - # Preserve runtime LED count across hot-updates when auto-sized - if prev_led_count and self._auto_size: - self._led_count = prev_led_count - self._rebuild_colors() - logger.info("GradientColorStripStream params updated in-place") - - def set_clock(self, clock) -> None: - """Set or clear the sync clock runtime. Thread-safe (read atomically by loop).""" - self._clock = clock - - def _animate_loop(self) -> None: - """Background thread: apply animation effects at target fps when animation is active. - - Uses double-buffered output arrays plus a uint16 scratch buffer for - integer-math brightness scaling, avoiding per-frame numpy allocations. - """ - _cached_base: Optional[np.ndarray] = None - _cached_n: int = 0 - _cached_stops: Optional[list] = None - _cached_easing: str = "" - # Double-buffer pool + uint16 scratch for brightness math - _pool_n = 0 - _buf_a = _buf_b = _scratch_u16 = None - _use_a = True - _wave_i = None # cached np.arange for wave animation - _wave_factors = None # float32 scratch for wave sin result - _wave_u16 = None # uint16 scratch for wave int factors - - try: - with high_resolution_timer(): - while self._running: - wall_start = time.perf_counter() - frame_time = self._frame_time - try: - anim = self._animation - if anim and anim.get("enabled"): - clock = self._clock - if clock: - if not clock.is_running: - time.sleep(0.1) - continue - speed = clock.speed - t = clock.get_time() - else: - speed = 1.0 - t = wall_start - atype = anim.get("type", "breathing") - n = self._led_count - stops = self._stops - colors = None - - # Recompute base gradient only when stops, led_count, or easing change - easing = self._easing - if ( - _cached_base is None - or _cached_n != n - or _cached_stops is not stops - or _cached_easing != easing - ): - _cached_base = _compute_gradient_colors(stops, n, easing) - _cached_n = n - _cached_stops = stops - _cached_easing = easing - base = _cached_base - - # Re-allocate pool only when LED count changes - if n != _pool_n: - _pool_n = n - _buf_a = np.empty((n, 3), dtype=np.uint8) - _buf_b = np.empty((n, 3), dtype=np.uint8) - _scratch_u16 = np.empty((n, 3), dtype=np.uint16) - _wave_i = np.arange(n, dtype=np.float32) - _wave_factors = np.empty(n, dtype=np.float32) - _wave_u16 = np.empty(n, dtype=np.uint16) - - buf = _buf_a if _use_a else _buf_b - _use_a = not _use_a - - if atype == "breathing": - int_f = max( - 0, - min( - 256, - int( - 0.5 - * (1 + math.sin(2 * math.pi * speed * t * 0.5)) - * 256 - ), - ), - ) - np.copyto(_scratch_u16, base) - _scratch_u16 *= int_f - _scratch_u16 >>= 8 - np.copyto(buf, _scratch_u16, casting="unsafe") - colors = buf - - elif atype == "gradient_shift": - shift = int(speed * t * 10) % max(n, 1) - if shift > 0: - buf[: n - shift] = base[shift:] - buf[n - shift :] = base[:shift] - else: - np.copyto(buf, base) - colors = buf - - elif atype == "wave": - if n > 1: - np.sin( - 2 * math.pi * _wave_i / n - 2 * math.pi * speed * t * 0.25, - out=_wave_factors, - ) - _wave_factors *= 0.5 - _wave_factors += 0.5 - np.multiply(_wave_factors, 256, out=_wave_factors) - np.clip(_wave_factors, 0, 256, out=_wave_factors) - np.copyto(_wave_u16, _wave_factors, casting="unsafe") - np.copyto(_scratch_u16, base) - _scratch_u16 *= _wave_u16[:, None] - _scratch_u16 >>= 8 - np.copyto(buf, _scratch_u16, casting="unsafe") - colors = buf - else: - np.copyto(buf, base) - colors = buf - - elif atype == "strobe": - if math.sin(2 * math.pi * speed * t * 2.0) >= 0: - np.copyto(buf, base) - else: - buf[:] = 0 - colors = buf - - elif atype == "sparkle": - np.copyto(buf, base) - density = min(0.5, 0.1 * speed) - mask = np.random.random(n) < density - buf[mask] = (255, 255, 255) - colors = buf - - elif atype == "pulse": - phase = (speed * t * 1.0) % 1.0 - if phase < 0.1: - factor = phase / 0.1 - else: - factor = math.exp(-5.0 * (phase - 0.1)) - int_f = max(0, min(256, int(factor * 256))) - np.copyto(_scratch_u16, base) - _scratch_u16 *= int_f - _scratch_u16 >>= 8 - np.copyto(buf, _scratch_u16, casting="unsafe") - colors = buf - - elif atype == "candle": - base_factor = 0.75 - flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7) - flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3) - flicker += 0.10 * (np.random.random() - 0.5) - factor = max(0.2, min(1.0, base_factor + flicker)) - int_f = int(factor * 256) - np.copyto(_scratch_u16, base) - _scratch_u16 *= int_f - _scratch_u16 >>= 8 - np.copyto(buf, _scratch_u16, casting="unsafe") - colors = buf - - elif atype == "rainbow_fade": - h_shift = (speed * t * 0.1) % 1.0 - # Vectorized RGB->HSV shift->RGB (no per-LED colorsys) - rgb_f = base.astype(np.float32) * (1.0 / 255.0) - r_f = rgb_f[:, 0] - g_f = rgb_f[:, 1] - b_f = rgb_f[:, 2] - cmax = np.maximum(np.maximum(r_f, g_f), b_f) - cmin = np.minimum(np.minimum(r_f, g_f), b_f) - delta = cmax - cmin - # Hue - h_arr = np.zeros(n, dtype=np.float32) - mask_r = (delta > 0) & (cmax == r_f) - mask_g = (delta > 0) & (cmax == g_f) & ~mask_r - mask_b = (delta > 0) & ~mask_r & ~mask_g - h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0 - h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0 - h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0 - h_arr *= 1.0 / 6.0 - h_arr %= 1.0 - # Saturation & Value with clamping - s_arr = np.where(cmax > 0, delta / cmax, np.float32(0)) - np.maximum(s_arr, 0.5, out=s_arr) - v_arr = cmax.copy() - np.maximum(v_arr, 0.3, out=v_arr) - # Shift hue - h_arr += h_shift - h_arr %= 1.0 - # Vectorized HSV->RGB - h6 = h_arr * 6.0 - hi = h6.astype(np.int32) % 6 - f_arr = h6 - np.floor(h6) - p = v_arr * (1.0 - s_arr) - q = v_arr * (1.0 - s_arr * f_arr) - tt = v_arr * (1.0 - s_arr * (1.0 - f_arr)) - ro = np.empty(n, dtype=np.float32) - go = np.empty(n, dtype=np.float32) - bo = np.empty(n, dtype=np.float32) - for sxt, rv, gv, bv in ( - (0, v_arr, tt, p), - (1, q, v_arr, p), - (2, p, v_arr, tt), - (3, p, q, v_arr), - (4, tt, p, v_arr), - (5, v_arr, p, q), - ): - m = hi == sxt - ro[m] = rv[m] - go[m] = gv[m] - bo[m] = bv[m] - buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8) - buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8) - buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8) - colors = buf - - elif atype == "noise_perturb": - # Perturb gradient stop positions with value noise - perturbed = [] - for si, s in enumerate(stops): - noise_val = _gradient_noise.noise( - np.array([si * 10.0 + t * speed], dtype=np.float32) - )[0] - new_pos = min( - 1.0, - max( - 0.0, - float(s.get("position", 0)) + (noise_val - 0.5) * 0.2, - ), - ) - perturbed.append(dict(s, position=new_pos)) - buf[:] = _compute_gradient_colors(perturbed, n, easing) - colors = buf - - elif atype == "hue_rotate": - # Rotate hue while preserving original S/V - h_shift = (speed * t * 0.1) % 1.0 - rgb_f = base.astype(np.float32) * (1.0 / 255.0) - r_f = rgb_f[:, 0] - g_f = rgb_f[:, 1] - b_f = rgb_f[:, 2] - cmax = np.maximum(np.maximum(r_f, g_f), b_f) - cmin = np.minimum(np.minimum(r_f, g_f), b_f) - delta = cmax - cmin - # Hue - h_arr = np.zeros(n, dtype=np.float32) - mask_r = (delta > 0) & (cmax == r_f) - mask_g = (delta > 0) & (cmax == g_f) & ~mask_r - mask_b = (delta > 0) & ~mask_r & ~mask_g - h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0 - h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0 - h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0 - h_arr *= 1.0 / 6.0 - h_arr %= 1.0 - # S and V — preserve original values (no clamping) - s_arr = np.where(cmax > 0, delta / cmax, np.float32(0)) - v_arr = cmax - # Shift hue - h_arr += h_shift - h_arr %= 1.0 - # HSV->RGB - h6 = h_arr * 6.0 - hi = h6.astype(np.int32) % 6 - f_arr = h6 - np.floor(h6) - p = v_arr * (1.0 - s_arr) - q = v_arr * (1.0 - s_arr * f_arr) - tt = v_arr * (1.0 - s_arr * (1.0 - f_arr)) - ro = np.empty(n, dtype=np.float32) - go = np.empty(n, dtype=np.float32) - bo = np.empty(n, dtype=np.float32) - for sxt, rv, gv, bv in ( - (0, v_arr, tt, p), - (1, q, v_arr, p), - (2, p, v_arr, tt), - (3, p, q, v_arr), - (4, tt, p, v_arr), - (5, v_arr, p, q), - ): - m = hi == sxt - ro[m] = rv[m] - go[m] = gv[m] - bo[m] = bv[m] - buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8) - buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8) - buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8) - colors = buf - - if colors is not None: - with self._colors_lock: - self._colors = colors - except Exception as e: - logger.error(f"GradientColorStripStream animation error: {e}") - - elapsed = time.perf_counter() - wall_start - sleep_target = frame_time if anim and anim.get("enabled") else 0.25 - time.sleep(max(sleep_target - elapsed, 0.001)) - except Exception as e: - logger.error(f"Fatal GradientColorStripStream loop error: {e}", exc_info=True) - finally: - self._running = False +__all__ = [ + "ColorStripStream", + "PictureColorStripStream", + "StaticColorStripStream", + "ColorCycleColorStripStream", + "GradientColorStripStream", + "_compute_gradient_colors", + "_SimpleNoise1D", + "_gradient_noise", +] diff --git a/server/src/ledgrab/core/processing/composite_stream.py b/server/src/ledgrab/core/processing/composite_stream.py index 80318d6..9b56397 100644 --- a/server/src/ledgrab/core/processing/composite_stream.py +++ b/server/src/ledgrab/core/processing/composite_stream.py @@ -9,6 +9,7 @@ import numpy as np from ledgrab.core.processing.color_strip_stream import ColorStripStream from ledgrab.storage.bindable import bfloat from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter logger = get_logger(__name__) @@ -520,9 +521,11 @@ class CompositeColorStripStream(ColorStripStream): # Per-layer CSPT filter cache: layer_index -> (template_id, [PostprocessingFilter, ...]) _layer_cspt_cache: Dict[int, tuple] = {} + limiter = FrameLimiter(self._fps) + try: while self._running: - loop_start = time.perf_counter() + limiter.begin() frame_time = self._frame_time try: @@ -684,8 +687,7 @@ class CompositeColorStripStream(ColorStripStream): except Exception as e: logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True) - elapsed = time.perf_counter() - loop_start - time.sleep(max(frame_time - elapsed, 0.001)) + limiter.wait(frame_time) except Exception as e: logger.error(f"Fatal CompositeColorStripStream loop error: {e}", exc_info=True) finally: diff --git a/server/src/ledgrab/core/processing/daylight_stream.py b/server/src/ledgrab/core/processing/daylight_stream.py index 4c8135e..9fe1469 100644 --- a/server/src/ledgrab/core/processing/daylight_stream.py +++ b/server/src/ledgrab/core/processing/daylight_stream.py @@ -19,6 +19,7 @@ import numpy as np from ledgrab.core.processing.color_strip_stream import ColorStripStream from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter from ledgrab.utils.timer import high_resolution_timer logger = get_logger(__name__) @@ -274,9 +275,12 @@ class DaylightColorStripStream(ColorStripStream): _buf_a = _buf_b = None _use_a = True + limiter = FrameLimiter(self._fps) + try: with high_resolution_timer(): while self._running: + limiter.begin() wall_start = time.perf_counter() frame_time = self._frame_time try: @@ -322,8 +326,7 @@ class DaylightColorStripStream(ColorStripStream): except Exception as e: logger.error(f"DaylightColorStripStream animation error: {e}") - elapsed = time.perf_counter() - wall_start - time.sleep(max(frame_time - elapsed, 0.001)) + limiter.wait(frame_time) except Exception as e: logger.error(f"Fatal DaylightColorStripStream loop error: {e}", exc_info=True) finally: diff --git a/server/src/ledgrab/core/processing/effect_stream.py b/server/src/ledgrab/core/processing/effect_stream.py index 220a1ad..7bb38d4 100644 --- a/server/src/ledgrab/core/processing/effect_stream.py +++ b/server/src/ledgrab/core/processing/effect_stream.py @@ -17,6 +17,7 @@ import numpy as np from ledgrab.core.processing.color_strip_stream import ColorStripStream from ledgrab.utils import get_logger +from ledgrab.utils.frame_limiter import FrameLimiter from ledgrab.utils.timer import high_resolution_timer logger = get_logger(__name__) @@ -382,9 +383,12 @@ class EffectColorStripStream(ColorStripStream): "wave_interference": self._render_wave_interference, } + limiter = FrameLimiter(self._fps) + try: with high_resolution_timer(): while self._running: + limiter.begin() wall_start = time.perf_counter() frame_time = self._frame_time try: @@ -428,8 +432,7 @@ class EffectColorStripStream(ColorStripStream): except Exception as e: logger.error(f"EffectColorStripStream render error: {e}") - elapsed = time.perf_counter() - wall_start - time.sleep(max(frame_time - elapsed, 0.001)) + limiter.wait(frame_time) except Exception as e: logger.error(f"Fatal EffectColorStripStream loop error: {e}", exc_info=True) finally: diff --git a/server/src/ledgrab/core/processing/value_stream.py b/server/src/ledgrab/core/processing/value_stream.py index b052124..decb9ec 100644 --- a/server/src/ledgrab/core/processing/value_stream.py +++ b/server/src/ledgrab/core/processing/value_stream.py @@ -830,7 +830,7 @@ class HAEntityValueStream(ValueStream): def start(self) -> None: if self._ha_manager and self._ha_source_id: try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() loop.create_task(self._async_start()) except Exception as e: logger.warning("HAEntityValueStream failed to schedule start: %s", e) @@ -850,7 +850,7 @@ class HAEntityValueStream(ValueStream): def stop(self) -> None: if self._ha_manager and self._ha_source_id: try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() loop.create_task(self._async_stop()) except Exception as e: logger.warning("HAEntityValueStream failed to schedule stop: %s", e) @@ -924,7 +924,7 @@ class HAEntityValueStream(ValueStream): # If HA source changed, swap runtime if source.ha_source_id != old_ha_source and self._ha_manager: try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() loop.create_task(self._async_swap_runtime(old_ha_source)) except Exception as e: logger.warning("HAEntityValueStream failed to schedule runtime swap: %s", e) diff --git a/server/src/ledgrab/core/update/update_service.py b/server/src/ledgrab/core/update/update_service.py index 8d89b93..0f1bc6d 100644 --- a/server/src/ledgrab/core/update/update_service.py +++ b/server/src/ledgrab/core/update/update_service.py @@ -1,7 +1,9 @@ """Background service that periodically checks for new releases.""" import asyncio +import hashlib import os +import re import shutil import subprocess import sys @@ -30,6 +32,9 @@ DEFAULT_SETTINGS: dict[str, Any] = { "include_prerelease": False, } +# Match a 64-hex-char sha256 in release body or sibling .sha256 asset +_SHA256_RE = re.compile(r"\b([a-fA-F0-9]{64})\b") + _STARTUP_DELAY_S = 30 _MANUAL_CHECK_DEBOUNCE_S = 60 @@ -283,6 +288,9 @@ class UpdateService: assert self._downloaded_file is not None file_path = self._downloaded_file + # Verify checksum before executing anything + await self._verify_checksum(file_path) + if self._install_type == InstallType.INSTALLER: await self._apply_installer(file_path) elif self._install_type == InstallType.PORTABLE: @@ -299,6 +307,81 @@ class UpdateService: finally: self._applying = False + # ── Checksum verification ────────────────────────────────── + + async def _expected_sha256(self) -> str | None: + """Find the expected sha256 for the current asset. + + Looks for: + 1. A sibling asset named ``.sha256`` (downloads + parses) + 2. A 64-hex-char string in the release body + Returns None if no checksum is published. + """ + release = self._available_release + if not release: + return None + asset = self._find_asset(release) + if not asset: + return None + + # 1) sibling .sha256 asset + sibling = next( + (a for a in release.assets if a.name == f"{asset.name}.sha256"), + None, + ) + if sibling: + try: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.get(sibling.download_url) + resp.raise_for_status() + text = resp.text.strip() + match = _SHA256_RE.search(text) + if match: + return match.group(1).lower() + except Exception as exc: + logger.warning("Failed to fetch sibling sha256 asset: %s", exc) + + # 2) embedded in release body + if release.body: + match = _SHA256_RE.search(release.body) + if match: + return match.group(1).lower() + return None + + async def _verify_checksum(self, file_path: Path) -> None: + """Verify file matches the published sha256. + + Raises RuntimeError on mismatch. Aborts when no checksum is + available unless ``settings.allow_unchecked`` is True. + """ + expected = await self._expected_sha256() + if not expected: + from ledgrab.config import get_config + + if get_config().updates.allow_unchecked: + logger.warning( + "Update checksum unavailable — installing anyway " + "(updates.allow_unchecked=true)" + ) + return + logger.critical( + "No checksum available for update %s — install aborted. " + "Set updates.allow_unchecked=true to override (NOT recommended).", + file_path.name, + ) + raise RuntimeError("Update checksum unavailable; install aborted") + + actual = await asyncio.to_thread(_sha256_file, file_path) + if actual.lower() != expected.lower(): + logger.critical( + "Checksum mismatch for %s: expected %s, got %s — install aborted", + file_path.name, + expected, + actual, + ) + raise RuntimeError("Update checksum mismatch; install aborted") + logger.info("Update checksum verified: %s = %s", file_path.name, actual) + async def _apply_installer(self, exe_path: Path) -> None: """Launch the NSIS installer silently and shut down.""" install_dir = str(Path.cwd()) @@ -339,11 +422,22 @@ class UpdateService: if staging.exists(): shutil.rmtree(staging) staging.mkdir(parents=True) + staging_root = staging.resolve() with zipfile.ZipFile(zip_path, "r") as zf: - for member in zf.namelist(): - target = (staging / member).resolve() - if not target.is_relative_to(staging.resolve()): - raise ValueError(f"Zip entry escapes target directory: {member}") + # Validate every member before extracting anything + for info in zf.infolist(): + name = info.filename + if ( + ".." in Path(name).parts + or name.startswith(("/", "\\")) + or (len(name) >= 2 and name[1] == ":") + ): + raise ValueError(f"Zip entry has unsafe path: {name}") + target = (staging / name).resolve() + try: + target.relative_to(staging_root) + except ValueError: + raise ValueError(f"Zip entry escapes target directory: {name}") zf.extractall(staging) await asyncio.to_thread(_extract) @@ -391,8 +485,29 @@ class UpdateService: if staging.exists(): shutil.rmtree(staging) staging.mkdir(parents=True) + staging_root = staging.resolve() with tarfile.open(tar_path, "r:gz") as tf: - tf.extractall(staging, filter="data") + # Manual member validation: reject traversal, absolute paths, + # symlinks, devices, and other non-regular/non-dir types. + safe_members: list[tarfile.TarInfo] = [] + for member in tf.getmembers(): + name = member.name + if ( + ".." in Path(name).parts + or name.startswith(("/", "\\")) + or (len(name) >= 2 and name[1] == ":") + ): + raise ValueError(f"Tar entry has unsafe path: {name}") + target = (staging / name).resolve() + try: + target.relative_to(staging_root) + except ValueError: + raise ValueError(f"Tar entry escapes target directory: {name}") + if not (member.isreg() or member.isdir()): + raise ValueError(f"Tar entry has disallowed type {member.type!r}: {name}") + safe_members.append(member) + # Use the data filter as defense in depth. + tf.extractall(staging, members=safe_members, filter="data") await asyncio.to_thread(_extract) @@ -515,6 +630,15 @@ class UpdateService: # ── Helpers ────────────────────────────────────────────────── +def _sha256_file(path: Path) -> str: + """Compute the sha256 of *path*.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + def _find_single_child_dir(parent: Path) -> Path: """Return the single subdirectory inside *parent* (e.g. LedGrab/).""" children = [c for c in parent.iterdir() if c.is_dir()] diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index e0164de..0895f94 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -161,7 +161,18 @@ async def lifespan(app: FastAPI): # Log authentication mode if not config.auth.api_keys: - logger.info("Authentication disabled (no API keys configured)") + logger.info( + "Authentication: loopback-only (no API keys configured — " + "LAN requests will be rejected with 401)" + ) + # Loud warning when bound to a non-loopback address + if config.server.host not in ("127.0.0.1", "::1", "localhost"): + logger.critical( + "SECURITY: server bound to %s but no API key set; " + "LAN requests will be rejected. Add auth.api_keys entries " + "in config.yaml to enable LAN access.", + config.server.host, + ) else: logger.info(f"Authentication enabled ({len(config.auth.api_keys)} API key(s) configured)") client_labels = ", ".join(config.auth.api_keys.keys()) @@ -377,9 +388,9 @@ app = FastAPI( app.add_middleware( CORSMiddleware, allow_origins=config.server.cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_credentials=False, # API uses Bearer tokens, not cookies + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-Api-Key"], ) app.add_middleware(GZipMiddleware, minimum_size=500) diff --git a/server/src/ledgrab/static/css/all.css b/server/src/ledgrab/static/css/all.css index 3517e6f..3f69e4f 100644 --- a/server/src/ledgrab/static/css/all.css +++ b/server/src/ledgrab/static/css/all.css @@ -17,3 +17,4 @@ @import './appearance.css'; @import './game-integration.css'; @import './mobile.css'; +@import './tv.css'; diff --git a/server/src/ledgrab/static/css/base.css b/server/src/ledgrab/static/css/base.css index 7b5a545..2a89088 100644 --- a/server/src/ledgrab/static/css/base.css +++ b/server/src/ledgrab/static/css/base.css @@ -8,6 +8,11 @@ --primary-color: #4CAF50; --primary-hover: #5cb860; --primary-contrast: #ffffff; + /* Theme-aware token for primary color used as TEXT on light backgrounds. + Defaults to --primary-color (dark theme); overridden in light theme below + to a darker green that meets WCAG AA contrast on white. */ + --primary-color-on-light-bg: #2e7d32; + --primary-text: var(--primary-color); --danger-color: #f44336; --warning-color: #ff9800; --info-color: #2196F3; @@ -51,8 +56,13 @@ --z-command-palette: 3000; --z-toast: 3500; --z-overlay-spinner: 9999; + --z-modal-top: 9999; /* drag clones, top-most modals */ --z-lightbox: 10000; - --z-connection: 10000; + --z-tutorial: 10010; /* tutorial backdrop / fixed overlay */ + --z-tutorial-tooltip: 10012; + --z-connection: 10020; /* connection-lost overlay — MUST be the + highest layer so a dropped connection is + never occluded by a tutorial step. */ } /* ── SVG icon base ── */ @@ -104,6 +114,11 @@ --shadow-color: rgba(0, 0, 0, 0.12); --hover-bg: rgba(0, 0, 0, 0.05); --input-bg: #f0f0f0; + /* WCAG AA: #4CAF50 fails (~3.2:1) on #ffffff. Darken to #2e7d32 (~5.4:1). */ + --primary-color: #2e7d32; + --primary-hover: #36913a; + --primary-color-on-light-bg: #2e7d32; + --primary-text: #2e7d32; color-scheme: light; } diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 87428ec..7c62dcd 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -227,8 +227,24 @@ section { pointer-events: none; z-index: 2; animation: rotateBorder 4s linear infinite; + /* Promote to its own GPU layer so the rotating conic-gradient does not + force repaints of the whole card. */ + will-change: transform; } +/* Honor user preference for reduced motion — base.css globally clamps + animation durations, but the rotating border is decorative and we'd rather + not run it at all for these users. */ +@media (prefers-reduced-motion: reduce) { + .card-running::before { + animation: none; + } +} + +/* TODO(perf): pause animation when the card scrolls off-screen via an + IntersectionObserver toggling `animation-play-state: paused`. Not done in + CSS-only pass — would require a JS hook in card lifecycle. */ + /* Fallback for browsers without mask-composite support (older Firefox) */ @supports not (mask-composite: exclude) { .card-running::before { @@ -318,7 +334,7 @@ section { /* Clone floating during drag */ .card-drag-clone { position: fixed; - z-index: 9999; + z-index: var(--z-modal-top); pointer-events: none; opacity: 0.92; transform: scale(1.03) rotate(0.8deg); diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index c1c6ee1..fb89b48 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -134,7 +134,10 @@ height: 14px; background: white; border-radius: 50%; - transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + /* Was cubic-bezier(0.34, 1.56, 0.64, 1) (spring overshoot). Unified to + --ease-out to match buttons/modals. The --ease-spring var is kept in + base.css for easy revert. */ + transition: transform 0.3s var(--ease-out); } .settings-toggle input:checked + .settings-toggle-slider { diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 9434151..bfe36e8 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -2119,7 +2119,7 @@ .composite-layer-drag-clone { position: fixed; - z-index: 9999; + z-index: var(--z-modal-top); pointer-events: none; opacity: 0.92; transform: scale(1.02); diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index b2efba4..223c8b7 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -428,7 +428,7 @@ .pp-filter-drag-clone { position: fixed; - z-index: 9999; + z-index: var(--z-modal-top); pointer-events: none; opacity: 0.92; transform: scale(1.02); diff --git a/server/src/ledgrab/static/css/tutorials.css b/server/src/ledgrab/static/css/tutorials.css index 677c47a..1ea4826 100644 --- a/server/src/ledgrab/static/css/tutorials.css +++ b/server/src/ledgrab/static/css/tutorials.css @@ -176,7 +176,7 @@ /* Fixed (viewport-level) tutorial overlay for device cards */ .tutorial-overlay-fixed { position: fixed; - z-index: 10000; + z-index: var(--z-tutorial); } .tutorial-overlay-fixed .tutorial-backdrop { @@ -189,7 +189,7 @@ .tutorial-overlay-fixed .tutorial-tooltip { position: absolute; - z-index: 10002; + z-index: var(--z-tutorial-tooltip); animation: none; opacity: 1; } diff --git a/server/src/ledgrab/static/css/tv.css b/server/src/ledgrab/static/css/tv.css new file mode 100644 index 0000000..3de638d --- /dev/null +++ b/server/src/ledgrab/static/css/tv.css @@ -0,0 +1,86 @@ +/* ────────────────────────────────────────────────────────────────────────── + * tv.css — Android TV / 10-foot UI overrides + * + * Heuristic: large viewport (>=1920px) AND coarse / no-hover input. This + * matches Android TV (D-pad / remote, no mouse hover) but excludes desktop + * monitors at the same resolution. + * + * Loaded last (after mobile.css) via all.css so its declarations win. + * ─────────────────────────────────────────────────────────────────────── */ + +@media (min-width: 1920px) and (pointer: coarse), + (min-width: 1920px) and (hover: none) { + + /* Larger base font — readable from a couch. */ + html, body { + font-size: 18px; + } + + /* Touch / D-pad target sizing: WCAG 2.5.5 minimum is 44x44; 48 gives + more breathing room for fat-finger / sloppy aim from a remote. */ + button, + .btn, + .icon-button, + .icon-btn, + [role="button"], + a.btn { + min-width: 48px; + min-height: 48px; + padding: 12px 18px; + } + + .icon-button .icon, + .icon-btn .icon { + width: 1.4em; + height: 1.4em; + } + + /* Bottom nav bar — easier to reach with a D-pad than a top bar. + Assumes the existing nav uses .tab-bar (verified to exist in mobile.css + layouts). Override its position. */ + .tab-bar, + nav.tab-bar, + header .tab-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: auto; + width: 100%; + padding: 16px 24px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + z-index: var(--z-sticky); + box-shadow: 0 -4px 16px var(--shadow-color); + } + + .tab-bar .tab, + .tab-bar a, + .tab-bar button { + min-height: 48px; + padding: 12px 20px; + font-size: 1.05rem; + } + + /* Reserve space at the bottom of the viewport so content is not + hidden underneath the fixed nav. */ + body { + padding-bottom: 96px; + } + + /* D-pad focus ring — needs to be obvious from across the room. */ + *:focus-visible { + outline: 3px solid var(--primary-color); + outline-offset: 3px; + } + + /* Card hover/elevation effects driven by :hover are useless without a + pointer; mirror them onto :focus-within so the highlighted card is + always the focused one. */ + .card:focus-within, + .template-card:focus-within, + .dashboard-target:focus-within { + box-shadow: 0 8px 24px var(--shadow-color); + transform: translateY(-2px); + } +} diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 9baa28b..8e4adda 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -22,7 +22,7 @@ import { toggleHint, lockBody, unlockBody, closeLightbox, showToast, showUndoToast, showConfirm, closeConfirmModal, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, - setFieldError, clearFieldError, setupBlurValidation, + setFieldError, clearFieldError, setupBlurValidation, initLightbox, } from './core/ui.ts'; // Layer 3: displays, tutorials @@ -688,6 +688,7 @@ window.addEventListener('beforeunload', () => { // ─── Initialization ─── document.addEventListener('DOMContentLoaded', async () => { + try { // Load API key from localStorage before anything that triggers API calls setApiKey(localStorage.getItem('ledgrab_api_key')); @@ -713,6 +714,12 @@ document.addEventListener('DOMContentLoaded', async () => { const accent = localStorage.getItem('accentColor') || '#4CAF50'; updateBgAnimAccent(accent); + // App-level listeners share a single AbortController. The app currently + // never tears these down (it lives for the lifetime of the page), but the + // pattern documents that future SPA-style teardown should call abort(). + const appListeners = new AbortController(); + const { signal: appSignal } = appListeners; + // Set CSS variable for sticky header height (header now includes tab bar) const headerEl = document.querySelector('header'); if (headerEl) { @@ -722,7 +729,7 @@ document.addEventListener('DOMContentLoaded', async () => { document.documentElement.style.setProperty('--sticky-top', hh + 'px'); }; updateHeaderHeight(); - window.addEventListener('resize', updateHeaderHeight); + window.addEventListener('resize', updateHeaderHeight, { signal: appSignal }); } // Scroll-to-top button visibility @@ -730,12 +737,15 @@ document.addEventListener('DOMContentLoaded', async () => { if (scrollBtn) { window.addEventListener('scroll', () => { scrollBtn.classList.toggle('visible', window.scrollY > 300); - }, { passive: true }); + }, { passive: true, signal: appSignal }); } // Initialize command palette initCommandPalette(); + // Enhance lightbox FPS with an IconSelect. Idempotent. */ +export function initLightbox(): void { + if (_lightboxFpsIconSelect) return; + const sel = document.getElementById('lightbox-fps-select') as HTMLSelectElement | null; + if (!sel) return; + _lightboxFpsIconSelect = new IconSelect({ + target: sel, + items: [ + { value: '1', icon: '1', label: '1 fps' }, + { value: '2', icon: '2', label: '2 fps' }, + { value: '3', icon: '3', label: '3 fps' }, + { value: '5', icon: '5', label: '5 fps' }, + ], + columns: 2, + onChange: (val: string) => { + const fn = (window as any).onLightboxFpsChange; + if (typeof fn === 'function') fn(val); + }, + }); +} /** Returns true on touch devices where auto-focus would pop up the virtual keyboard */ export function isTouchDevice() { diff --git a/server/src/ledgrab/static/js/core/ws-auth.ts b/server/src/ledgrab/static/js/core/ws-auth.ts new file mode 100644 index 0000000..6fae9a8 --- /dev/null +++ b/server/src/ledgrab/static/js/core/ws-auth.ts @@ -0,0 +1,100 @@ +/** + * First-message WebSocket authentication helper. + * + * Opens a WebSocket, sends `{type:'auth', token}` as the first message, + * then waits for `{type:'auth_ok'}` before resolving. Rejects on + * `{type:'auth_error'}`, connection error, or timeout. + * + * The returned WebSocket is ready for normal use — all auth-related + * event handlers are removed before resolution. + */ + +import { apiKey } from './state.ts'; + +export class WsAuthError extends Error { + constructor(public readonly reason: string) { + super(`WebSocket auth failed: ${reason}`); + this.name = 'WsAuthError'; + } +} + +/** + * Open a WebSocket with first-message authentication. + * + * @param url WebSocket URL (without ?token= query string). + * @param token API key to authenticate with (defaults to the global apiKey). + * Pass `null` explicitly when running on loopback with no keys. + * @param timeoutMs Time in ms to wait for auth_ok (default 5000). + * @returns A connected, authenticated WebSocket. + */ +export function openAuthedWs( + url: string, + token: string | null = apiKey ?? null, + timeoutMs = 5000, +): Promise { + return new Promise((resolve, reject) => { + let ws: WebSocket; + try { + ws = new WebSocket(url); + } catch (e: any) { + reject(new WsAuthError(e.message ?? 'WebSocket constructor failed')); + return; + } + + let settled = false; + let timer: ReturnType | null = null; + + const cleanup = () => { + if (timer) { clearTimeout(timer); timer = null; } + ws.onmessage = null; + ws.onerror = null; + // Keep onclose for the caller + }; + + const fail = (reason: string) => { + if (settled) return; + settled = true; + cleanup(); + try { if (ws.readyState <= WebSocket.OPEN) ws.close(); } catch { /* ignore */ } + reject(new WsAuthError(reason)); + }; + + const succeed = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(ws); + }; + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'auth', token })); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'auth_ok') { + succeed(); + } else if (msg.type === 'auth_error') { + fail(msg.reason ?? 'auth rejected'); + } else { + fail(`unexpected first message type: ${msg.type}`); + } + } catch { + fail('failed to parse auth response'); + } + }; + + ws.onerror = () => { + fail('connection error'); + }; + + ws.onclose = () => { + fail('connection closed before auth completed'); + }; + + timer = setTimeout(() => { + fail('auth timeout'); + }, timeoutMs); + }); +} diff --git a/server/src/ledgrab/static/js/features/audio-sources.ts b/server/src/ledgrab/static/js/features/audio-sources.ts index 832d0a4..41dbc2f 100644 --- a/server/src/ledgrab/static/js/features/audio-sources.ts +++ b/server/src/ledgrab/static/js/features/audio-sources.ts @@ -12,11 +12,13 @@ import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { logError } from '../core/log.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.ts'; import { EntitySelect } from '../core/entity-palette.ts'; +import { openAuthedWs } from '../core/ws-auth.ts'; import { TagInput } from '../core/tag-input.ts'; import { loadPictureSources } from './streams.ts'; @@ -415,7 +417,7 @@ let _testBeatFlash = 0; const testAudioModal = new Modal('test-audio-source-modal', { backdrop: true, lock: true }); -export function testAudioSource(sourceId: any) { +export async function testAudioSource(sourceId: any) { const statusEl = document.getElementById('audio-test-status'); if (statusEl) { statusEl.textContent = t('audio_source.test.connecting'); @@ -439,19 +441,17 @@ export function testAudioSource(sourceId: any) { // Connect WebSocket const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey ?? '')}`; + const wsUrl = `${protocol}//${window.location.host}${API_BASE}/audio-sources/${sourceId}/test/ws`; try { - _testAudioWs = new WebSocket(wsUrl); + _testAudioWs = await openAuthedWs(wsUrl); - _testAudioWs.onopen = () => { - if (statusEl) statusEl.style.display = 'none'; - }; + if (statusEl) statusEl.style.display = 'none'; _testAudioWs.onmessage = (event) => { try { _testAudioLatest = JSON.parse(event.data); - } catch {} + } catch (err) { logError('audio-sources.testWs.message', err); } }; _testAudioWs.onclose = () => { diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index eee7e52..b2c5beb 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -12,9 +12,13 @@ import { showToast } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { closeTutorial, startCalibrationTutorial } from './tutorials.ts'; import { startCSSOverlay, stopCSSOverlay } from './color-strips/index.ts'; -import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.ts'; +import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW, ICON_DEVICE } from '../core/icons.ts'; +import { EntitySelect } from '../core/entity-palette.ts'; import type { Calibration } from '../types.ts'; +let _calTestDeviceEntitySelect: EntitySelect | null = null; +let _calTestDeviceList: any[] = []; + /* ── CalibrationModal subclass ────────────────────────────────── */ class CalibrationModal extends Modal { @@ -255,6 +259,7 @@ export async function showCSSCalibration(cssId: any) { opt.textContent = d.name; testDeviceSelect.appendChild(opt); }); + _calTestDeviceList = devices; const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement; testGroup.style.display = devices.length ? '' : 'none'; @@ -272,6 +277,27 @@ export async function showCSSCalibration(cssId: any) { testDeviceSelect.onchange = () => localStorage.setItem('css_calibration_test_device', testDeviceSelect.value); } + // Wrap the device diff --git a/server/src/ledgrab/static/js/features/color-strips/notification.ts b/server/src/ledgrab/static/js/features/color-strips/notification.ts index 598ac3a..1ee0fa3 100644 --- a/server/src/ledgrab/static/js/features/color-strips/notification.ts +++ b/server/src/ledgrab/static/js/features/color-strips/notification.ts @@ -211,16 +211,21 @@ function _overridesRenderList() { // Wire browse buttons list.querySelectorAll('.notif-override-browse').forEach(btn => { btn.addEventListener('click', async () => { - const idx = parseInt(btn.dataset.idx!); - const nameInput = list.querySelector(`.notif-override-name[data-idx="${idx}"]`); - if (!nameInput) return; - const picked = await NotificationAppPalette.pick({ - current: nameInput.value, - placeholder: t('color_strip.notification.search_apps') || 'Search notification apps…', - }); - if (picked !== undefined) { - nameInput.value = picked; - _overridesSyncFromDom(); + try { + const idx = parseInt(btn.dataset.idx!); + const nameInput = list.querySelector(`.notif-override-name[data-idx="${idx}"]`); + if (!nameInput) return; + const picked = await NotificationAppPalette.pick({ + current: nameInput.value, + placeholder: t('color_strip.notification.search_apps') || 'Search notification apps…', + }); + if (picked !== undefined) { + nameInput.value = picked; + _overridesSyncFromDom(); + } + } catch (err) { + // Surface to user but don't crash the rest of the modal. + console.error('[notification override browse]', err); } }); }); diff --git a/server/src/ledgrab/static/js/features/color-strips/test.ts b/server/src/ledgrab/static/js/features/color-strips/test.ts index 94153c3..f23b79a 100644 --- a/server/src/ledgrab/static/js/features/color-strips/test.ts +++ b/server/src/ledgrab/static/js/features/color-strips/test.ts @@ -4,6 +4,7 @@ */ import { fetchWithAuth, escapeHtml } from '../../core/api.ts'; +import { logError } from '../../core/log.ts'; import { colorStripSourcesCache } from '../../core/state.ts'; import { t } from '../../core/i18n.ts'; import { showToast, openLightbox, closeLightbox } from '../../core/ui.ts'; @@ -16,6 +17,7 @@ import { EntitySelect } from '../../core/entity-palette.ts'; import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts'; import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './index.ts'; import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts'; +import { openAuthedWs } from '../../core/ws-auth.ts'; /* ── Preview config builder ───────────────────────────────────── */ @@ -182,37 +184,40 @@ function _testKeyColorsSource(sourceId: string) { // Build WS URL const loc = window.location; const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; - const apiKey = (window as any).apiKey || localStorage.getItem('ledgrab_api_key') || ''; - const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?token=${encodeURIComponent(apiKey)}&fps=5&preview_width=960`; + const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?fps=5&preview_width=960`; - const ws = new WebSocket(wsUrl); - _kcTestWs = ws; + openAuthedWs(wsUrl).then((ws) => { + _kcTestWs = ws; - ws.onmessage = (ev) => { - try { - const data = JSON.parse(ev.data); - if (data.type === 'frame') { - _renderKCTestFrame(data); - } - } catch {} - }; + ws.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.type === 'frame') { + _renderKCTestFrame(data); + } + } catch (err) { logError('color-strips.test.kcWs.message', err); } + }; - ws.onerror = () => { + ws.onerror = () => { + showToast('Key Colors test connection failed', 'error'); + closeLightbox(); + }; + + ws.onclose = () => { + _kcTestWs = null; + }; + + // Stop WS when lightbox closes + const origClose = (window as any).closeLightbox; + lightbox.onclick = (e) => { + if ((e.target as HTMLElement).closest('.lightbox-content')) return; + if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } + closeLightbox(); + }; + }).catch(() => { showToast('Key Colors test connection failed', 'error'); closeLightbox(); - }; - - ws.onclose = () => { - _kcTestWs = null; - }; - - // Stop WS when lightbox closes - const origClose = (window as any).closeLightbox; - lightbox.onclick = (e) => { - if ((e.target as HTMLElement).closest('.lightbox-content')) return; - if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; } - closeLightbox(); - }; + }); } function _renderKCTestFrame(data: any) { @@ -374,24 +379,23 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) { if (!fps) fps = _getCssTestFps(); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const apiKey = localStorage.getItem('ledgrab_api_key') || ''; const isTransient = sourceId === '__preview__' && _cssTestTransientConfig; let wsUrl; if (isTransient) { - wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`; + wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?led_count=${ledCount}&fps=${fps}`; } else if (_cssTestCSPTMode && _cssTestCSPTId) { - wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`; + wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`; } else { - wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`; + wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?led_count=${ledCount}&fps=${fps}`; } - _cssTestWs = new WebSocket(wsUrl); - _cssTestWs.binaryType = 'arraybuffer'; + openAuthedWs(wsUrl).then((ws) => { + if (gen !== _cssTestGeneration) { ws.close(); return; } + _cssTestWs = ws; + ws.binaryType = 'arraybuffer'; - if (isTransient) { - _cssTestWs.onopen = () => { - if (gen !== _cssTestGeneration) return; - _cssTestWs!.send(JSON.stringify(_cssTestTransientConfig)); + if (isTransient) { + ws.send(JSON.stringify(_cssTestTransientConfig)); // Auto-fire notification after stream starts so user sees the effect immediately if (_cssTestTransientConfig.source_type === 'notification') { setTimeout(() => { @@ -400,10 +404,9 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) { } }, 300); } - }; - } + } - _cssTestWs.onmessage = (event) => { + ws.onmessage = (event) => { // Ignore messages from a stale connection if (gen !== _cssTestGeneration) return; @@ -536,13 +539,13 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) { } }; - _cssTestWs.onerror = () => { + ws.onerror = () => { if (gen !== _cssTestGeneration) return; (document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error'); (document.getElementById('css-test-status') as HTMLElement).style.display = ''; }; - _cssTestWs.onclose = (ev) => { + ws.onclose = (ev) => { if (gen !== _cssTestGeneration) return; _cssTestWs = null; // Show server-provided close reason (e.g. "No LEDs configured") @@ -555,6 +558,11 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) { // Start render loop (only once) if (!_cssTestRaf) _cssTestRenderLoop(); + }).catch(() => { + if (gen !== _cssTestGeneration) return; + (document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error'); + (document.getElementById('css-test-status') as HTMLElement).style.display = ''; + }); } const _BELL_SVG = ''; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index c52c7d1..e190183 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -375,8 +375,7 @@ export async function showSettings(deviceId: any) { const origin = getBaseOrigin(); const wsProto = origin.startsWith('https') ? 'wss:' : 'ws:'; const hostPart = origin.replace(/^https?:\/\//, ''); - const apiKey = localStorage.getItem('ledgrab_api_key') || ''; - const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`; + const wsUrl = `${wsProto}//${hostPart}/api/v1/devices/${device.id}/ws`; (document.getElementById('settings-ws-url') as HTMLInputElement).value = wsUrl; (wsUrlGroup as HTMLElement).style.display = ''; } else { diff --git a/server/src/ledgrab/static/js/features/graph-editor.ts b/server/src/ledgrab/static/js/features/graph-editor.ts index da55a75..e796079 100644 --- a/server/src/ledgrab/static/js/features/graph-editor.ts +++ b/server/src/ledgrab/static/js/features/graph-editor.ts @@ -20,6 +20,20 @@ import { t } from '../core/i18n.ts'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.ts'; import { showTypePicker } from '../core/icon-select.ts'; import * as P from '../core/icon-paths.ts'; +import { readJson, isObject, isString, isNumber } from '../core/storage.ts'; +import { logError } from '../core/log.ts'; + +// Local type guard for AnchoredRect — `JSON.parse` returns unknown and the +// helper enforces the shape before consumers dereference fields. +function _isAnchoredRect(v: unknown): v is AnchoredRect { + if (!isObject(v)) return false; + return isString(v.anchor) && isNumber(v.offsetX) && isNumber(v.offsetY) + && isNumber(v.width) && isNumber(v.height); +} + +function _isToolbarPos(v: unknown): v is { dock: string } { + return isObject(v) && isString(v.dock); +} /* ── Local type helpers (plain objects from graph-layout) ── */ @@ -130,7 +144,7 @@ let _selectedEdge: SelectedEdge | null = null; // Minimap position/size persisted in localStorage (with anchor corner) const _MM_KEY = 'graph_minimap'; function _loadMinimapRect(): AnchoredRect | null { - try { return JSON.parse(localStorage.getItem(_MM_KEY)!); } catch { return null; } + return readJson(_MM_KEY, _isAnchoredRect); } function _saveMinimapRect(r: AnchoredRect): void { localStorage.setItem(_MM_KEY, JSON.stringify(r)); @@ -235,7 +249,7 @@ function _applyToolbarDock(el: HTMLElement, container: HTMLElement, dock: string } function _loadToolbarPos(): { dock: string } | null { - try { return JSON.parse(localStorage.getItem(_TB_KEY)!); } catch { return null; } + return readJson(_TB_KEY, _isToolbarPos); } function _saveToolbarPos(r: { dock: string }): void { localStorage.setItem(_TB_KEY, JSON.stringify(r)); @@ -244,7 +258,7 @@ function _saveToolbarPos(r: { dock: string }): void { // Legend position persisted in localStorage const _LG_KEY = 'graph_legend'; function _loadLegendPos(): AnchoredRect | null { - try { return JSON.parse(localStorage.getItem(_LG_KEY)!); } catch { return null; } + return readJson(_LG_KEY, _isAnchoredRect); } function _saveLegendPos(r: AnchoredRect): void { localStorage.setItem(_LG_KEY, JSON.stringify(r)); @@ -338,7 +352,8 @@ export async function loadGraphEditor(): Promise { export function toggleGraphLegend(): void { _legendVisible = !_legendVisible; - try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } catch {} + try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } + catch (err) { logError('graph-editor.legend.persist', err); } const legend = document.querySelector('.graph-legend'); if (!legend) return; legend.classList.toggle('visible', _legendVisible); @@ -2330,10 +2345,8 @@ async function _redo(): Promise { let _helpVisible = false; function _loadHelpPos(): AnchoredRect | null { - try { - const saved = JSON.parse(localStorage.getItem('graph_help_pos')!); - return saved || { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; - } catch { return { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; } + const saved = readJson('graph_help_pos', _isAnchoredRect); + return saved ?? { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; } function _saveHelpPos(pos: AnchoredRect): void { localStorage.setItem('graph_help_pos', JSON.stringify(pos)); @@ -2408,13 +2421,17 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle btn.className = 'graph-edge-menu-item danger'; btn.textContent = t('graph.disconnect') || 'Disconnect'; btn.addEventListener('click', async () => { - _dismissEdgeContextMenu(); - const ok = await detachConnection(toId, toNode.kind, field); - if (ok) { - showToast(t('graph.connection_removed') || 'Connection removed', 'success'); - await loadGraphEditor(); - } else { - showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error'); + try { + _dismissEdgeContextMenu(); + const ok = await detachConnection(toId, toNode.kind, field); + if (ok) { + showToast(t('graph.connection_removed') || 'Connection removed', 'success'); + await loadGraphEditor(); + } else { + showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error'); + } + } catch (err) { + showToast(`${t('graph.disconnect_failed') || 'Failed to disconnect'}: ${err instanceof Error ? err.message : String(err)}`, 'error'); } }); menu.appendChild(btn); diff --git a/server/src/ledgrab/static/js/features/ha-light-targets.ts b/server/src/ledgrab/static/js/features/ha-light-targets.ts index b9c9a88..a0d815e 100644 --- a/server/src/ledgrab/static/js/features/ha-light-targets.ts +++ b/server/src/ledgrab/static/js/features/ha-light-targets.ts @@ -4,6 +4,7 @@ import { _cachedHASources, _cachedValueSources, haSourcesCache, colorStripSourcesCache, outputTargetsCache, valueSourcesCache } from '../core/state.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { logError } from '../core/log.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; @@ -12,6 +13,7 @@ import * as P from '../core/icon-paths.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { openAuthedWs } from '../core/ws-auth.ts'; import { bindableSourceId, bindableValue } from '../types.ts'; import { BindableScalarWidget } from '../core/bindable-scalar.ts'; @@ -656,28 +658,30 @@ export function connectHALightWS(targetId: string): void { if (_haLightWS[targetId]) return; const loc = window.location; const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; - const apiKey = (window as any).apiKey || localStorage.getItem('ledgrab_api_key') || ''; - const url = `${wsProto}//${loc.host}/api/v1/output-targets/${targetId}/ha-light/ws?token=${encodeURIComponent(apiKey)}`; + const url = `${wsProto}//${loc.host}/api/v1/output-targets/${targetId}/ha-light/ws`; - const ws = new WebSocket(url); - _haLightWS[targetId] = ws; + openAuthedWs(url).then((ws) => { + _haLightWS[targetId] = ws; - ws.onmessage = (ev) => { - try { - const data = JSON.parse(ev.data); - if (data.type === 'colors_update') { - _updateSwatchColors(targetId, data.colors); - } - } catch {} - }; + ws.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.type === 'colors_update') { + _updateSwatchColors(targetId, data.colors); + } + } catch (err) { logError('ha-light-targets.ws.message', err); } + }; - ws.onclose = () => { + ws.onclose = () => { + delete _haLightWS[targetId]; + }; + + ws.onerror = () => { + delete _haLightWS[targetId]; + }; + }).catch(() => { delete _haLightWS[targetId]; - }; - - ws.onerror = () => { - delete _haLightWS[targetId]; - }; + }); } export function disconnectHALightWS(targetId: string): void { diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index a73c4ef..de5b9bf 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -210,7 +210,10 @@ async function _seedFromServer(): Promise { try { const data = await fetchMetricsHistory(); if (!data) return; - const samples = data.system || []; + // The /system/metrics-history payload is loosely typed at the cache + // boundary — narrow `samples` here where we know the schema. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const samples: any[] = (data.system as any[] | undefined) || []; _history.cpu = samples.map((s: any) => s.cpu).filter((v: any) => v != null); _history.ram = samples.map((s: any) => s.ram_pct).filter((v: any) => v != null); _history.gpu = samples.map((s: any) => s.gpu_util).filter((v: any) => v != null); diff --git a/server/src/ledgrab/static/js/features/settings.ts b/server/src/ledgrab/static/js/features/settings.ts index 40accba..e86226b 100644 --- a/server/src/ledgrab/static/js/features/settings.ts +++ b/server/src/ledgrab/static/js/features/settings.ts @@ -9,6 +9,7 @@ import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.ts'; import { IconSelect } from '../core/icon-select.ts'; +import { openAuthedWs } from '../core/ws-auth.ts'; // ─── External URL (used by other modules for user-visible URLs) ── @@ -153,26 +154,27 @@ export function connectLogViewer(): void { } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = `${proto}//${location.host}/api/v1/system/logs/ws?token=${encodeURIComponent(apiKey ?? '')}`; + const url = `${proto}//${location.host}/api/v1/system/logs/ws`; - _logWs = new WebSocket(url); - - _logWs.onopen = () => { + openAuthedWs(url).then((ws) => { + _logWs = ws; if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; } - }; - _logWs.onmessage = (evt) => { - _appendLine(evt.data); - }; + ws.onmessage = (evt) => { + _appendLine(evt.data); + }; - _logWs.onerror = () => { + ws.onerror = () => { + showToast(t('settings.logs.error'), 'error'); + }; + + ws.onclose = () => { + _logWs = null; + if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } + }; + }).catch(() => { showToast(t('settings.logs.error'), 'error'); - }; - - _logWs.onclose = () => { - _logWs = null; - if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } - }; + }); } export function disconnectLogViewer(): void { @@ -253,6 +255,21 @@ export function closeLogOverlay(): void { const settingsModal = new Modal('settings-modal'); let _logLevelIconSelect: IconSelect | null = null; +let _autoBackupIntervalIconSelect: IconSelect | null = null; + +/** Build interval items (hour-tiles) for auto-backup and update check pickers. + * Labels match the existing native-