fix: comprehensive security, stability, and code quality audit
Build Android APK / build-android (push) Failing after 1m45s
Lint & Test / test (push) Successful in 4m54s

Security:
- Force API key auth for LAN (non-loopback) requests; remove shipped dev key
- Block path-traversal in backup restore; require auth on backup endpoints
- SSRF protection: DNS resolve + private/loopback/link-local IP rejection
- AES-256-GCM encryption for HA tokens and MQTT passwords with auto-migration
- WebSocket auth migrated from query-string to first-message protocol
- Asset upload: extension allowlist, server-side mime, Content-Disposition
- Update installer: SHA256 verification, tar/zip member validation
- Tightened CORS (explicit methods/headers, no credentials)
- ADB serial regex allowlist, webhook rate-limit key fix, log scrubbing

Android:
- Root-capture: ordered teardown, screenrecord respawn watchdog, child reaping
- USB permission blocking API via CompletableDeferred
- Python init crash guard with fatal-error screen
- Moved root grant + QR generation off Main thread
- Cached PyObject engine for per-frame bridge calls
- Ordered ScreenCapture resource cleanup, allowBackup=false

Python:
- Replaced all asyncio.get_event_loop() with get_running_loop/to_thread
- Split color_strip_sources.py (1683->5 files) and color_strip_stream.py
  (1324->7 files) into packages
- Extracted FrameLimiter utility, migrated 9 stream loops
- Provider base-class reuse, WLED state caching + URL normalization
- Narrowed broad except-pass in WS routes, threading fixes in BaseStore

Frontend:
- XSS fix: escapeHtml on dynamic option labels, reconcile-based list renders
- Typed DOM helpers, safe localStorage access, AbortController listener hygiene
- openAuthedWs helper for first-message WS auth protocol
- Migrated remaining plain <select>s to IconSelect/EntitySelect

Design:
- WCAG AA primary color on light theme (#2e7d32, 5.4:1 contrast)
- Android TV 10-foot breakpoint (tv.css)
- Consolidated z-index tokens, unified easing, card-running GPU hints
This commit is contained in:
2026-04-16 04:56:04 +03:00
parent 5fcb9f82bd
commit 123da1b5c4
124 changed files with 6276 additions and 3705 deletions
+15
View File
@@ -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
+27
View File
@@ -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*
+1 -1
View File
@@ -29,7 +29,7 @@
<application
android:name=".LedGrabApp"
android:allowBackup="true"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
@@ -93,13 +93,14 @@ class CaptureService : Service() {
}
private fun startRootCapture(url: String) {
bridge = PythonBridge(this).also { b ->
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,
@@ -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
@@ -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)
}
}
@@ -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}")
@@ -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 <cmd>` 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() {
@@ -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) {
@@ -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")
}
}
@@ -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<Int, UsbSerialPort>()
private val initialized = AtomicBoolean(false)
private val pendingPermissions = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
/** 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<android.hardware.usb.UsbDevice>(
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<Boolean>().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,
+15 -4
View File
@@ -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 <api-key>` 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
+1
View File
@@ -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",
+224 -20
View File
@@ -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": "<key>"}``.
``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
@@ -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)
+8 -1
View File
@@ -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}"'},
)
@@ -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=<api_key>.
"""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
@@ -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=<api_key>. 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}"
)
+39 -10
View File
@@ -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:
@@ -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=<api_key>``.
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:
File diff suppressed because it is too large Load Diff
@@ -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",
]
@@ -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
@@ -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")
@@ -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,
}
@@ -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)
@@ -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}")
+4 -7
View File
@@ -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=<api_key>.
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()
@@ -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(
@@ -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=<api_key>."""
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=<api_key>."""
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:
@@ -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:
@@ -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:
+2 -2
View File
@@ -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),
@@ -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=<api_key>``. 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()
+7 -12
View File
@@ -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()
@@ -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=<api_key>.
"""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
+23 -4
View File
@@ -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,
@@ -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):
+49 -1
View File
@@ -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."""
@@ -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,
@@ -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)
@@ -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 <serial>``.
"""
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']}, "
@@ -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"
@@ -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"
@@ -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)
)
@@ -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),
@@ -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)
+2 -11
View File
@@ -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
@@ -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
@@ -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:
+5 -12
View File
@@ -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)
@@ -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)
@@ -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)
@@ -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:
@@ -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:
@@ -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",
]
@@ -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
@@ -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
@@ -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
@@ -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 01, "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)
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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:
@@ -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:
@@ -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:
@@ -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)
@@ -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 ``<asset>.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()]
+15 -4
View File
@@ -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)
+1
View File
@@ -17,3 +17,4 @@
@import './appearance.css';
@import './game-integration.css';
@import './mobile.css';
@import './tv.css';
+16 -1
View File
@@ -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;
}
+17 -1
View File
@@ -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);
+4 -1
View File
@@ -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 {
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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;
}
+86
View File
@@ -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);
}
}
+21 -3
View File
@@ -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 <select> with IconSelect
initLightbox();
// Setup form handler
const addDeviceForm = queryEl('add-device-form');
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
@@ -786,4 +796,12 @@ document.addEventListener('DOMContentLoaded', async () => {
if (!localStorage.getItem('tour_completed')) {
setTimeout(() => startGettingStartedTutorial(), 600);
}
} catch (err) {
// Top-level init failure should not silently leave the user with a
// half-loaded UI — surface a toast and log a stack to DevTools.
console.error('App init failed:', err);
try {
showToast(`Init failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
} catch {/* showToast itself failed; nothing else to do */}
}
});
+14 -4
View File
@@ -66,6 +66,8 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
return resp;
} catch (err) {
clearTimeout(timer);
// Never retry auth errors — caller has already been notified by handle401Error.
if (err instanceof ApiError && err.status === 401) throw err;
if (err instanceof ApiError) throw err;
if (attempt < maxAttempts - 1) {
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
@@ -86,11 +88,17 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
}
// ── Cached metrics-history fetch ────────────────────────────
let _metricsHistoryCache: { data: any; ts: number } | null = null;
/**
* Server response shape for /system/metrics-history. Kept loose because the
* payload contains many series and consumers narrow as needed.
*/
export type MetricsHistory = Record<string, unknown>;
let _metricsHistoryCache: { data: MetricsHistory; ts: number } | null = null;
const _METRICS_CACHE_TTL = 5000; // 5 seconds
/** Fetch metrics history with a short TTL cache to avoid duplicate requests across tabs. */
export async function fetchMetricsHistory(): Promise<any | null> {
export async function fetchMetricsHistory(): Promise<MetricsHistory | null> {
const now = Date.now();
if (_metricsHistoryCache && now - _metricsHistoryCache.ts < _METRICS_CACHE_TTL) {
return _metricsHistoryCache.data;
@@ -98,10 +106,12 @@ export async function fetchMetricsHistory(): Promise<any | null> {
try {
const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() });
if (!resp.ok) return null;
const data = await resp.json();
const data = await resp.json() as MetricsHistory;
_metricsHistoryCache = { data, ts: now };
return data;
} catch {
} catch (err) {
// Best-effort cache fetch — surface in DevTools but don't pop a toast.
if (typeof console !== 'undefined') console.debug('[fetchMetricsHistory]', err);
return null;
}
}
+6 -2
View File
@@ -106,7 +106,9 @@ void main() {
}
`;
let _canvas: HTMLCanvasElement = undefined as any, _gl: WebGLRenderingContext | null = null, _prog: WebGLProgram | null = null;
let _canvas: HTMLCanvasElement | null = null;
let _gl: WebGLRenderingContext | null = null;
let _prog: WebGLProgram | null = null;
let _uTime: WebGLUniformLocation | null, _uRes: WebGLUniformLocation | null, _uAccent: WebGLUniformLocation | null, _uBg: WebGLUniformLocation | null, _uLight: WebGLUniformLocation | null, _uParticlesBase: WebGLUniformLocation | null;
let _particleBuf: Float32Array | null = null; // pre-allocated Float32Array for uniform3fv
let _raf: number | null = null;
@@ -156,6 +158,7 @@ function _compile(gl: WebGLRenderingContext, type: number, src: string): WebGLSh
}
function _initGL(): boolean {
if (!_canvas) return false;
_gl = _canvas.getContext('webgl', { alpha: false, antialias: false, depth: false });
if (!_gl) return false;
const gl = _gl;
@@ -194,6 +197,7 @@ function _initGL(): boolean {
}
function _resize(): void {
if (!_canvas) return;
const w = Math.round(window.innerWidth * 0.5);
const h = Math.round(window.innerHeight * 0.5);
_canvas.width = w;
@@ -204,7 +208,7 @@ function _resize(): void {
function _draw(time: number): void {
_raf = requestAnimationFrame(_draw);
const gl = _gl;
if (!gl) return;
if (!gl || !_canvas) return;
_updateParticles();
@@ -11,6 +11,7 @@ import { bindableColor, bindableColorSourceId } from '../types.ts';
import { EntitySelect } from './entity-palette.ts';
import { getValueSourceIcon } from './icons.ts';
import { t } from './i18n.ts';
import { getInput, getSelect, getButton, getEl, reconcileList } from './dom-helpers.ts';
export interface BindableColorOpts {
container: HTMLElement;
@@ -74,11 +75,11 @@ export class BindableColorWidget {
this._container.innerHTML = colorHtml + vsHtml;
this._colorRow = document.getElementById(`${id}-color-row`)!;
this._vsRow = document.getElementById(`${id}-vs-row`)!;
this._colorInput = document.getElementById(`${id}-color`) as HTMLInputElement;
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
this._colorRow = getEl(`${id}-color-row`, HTMLElement);
this._vsRow = getEl(`${id}-vs-row`, HTMLElement);
this._colorInput = getInput(`${id}-color`);
this._select = getSelect(`${id}-select`);
this._toggleBtn = getButton(`${id}-toggle`);
this._colorInput.addEventListener('input', () => {
this._staticColor = this._hexToRgb(this._colorInput.value);
@@ -86,7 +87,7 @@ export class BindableColorWidget {
});
this._toggleBtn.addEventListener('click', () => this._setMode(true));
document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false));
getButton(`${id}-untoggle`).addEventListener('click', () => this._setMode(false));
}
private _setMode(bound: boolean): void {
@@ -106,10 +107,28 @@ export class BindableColorWidget {
// Filter to only color value sources
const sources = this._opts.valueSources().filter(vs => vs.return_type === 'color');
this._select.innerHTML = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
sources.map(vs =>
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
).join('');
const noneLabel = this._opts.noneLabel || t('bindable.none');
type Entry = { id: string; name: string };
const entries: Entry[] = [{ id: '', name: noneLabel }, ...sources.map(vs => ({ id: vs.id, name: vs.name }))];
reconcileList<Entry>(
this._select,
entries,
(e, idx) => `${idx}:${e.id}`,
(e) => {
const opt = document.createElement('option');
opt.value = e.id;
opt.text = e.name;
return opt;
},
(el, e) => {
if (el instanceof HTMLOptionElement) {
if (el.value !== e.id) el.value = e.id;
if (el.text !== e.name) el.text = e.name;
}
},
);
this._select.value = this._sourceId;
if (this._entitySelect) this._entitySelect.destroy();
this._entitySelect = new EntitySelect({
@@ -22,6 +22,7 @@ import { bindableValue, bindableSourceId } from '../types.ts';
import { EntitySelect } from './entity-palette.ts';
import { getValueSourceIcon } from './icons.ts';
import { t } from './i18n.ts';
import { getInput, getSelect, getButton, getEl, reconcileList } from './dom-helpers.ts';
export interface BindableScalarOpts {
container: HTMLElement;
@@ -97,13 +98,13 @@ export class BindableScalarWidget {
this._container.innerHTML = sliderHtml + vsHtml;
// Cache DOM refs
this._sliderRow = document.getElementById(`${id}-slider-row`)!;
this._vsRow = document.getElementById(`${id}-vs-row`)!;
this._slider = document.getElementById(`${id}-slider`) as HTMLInputElement;
this._display = document.getElementById(`${id}-display`)!;
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
// Cache DOM refs (typed helpers throw if the markup is broken)
this._sliderRow = getEl(`${id}-slider-row`, HTMLElement);
this._vsRow = getEl(`${id}-vs-row`, HTMLElement);
this._slider = getInput(`${id}-slider`);
this._display = getEl(`${id}-display`, HTMLElement);
this._select = getSelect(`${id}-select`);
this._toggleBtn = getButton(`${id}-toggle`);
// Slider input handler
this._slider.addEventListener('input', () => {
@@ -114,7 +115,7 @@ export class BindableScalarWidget {
// Toggle to bound mode
this._toggleBtn.addEventListener('click', () => this._setMode(true));
document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false));
getButton(`${id}-untoggle`).addEventListener('click', () => this._setMode(false));
}
private _setMode(bound: boolean): void {
@@ -136,12 +137,32 @@ export class BindableScalarWidget {
private _populateVsSelect(): void {
const sources = this._opts.valueSources();
const id = this._id;
this._select.innerHTML = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
sources.map(vs =>
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
).join('');
// Reconcile <option> children — preserves the element across rerenders
// and (more importantly) sets `option.text` via textContent so source
// names cannot inject markup into the DOM.
const noneLabel = this._opts.noneLabel || t('bindable.none');
type Entry = { id: string; name: string };
const entries: Entry[] = [{ id: '', name: noneLabel }, ...sources.map(vs => ({ id: vs.id, name: vs.name }))];
reconcileList<Entry>(
this._select,
entries,
(e, idx) => `${idx}:${e.id}`,
(e) => {
const opt = document.createElement('option');
opt.value = e.id;
opt.text = e.name; // textContent — safe for arbitrary names
return opt;
},
(el, e) => {
if (el instanceof HTMLOptionElement) {
if (el.value !== e.id) el.value = e.id;
if (el.text !== e.name) el.text = e.name;
}
},
);
this._select.value = this._sourceId;
// Wrap with EntitySelect
if (this._entitySelect) this._entitySelect.destroy();
@@ -155,7 +176,7 @@ export class BindableScalarWidget {
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: this._opts.noneLabel || t('bindable.none'),
noneLabel: noneLabel,
onChange: (value: string) => {
this._sourceId = value;
this._fireChange();
+9 -4
View File
@@ -2,8 +2,13 @@
* Reusable data cache with fetch deduplication, invalidation, and subscribers.
*/
import { fetchWithAuth } from './api.ts';
import { fetchWithAuth, ApiError } from './api.ts';
// Server JSON is treated as `any` at the cache boundary because each
// extractor knows the endpoint-specific shape (e.g. `json.devices`).
// Consumers see typed `T` once extraction returns. Narrowing happens
// implicitly via the extractData function signature for each cache instance.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ExtractDataFn<T> = (json: any) => T;
export type SubscriberFn<T> = (data: T) => void;
@@ -13,7 +18,7 @@ export interface DataCacheOpts<T> {
defaultValue?: T;
}
export class DataCache<T = any> {
export class DataCache<T = unknown> {
private _endpoint: string;
private _extractData: ExtractDataFn<T>;
private _defaultValue: T;
@@ -71,8 +76,8 @@ export class DataCache<T = any> {
this._fresh = true;
this._notify();
return this._data;
} catch (err: any) {
if (err.isAuth) return this._data;
} catch (err: unknown) {
if (err instanceof ApiError && err.isAuth) return this._data;
console.error(`Cache fetch ${this._endpoint}:`, err);
return this._data;
}
+60 -11
View File
@@ -4,14 +4,37 @@
* Uses a single document-level mousemove listener (event delegation) and
* CSS custom properties (--glare-x, --glare-y) to position a radial gradient
* overlay via the ::after pseudo-element defined in cards.css.
*
* All listeners are owned by an AbortController so callers can call
* `disposeCardGlare()` to detach everything in one go (e.g. before tests
* or a hot-reload). Mousemove updates are throttled to one per animation
* frame, and the cached bounding rect is invalidated on scroll/resize so
* the spotlight follows layout changes.
*/
const CARD_SEL = '.card, .template-card, .dashboard-target, .perf-chart-card';
let _active: Element | null = null; // currently illuminated card element
let _active: Element | null = null; // currently illuminated card element
let _cachedRect: DOMRect | null = null; // cached bounding rect for current card
let _controller: AbortController | null = null;
function _onMove(e) {
// rAF throttle state
let _pendingX = 0;
let _pendingY = 0;
let _rafScheduled = false;
function _flush(): void {
_rafScheduled = false;
if (!_active || !_cachedRect) return;
if (!(_active instanceof HTMLElement)) return;
const x = _pendingX - _cachedRect.left;
const y = _pendingY - _cachedRect.top;
_active.style.setProperty('--glare-x', `${x}px`);
_active.style.setProperty('--glare-y', `${y}px`);
}
function _onMove(e: MouseEvent): void {
if (!(e.target instanceof Element)) return;
const card = e.target.closest(CARD_SEL);
if (card && !card.classList.contains('add-device-card')) {
@@ -21,11 +44,12 @@ function _onMove(e) {
_active = card;
_cachedRect = card.getBoundingClientRect();
}
const x = e.clientX - _cachedRect!.left;
const y = e.clientY - _cachedRect!.top;
card.style.setProperty('--glare-x', `${x}px`);
card.style.setProperty('--glare-y', `${y}px`);
_pendingX = e.clientX;
_pendingY = e.clientY;
if (!_rafScheduled) {
_rafScheduled = true;
requestAnimationFrame(_flush);
}
} else if (_active) {
_active.classList.remove('card-glare');
_active = null;
@@ -33,7 +57,7 @@ function _onMove(e) {
}
}
function _onLeave() {
function _onLeave(): void {
if (_active) {
_active.classList.remove('card-glare');
_active = null;
@@ -41,7 +65,32 @@ function _onLeave() {
}
}
export function initCardGlare() {
document.addEventListener('mousemove', _onMove, { passive: true });
document.addEventListener('mouseleave', _onLeave);
function _invalidateRect(): void {
if (_active) _cachedRect = _active.getBoundingClientRect();
}
export function initCardGlare(): void {
if (_controller) return; // already initialized
_controller = new AbortController();
const { signal } = _controller;
document.addEventListener('mousemove', _onMove, { passive: true, signal });
document.addEventListener('mouseleave', _onLeave, { signal });
// Cached rect goes stale when the page scrolls or the viewport resizes —
// re-measure so the spotlight stays glued to the cursor.
window.addEventListener('scroll', _invalidateRect, { passive: true, signal });
window.addEventListener('resize', _invalidateRect, { passive: true, signal });
}
/** Tear down all listeners. Call before hot-reload or in tests. */
export function disposeCardGlare(): void {
if (_controller) {
_controller.abort();
_controller = null;
}
if (_active) {
_active.classList.remove('card-glare');
_active = null;
}
_cachedRect = null;
_rafScheduled = false;
}
@@ -0,0 +1,142 @@
/**
* Typed DOM helpers replace `as HTMLInputElement` casts and bang
* non-null assertions with helpers that fail loudly on misuse.
*
* Use `getInput`/`getSelect`/`getEl` when the element is required and a
* missing/wrong-type element is a developer error. Use the `tryGet…`
* variants when absence is legitimate (optional UI, conditional render).
*
* Also exports a minimal `reconcileList` reconciler used by bindable
* widgets to avoid blowing away DOM with `innerHTML = ''`.
*/
export function getInput(id: string): HTMLInputElement {
const el = document.getElementById(id);
if (!el) throw new Error(`Input #${id} not found in DOM`);
if (!(el instanceof HTMLInputElement)) {
throw new Error(`Element #${id} is not an HTMLInputElement (got ${el.tagName})`);
}
return el;
}
export function tryGetInput(id: string): HTMLInputElement | null {
const el = document.getElementById(id);
if (!el || !(el instanceof HTMLInputElement)) return null;
return el;
}
export function getSelect(id: string): HTMLSelectElement {
const el = document.getElementById(id);
if (!el) throw new Error(`Select #${id} not found in DOM`);
if (!(el instanceof HTMLSelectElement)) {
throw new Error(`Element #${id} is not an HTMLSelectElement (got ${el.tagName})`);
}
return el;
}
export function tryGetSelect(id: string): HTMLSelectElement | null {
const el = document.getElementById(id);
if (!el || !(el instanceof HTMLSelectElement)) return null;
return el;
}
export function getButton(id: string): HTMLButtonElement {
const el = document.getElementById(id);
if (!el) throw new Error(`Button #${id} not found in DOM`);
if (!(el instanceof HTMLButtonElement)) {
throw new Error(`Element #${id} is not an HTMLButtonElement (got ${el.tagName})`);
}
return el;
}
export function getEl<T extends HTMLElement>(id: string, ctor: new () => T): T {
const el = document.getElementById(id);
if (!el) throw new Error(`Element #${id} not found in DOM`);
if (!(el instanceof ctor)) {
throw new Error(`Element #${id} is not a ${ctor.name} (got ${el.tagName})`);
}
return el;
}
export function tryGetEl<T extends HTMLElement>(id: string, ctor: new () => T): T | null {
const el = document.getElementById(id);
if (!el || !(el instanceof ctor)) return null;
return el;
}
export function queryInput(root: ParentNode, selector: string): HTMLInputElement {
const el = root.querySelector(selector);
if (!el) throw new Error(`Input matching ${selector} not found`);
if (!(el instanceof HTMLInputElement)) {
throw new Error(`Element ${selector} is not an HTMLInputElement`);
}
return el;
}
export function tryQueryInput(root: ParentNode, selector: string): HTMLInputElement | null {
const el = root.querySelector(selector);
if (!el || !(el instanceof HTMLInputElement)) return null;
return el;
}
export function querySelect(root: ParentNode, selector: string): HTMLSelectElement {
const el = root.querySelector(selector);
if (!el) throw new Error(`Select matching ${selector} not found`);
if (!(el instanceof HTMLSelectElement)) {
throw new Error(`Element ${selector} is not an HTMLSelectElement`);
}
return el;
}
/**
* Minimal keyed list reconciler.
*
* Avoids the `container.innerHTML = items.map(...).join('')` pattern
* which (a) re-creates DOM and detaches all event listeners, and (b)
* makes XSS regressions easy because every interpolation must be
* escaped manually.
*
* @param container Parent element whose children will be reconciled.
* @param items Source list.
* @param keyOf Stable identity function.
* @param build Build a new element for an item.
* @param update Optional updater for an existing element.
*/
export function reconcileList<T>(
container: HTMLElement,
items: readonly T[],
keyOf: (item: T, index: number) => string,
build: (item: T, index: number) => HTMLElement,
update?: (el: HTMLElement, item: T, index: number) => void,
): void {
const existing = new Map<string, HTMLElement>();
for (const child of Array.from(container.children)) {
if (child instanceof HTMLElement) {
const key = child.dataset.reconcileKey;
if (key !== undefined) existing.set(key, child);
}
}
const next: HTMLElement[] = [];
items.forEach((item, idx) => {
const key = keyOf(item, idx);
let el = existing.get(key);
if (el) {
existing.delete(key);
if (update) update(el, item, idx);
} else {
el = build(item, idx);
el.dataset.reconcileKey = key;
}
next.push(el);
});
// Remove leftovers
for (const el of existing.values()) el.remove();
// Re-order / insert
next.forEach((el, idx) => {
const current = container.children[idx];
if (current !== el) container.insertBefore(el, current ?? null);
});
}
@@ -26,6 +26,8 @@
import { ICON_SEARCH } from './icons.ts';
import type { IconSelectItem } from './icon-select.ts';
import { escapeHtml } from './api.ts';
import { reconcileList } from './dom-helpers.ts';
// ── EntityPalette (singleton modal) ─────────────────────────
@@ -95,6 +97,19 @@ export class EntityPalette {
});
this._input.addEventListener('input', () => this._filter());
this._input.addEventListener('keydown', (e) => this._onKeyDown(e));
// Delegated click — set once for the lifetime of the palette so we
// don't have to re-attach handlers inside the reconcile loop below.
this._list.addEventListener('click', (e) => {
const target = e.target instanceof Element ? e.target.closest('.entity-palette-item') : null;
if (!target || !(target instanceof HTMLElement)) return;
const idxStr = target.dataset.idx;
if (idxStr === undefined) return;
const idx = parseInt(idxStr, 10);
if (Number.isFinite(idx) && this._filtered[idx]) {
this._select(this._filtered[idx]);
}
});
}
_pick({ items, current, placeholder, allowNone, noneLabel }: EntityPalettePickOpts) {
@@ -139,29 +154,58 @@ export class EntityPalette {
_render() {
if (this._filtered.length === 0) {
this._list.innerHTML = '<div class="entity-palette-empty">—</div>';
this._list.innerHTML = '';
const empty = document.createElement('div');
empty.className = 'entity-palette-empty';
empty.textContent = '—';
this._list.appendChild(empty);
return;
}
this._list.innerHTML = this._filtered.map((item, i) => {
const cls = [
'entity-palette-item',
i === this._highlightIdx ? 'ep-highlight' : '',
item.value === this._currentValue ? 'ep-current' : '',
].filter(Boolean).join(' ');
// Reconcile keyed list of items. label/desc are set via textContent,
// icon is raw SVG markup intentionally (see icons.ts — fixed set).
reconcileList(
this._list,
this._filtered,
(item, i) => `${i}:${item.value}`,
(item, i) => {
const div = document.createElement('div');
div.className = 'entity-palette-item';
div.dataset.idx = String(i);
if (item.icon) {
const ic = document.createElement('span');
ic.className = 'ep-item-icon';
ic.innerHTML = item.icon;
div.appendChild(ic);
}
const lbl = document.createElement('span');
lbl.className = 'ep-item-label';
lbl.textContent = item.label;
div.appendChild(lbl);
if (item.desc) {
const desc = document.createElement('span');
desc.className = 'ep-item-desc';
desc.textContent = item.desc;
div.appendChild(desc);
}
return div;
},
(el, item, i) => {
el.dataset.idx = String(i);
// Update text content if it shifted
const lbl = el.querySelector('.ep-item-label');
if (lbl && lbl.textContent !== item.label) lbl.textContent = item.label;
const desc = el.querySelector('.ep-item-desc');
if (desc && desc.textContent !== (item.desc ?? '')) desc.textContent = item.desc ?? '';
},
);
return `<div class="${cls}" data-idx="${i}">
${item.icon ? `<span class="ep-item-icon">${item.icon}</span>` : ''}
<span class="ep-item-label">${item.label}</span>
${item.desc ? `<span class="ep-item-desc">${item.desc}</span>` : ''}
</div>`;
}).join('');
// Click handlers
this._list.querySelectorAll('.entity-palette-item').forEach(el => {
el.addEventListener('click', () => {
this._select(this._filtered[parseInt((el as HTMLElement).dataset.idx!)]);
});
// Apply highlight/current classes
Array.from(this._list.children).forEach((el, i) => {
if (!(el instanceof HTMLElement)) return;
el.classList.toggle('ep-highlight', i === this._highlightIdx);
const item = this._filtered[i];
el.classList.toggle('ep-current', item?.value === this._currentValue);
});
// Scroll highlighted into view
@@ -260,18 +304,18 @@ export class EntitySelect {
if (item) {
this._trigger.innerHTML =
`${item.icon ? `<span class="es-trigger-icon">${item.icon}</span>` : ''}` +
`<span class="es-trigger-label">${item.label}</span>` +
`<span class="es-trigger-label">${escapeHtml(item.label)}</span>` +
`<span class="es-trigger-arrow">&#x25BE;</span>`;
} else if (this._allowNone && !val) {
this._trigger.innerHTML =
`<span class="es-trigger-label es-trigger-none">${this._noneLabel}</span>` +
`<span class="es-trigger-label es-trigger-none">${escapeHtml(this._noneLabel)}</span>` +
`<span class="es-trigger-arrow">&#x25BE;</span>`;
} else {
// Fallback: read from selected option text
const opt = this._select.selectedOptions[0];
const text = opt ? opt.textContent : val || '—';
const text = opt ? (opt.textContent ?? '') : (val || '—');
this._trigger.innerHTML =
`<span class="es-trigger-label">${text}</span>` +
`<span class="es-trigger-label">${escapeHtml(text)}</span>` +
`<span class="es-trigger-arrow">&#x25BE;</span>`;
}
}
+15 -12
View File
@@ -11,6 +11,8 @@
import { apiKey, authRequired } from './state.ts';
import { showRestartingOverlay } from './api.ts';
import { logError } from './log.ts';
import { openAuthedWs } from './ws-auth.ts';
/** True when the server has signalled it is restarting (not crashed). */
export let serverRestarting = false;
@@ -30,14 +32,13 @@ export function startEventsWS() {
if (authRequired && !apiKey) return;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey || '')}`;
const url = `${wsProto}//${location.host}/api/v1/events/ws`;
try {
_ws = new WebSocket(url);
_ws.onopen = () => {
_reconnectDelay = _RECONNECT_MIN; // reset backoff on successful connection
};
_ws.onmessage = (event) => {
openAuthedWs(url).then((ws) => {
_ws = ws;
_reconnectDelay = _RECONNECT_MIN;
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'server_restarting') {
@@ -45,17 +46,19 @@ export function startEventsWS() {
showRestartingOverlay();
}
document.dispatchEvent(new CustomEvent(`server:${data.type}`, { detail: data }));
} catch {}
} catch (err) { logError('events-ws.message', err); }
};
_ws.onclose = () => {
ws.onclose = () => {
_ws = null;
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
};
_ws.onerror = () => {};
} catch {
ws.onerror = () => {};
}).catch(() => {
_ws = null;
}
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
});
}
export function stopEventsWS() {
+31
View File
@@ -2,7 +2,10 @@
* Internationalization translations, locale detection, text updates.
*/
import { IconSelect } from './icon-select.ts';
let currentLocale = 'en';
let _localeIconSelect: IconSelect | null = null;
let translations = {};
let _initialized = false;
@@ -95,11 +98,39 @@ export function changeLocale() {
}
}
/** Build locale items for the IconSelect. Uses 2-letter code badge as icon. */
function _getLocaleItems(): { value: string; icon: string; label: string }[] {
return [
{ value: 'en', icon: '<span style="font-weight:700">EN</span>', label: 'English' },
{ value: 'ru', icon: '<span style="font-weight:700">RU</span>', label: 'Русский' },
{ value: 'zh', icon: '<span style="font-weight:700">ZH</span>', label: '中文' },
];
}
function _ensureLocaleIconSelect() {
if (_localeIconSelect) return;
const select = document.getElementById('locale-select') as HTMLSelectElement | null;
if (!select) return;
_localeIconSelect = new IconSelect({
target: select,
items: _getLocaleItems(),
columns: 1,
onChange: (val: string) => {
if (val && val !== currentLocale) {
localStorage.setItem('locale', val);
setLocale(val);
}
},
});
}
function updateLocaleSelect() {
const select = document.getElementById('locale-select') as HTMLSelectElement | null;
if (select) {
select.value = currentLocale;
}
_ensureLocaleIconSelect();
if (_localeIconSelect) _localeIconSelect.setValue(currentLocale);
}
export function updateAllText() {
+15
View File
@@ -0,0 +1,15 @@
/**
* Tiny logging helper used to replace silent `catch {}` blocks.
*
* Errors swallowed for control-flow reasons (best-effort persistence,
* optional WebSocket cleanup, parsing untrusted JSON, etc.) should still
* leave a debug trace so issues can be diagnosed without spamming users.
*/
export function logError(ctx: string, err: unknown): void {
// Always emit at debug level so production logs stay quiet but the
// information is reachable via DevTools verbose filtering.
if (typeof console !== 'undefined' && console.debug) {
console.debug(`[${ctx}]`, err);
}
}
@@ -4,6 +4,12 @@
* ES module `export let` creates live bindings: importers always see
* the latest value. But importers cannot reassign, so every variable
* gets a setter function.
*
* TODO(refactor): module-level mutable state has a large blast radius.
* Plan to migrate the highest-traffic globals (caches, currentEditing*,
* loading guards) into a small `Store` abstraction with explicit
* subscription, so listeners can be torn down per-tab. Tracking the
* migration in TODO.md.
*/
import { DataCache } from './cache.ts';
@@ -57,7 +63,14 @@ export function updateDeviceBrightness(deviceId: string, value: number) {
export let _discoveryScanRunning = false;
export function set_discoveryScanRunning(v: boolean) { _discoveryScanRunning = v; }
/**
* Per-device-type discovery cache. Payload shape varies per discovery adapter
* (mDNS, OpenRGB, espnow, etc.); kept as `any[]` here and narrowed at the
* consumer (device-discovery.ts) which knows the per-type shape.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let _discoveryCache: Record<string, any[]> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function set_discoveryCache(v: Record<string, any[]>) { _discoveryCache = v; }
// Streams / templates state
@@ -0,0 +1,63 @@
/**
* Safe localStorage helpers.
*
* Replaces the unsafe `JSON.parse(localStorage.getItem(key)!)` pattern,
* which throws on missing keys, malformed JSON, or unexpected payloads.
*/
import { logError } from './log.ts';
/**
* Read and validate a JSON value from localStorage.
*
* @param key Storage key.
* @param guard Type guard validating the parsed value before returning it.
* @returns The validated value or `null` if missing/invalid.
*/
export function readJson<T>(key: string, guard: (v: unknown) => v is T): T | null {
let raw: string | null;
try {
raw = localStorage.getItem(key);
} catch (err) {
// Some embedded WebViews throw on storage access (private mode etc.)
logError('storage.readJson', err);
return null;
}
if (raw === null || raw === '') return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
logError(`storage.readJson:${key}`, err);
return null;
}
return guard(parsed) ? parsed : null;
}
/** Best-effort write; logs but does not throw on quota/storage errors. */
export function writeJson(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
logError(`storage.writeJson:${key}`, err);
}
}
// ── Common type guards ────────────────────────────────────────
export function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
export function isString(v: unknown): v is string {
return typeof v === 'string';
}
export function isNumber(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v);
}
export function isStringRecord(v: unknown): v is Record<string, string> {
if (!isObject(v)) return false;
return Object.values(v).every(isString);
}
+24
View File
@@ -5,6 +5,30 @@
import { confirmResolve, setConfirmResolve } from './state.ts';
import { API_BASE, getHeaders } from './api.ts';
import { t } from './i18n.ts';
import { IconSelect } from './icon-select.ts';
let _lightboxFpsIconSelect: IconSelect | null = null;
/** Enhance the lightbox FPS <select> 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: '<span style="font-weight:700">1</span>', label: '1 fps' },
{ value: '2', icon: '<span style="font-weight:700">2</span>', label: '2 fps' },
{ value: '3', icon: '<span style="font-weight:700">3</span>', label: '3 fps' },
{ value: '5', icon: '<span style="font-weight:700">5</span>', 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() {
@@ -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<WebSocket> {
return new Promise<WebSocket>((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<typeof setTimeout> | 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);
});
}
@@ -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 = () => {
@@ -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 <select> with an EntitySelect for consistent UI
if (_calTestDeviceEntitySelect) {
_calTestDeviceEntitySelect.destroy();
_calTestDeviceEntitySelect = null;
}
if (devices.length) {
_calTestDeviceEntitySelect = new EntitySelect({
target: testDeviceSelect,
getItems: () => _calTestDeviceList.map((d: any) => ({
value: d.id,
label: d.name,
icon: ICON_DEVICE,
desc: d.led_count ? `${d.led_count} LEDs` : '',
})),
placeholder: t('palette.search'),
onChange: (val: string) => {
localStorage.setItem('css_calibration_test_device', val);
},
} as any);
}
// Populate calibration fields
const preview = document.querySelector('.calibration-preview') as HTMLElement;
preview.style.aspectRatio = '';
@@ -1610,8 +1610,7 @@ function _showApiInputEndpoints(cssId: any) {
const hostPart = origin.replace(/^https?:\/\//, '');
const wsBase = `${wsProto}//${hostPart}/api/v1`;
const restUrl = `${base}/color-strip-sources/${cssId}/colors`;
const apiKey = localStorage.getItem('ledgrab_api_key') || '';
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws`;
el.innerHTML = `
<small class="endpoint-label">REST POST</small>
<div class="ws-url-row" style="margin-bottom:6px"><input type="text" value="${restUrl}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">${ICON_CLONE}</button></div>
@@ -211,16 +211,21 @@ function _overridesRenderList() {
// Wire browse buttons
list.querySelectorAll<HTMLButtonElement>('.notif-override-browse').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx!);
const nameInput = list.querySelector<HTMLInputElement>(`.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<HTMLInputElement>(`.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);
}
});
});
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>';
@@ -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 {
@@ -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<void> {
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<void> {
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);

Some files were not shown because too many files have changed in this diff Show More