fix: comprehensive security, stability, and code quality audit
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:
@@ -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
|
||||
|
||||
Vendored
+27
@@ -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*
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 0–1, "color": [R,G,B], "color_right": [R,G,B] | absent}
|
||||
|
||||
Interpolation:
|
||||
Sort stops by position. For each LED at relative position p = i/(N-1):
|
||||
p ≤ first stop → first stop primary color
|
||||
p ≥ last stop → last stop right color (if bidirectional) else primary
|
||||
else find surrounding stops A (≤p) and B (>p):
|
||||
left_color = A["color_right"] if present, else A["color"]
|
||||
right_color = B["color"]
|
||||
t = (p - A.pos) / (B.pos - A.pos)
|
||||
color = lerp(left_color, right_color, eased(t))
|
||||
"""
|
||||
if led_count <= 0:
|
||||
led_count = 1
|
||||
|
||||
if not stops:
|
||||
return np.zeros((led_count, 3), dtype=np.uint8)
|
||||
|
||||
sorted_stops = sorted(stops, key=lambda s: float(s.get("position", 0)))
|
||||
|
||||
def _color(stop: dict, side: str = "left") -> np.ndarray:
|
||||
if side == "right":
|
||||
cr = stop.get("color_right")
|
||||
if cr and isinstance(cr, list) and len(cr) == 3:
|
||||
return np.array(cr, dtype=np.float32)
|
||||
c = stop.get("color", [255, 255, 255])
|
||||
return np.array(
|
||||
c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32
|
||||
)
|
||||
|
||||
# Vectorized: compute all LED positions at once
|
||||
positions = np.linspace(0, 1, led_count) if led_count > 1 else np.array([0.0])
|
||||
result = np.zeros((led_count, 3), dtype=np.float32)
|
||||
|
||||
# Extract stop positions and colors into arrays
|
||||
n_stops = len(sorted_stops)
|
||||
stop_positions = np.array([float(s.get("position", 0)) for s in sorted_stops], dtype=np.float32)
|
||||
|
||||
# Pre-compute left/right colors for each stop
|
||||
left_colors = np.array([_color(s, "left") for s in sorted_stops], dtype=np.float32)
|
||||
right_colors = np.array([_color(s, "right") for s in sorted_stops], dtype=np.float32)
|
||||
|
||||
# LEDs before first stop
|
||||
mask_before = positions <= stop_positions[0]
|
||||
result[mask_before] = left_colors[0]
|
||||
|
||||
# LEDs after last stop
|
||||
mask_after = positions >= stop_positions[-1]
|
||||
result[mask_after] = right_colors[-1]
|
||||
|
||||
# LEDs between stops — vectorized per segment
|
||||
mask_between = ~mask_before & ~mask_after
|
||||
if np.any(mask_between):
|
||||
between_pos = positions[mask_between]
|
||||
# np.searchsorted finds the right stop index for each LED
|
||||
idx = np.searchsorted(stop_positions, between_pos, side="right") - 1
|
||||
idx = np.clip(idx, 0, n_stops - 2)
|
||||
|
||||
a_pos = stop_positions[idx]
|
||||
b_pos = stop_positions[idx + 1]
|
||||
span = b_pos - a_pos
|
||||
t = np.where(span > 0, (between_pos - a_pos) / span, 0.0)
|
||||
|
||||
# Apply easing to interpolation parameter
|
||||
if easing == "ease_in_out":
|
||||
t = t * t * (3.0 - 2.0 * t)
|
||||
elif easing == "cubic":
|
||||
t = np.where(t < 0.5, 4.0 * t * t * t, 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0)
|
||||
elif easing == "step":
|
||||
steps = float(max(2, n_stops))
|
||||
t = np.round(t * steps) / steps
|
||||
|
||||
a_colors = right_colors[idx] # A's right color
|
||||
b_colors = left_colors[idx + 1] # B's left color
|
||||
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
|
||||
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
@@ -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()]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
@import './appearance.css';
|
||||
@import './game-integration.css';
|
||||
@import './mobile.css';
|
||||
@import './tv.css';
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
--primary-color: #4CAF50;
|
||||
--primary-hover: #5cb860;
|
||||
--primary-contrast: #ffffff;
|
||||
/* Theme-aware token for primary color used as TEXT on light backgrounds.
|
||||
Defaults to --primary-color (dark theme); overridden in light theme below
|
||||
to a darker green that meets WCAG AA contrast on white. */
|
||||
--primary-color-on-light-bg: #2e7d32;
|
||||
--primary-text: var(--primary-color);
|
||||
--danger-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
--info-color: #2196F3;
|
||||
@@ -51,8 +56,13 @@
|
||||
--z-command-palette: 3000;
|
||||
--z-toast: 3500;
|
||||
--z-overlay-spinner: 9999;
|
||||
--z-modal-top: 9999; /* drag clones, top-most modals */
|
||||
--z-lightbox: 10000;
|
||||
--z-connection: 10000;
|
||||
--z-tutorial: 10010; /* tutorial backdrop / fixed overlay */
|
||||
--z-tutorial-tooltip: 10012;
|
||||
--z-connection: 10020; /* connection-lost overlay — MUST be the
|
||||
highest layer so a dropped connection is
|
||||
never occluded by a tutorial step. */
|
||||
}
|
||||
|
||||
/* ── SVG icon base ── */
|
||||
@@ -104,6 +114,11 @@
|
||||
--shadow-color: rgba(0, 0, 0, 0.12);
|
||||
--hover-bg: rgba(0, 0, 0, 0.05);
|
||||
--input-bg: #f0f0f0;
|
||||
/* WCAG AA: #4CAF50 fails (~3.2:1) on #ffffff. Darken to #2e7d32 (~5.4:1). */
|
||||
--primary-color: #2e7d32;
|
||||
--primary-hover: #36913a;
|
||||
--primary-color-on-light-bg: #2e7d32;
|
||||
--primary-text: #2e7d32;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">▾</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">▾</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">▾</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user