diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 6f920a5..4327d64 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -210,6 +210,10 @@ dependencies {
implementation("androidx.core:core-splashscreen:1.0.1")
// QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3")
+ // EncryptedSharedPreferences (Android Keystore-backed) for the per-install
+ // server API key (see ApiKeyManager). Falls back to plain SharedPreferences
+ // when the keystore is unavailable.
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0417e90..249c233 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -14,7 +14,8 @@
-
+
+ // Best-effort persist into the encrypted store; cache regardless
+ // so we still return the recovered key if the write keeps failing.
+ runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
+ cached = recovered
+ Log.i(TAG, "Recovered existing API key from legacy storage")
+ return recovered
+ }
val generated = generateKey()
// commit() (synchronous disk write) on the FIRST write so
// the key is durable before MainActivity encodes it into a
@@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) {
}
}
+ /**
+ * Build the backing store, preferring EncryptedSharedPreferences. Returns
+ * (store, isEncrypted). Any keystore failure falls back to the plain prefs
+ * file so the local API key is never lost on a broken-keystore device.
+ */
+ private fun buildPrefs(context: Context): Pair {
+ return try {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ val store = EncryptedSharedPreferences.create(
+ context,
+ ENCRYPTED_PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ store to true
+ } catch (e: Exception) {
+ // Keystore unavailable/corrupt — degrade to plain prefs rather
+ // than crashing. Worst case the key is stored unencrypted on a
+ // single-user TV box, which is the pre-existing behaviour.
+ Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}")
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
+ }
+ }
+
+ /**
+ * One-time migration: if a key exists in the legacy plain-text prefs file
+ * (from before encrypted storage), copy it into the encrypted store and
+ * remove the plain copy. Preserves the existing key so already-scanned QR
+ * clients keep working — generating a fresh key here would silently 401
+ * every LAN client (see the Data Migration Policy in CLAUDE.md).
+ */
+ private fun migrateLegacyKeyIfPresent() {
+ // Don't migrate if the encrypted store already holds a key.
+ if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
+ runCatching {
+ val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val legacyKey = legacy.getString(KEY_API_KEY, null)
+ if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
+ // commit() returns false on write failure WITHOUT throwing, so the
+ // runCatching wrapper alone does NOT protect this path. Verify the
+ // encrypted store both committed AND reads back the identical value
+ // before touching the legacy copy — otherwise a silent write
+ // failure could delete the only surviving copy of the key and
+ // rotate it on next launch (401s every paired client — the exact
+ // silent-data-loss the Data Migration Policy forbids).
+ val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
+ if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
+ // Keep the value as a .migrated backup (don't hard-delete) per
+ // the migration policy; remove only the live legacy key so the
+ // plaintext copy no longer answers reads.
+ legacy.edit()
+ .putString(KEY_API_KEY_MIGRATED, legacyKey)
+ .remove(KEY_API_KEY)
+ .apply()
+ Log.i(TAG, "Migrated API key from plain to encrypted storage")
+ } else {
+ // Leave the legacy key untouched; getOrCreateKey() will recover
+ // it via recoverLegacyKey() rather than minting a fresh one.
+ Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
+ }
+ }
+ }.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
+ }
+
+ /**
+ * Recover a still-present key from the legacy plain store — either the live
+ * key (failed/never-run migration) or the `.migrated` backup. Returns null
+ * when on the plain-prefs path (no legacy/encrypted split) or no valid key
+ * survives. Guarantees [getOrCreateKey] never rotates an existing key as long
+ * as the legacy file survives.
+ */
+ private fun recoverLegacyKey(): String? {
+ if (!encrypted) return null
+ val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val candidate = legacy.getString(KEY_API_KEY, null)
+ ?: legacy.getString(KEY_API_KEY_MIGRATED, null)
+ return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
+ }
+
private fun generateKey(): String {
val bytes = ByteArray(KEY_BYTES)
SecureRandom().nextBytes(bytes)
@@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) {
companion object {
private const val TAG = "ApiKeyManager"
private const val PREFS_NAME = "ledgrab_auth"
+ private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
private const val KEY_API_KEY = "api_key"
+ // Backup of a migrated legacy key, kept in the plain store per the
+ // Data Migration Policy (never hard-delete user data on rename/move).
+ private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
private const val KEY_BYTES = 32
private const val MIN_KEY_LENGTH = 32
diff --git a/android/app/src/main/java/com/ledgrab/android/BleBridge.kt b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt
index 71aa5de..06f81cb 100644
--- a/android/app/src/main/java/com/ledgrab/android/BleBridge.kt
+++ b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt
@@ -103,12 +103,32 @@ object BleBridge {
}
try {
- bleHandler.post { scanner.startScan(callback) }
+ // startScan runs on the BLE handler thread; a denied
+ // BLUETOOTH_SCAN throws SecurityException there, which would
+ // crash the whole process (an uncaught exception on a handler
+ // thread is fatal). Catch it inside the posted body and report.
+ bleHandler.post {
+ try {
+ scanner.startScan(callback)
+ } catch (e: SecurityException) {
+ Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
+ } catch (e: Exception) {
+ Log.w(TAG, "BLE startScan failed: ${e.message}")
+ }
+ }
Thread.sleep(timeoutMs)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
} finally {
- try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
+ bleHandler.post {
+ try {
+ scanner.stopScan(callback)
+ } catch (e: SecurityException) {
+ Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
+ } catch (e: Exception) {
+ Log.w(TAG, "BLE stopScan failed: ${e.message}")
+ }
+ }
}
return seen.values.toList()
}
@@ -136,7 +156,18 @@ object BleBridge {
newState == BluetoothProfile.STATE_CONNECTED
&& status == BluetoothGatt.GATT_SUCCESS -> {
Log.d(TAG, "GATT connected to $address, discovering services")
- gatt.discoverServices()
+ // Runs on the BLE handler thread; a denied
+ // BLUETOOTH_CONNECT throws SecurityException here, which
+ // would crash the process. Catch and fail the connect.
+ try {
+ gatt.discoverServices()
+ } catch (e: SecurityException) {
+ Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
+ readyDeferred.complete(false)
+ } catch (e: Exception) {
+ Log.w(TAG, "discoverServices failed: ${e.message}")
+ readyDeferred.complete(false)
+ }
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
Log.w(TAG, "GATT disconnected from $address (status=$status)")
diff --git a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
index a6e0c07..0252f32 100644
--- a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
+++ b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
@@ -105,12 +105,48 @@ class CaptureService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
- // CRITICAL: startForeground must be called IMMEDIATELY — before
- // any other work, especially before getMediaProjection(). The
- // service type must match the work; pass it explicitly via
- // ServiceCompat so we stay compatible back to API 24.
+ // CRITICAL (Android 14+): for the MediaProjection path, validate the
+ // projection token BEFORE promoting to a foreground service with the
+ // mediaProjection FGS type. On service recreation (system redelivery
+ // or a stale relaunch) the consent token is gone — promoting first and
+ // then discovering the dead token causes a spurious foreground-service
+ // start + immediate stop, which on strict OEMs flickers the
+ // notification or trips a stopSelf loop. Bail out cleanly here, before
+ // startForeground, when the MediaProjection consent data is missing.
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—"
val url = "http://$localIp:$SERVER_PORT"
+
+ val mediaProjectionResultData: Intent? =
+ if (!useRoot) extractProjectionResultData(intent) else null
+ if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
+ // MediaProjection mode can't recover from a redelivery —
+ // the consent token in the original intent is single-use.
+ //
+ // We were launched via startForegroundService(), so the OS REQUIRES
+ // a startForeground() within ~5s even on this immediate-stop path,
+ // or it raises the fatal ForegroundServiceDidNotStartInTimeException.
+ // Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
+ // have no valid consent token, and requesting that type without an
+ // active projection is exactly what we're avoiding) just long enough
+ // to satisfy the contract, then stop.
+ Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
+ runCatching {
+ val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
+ } else {
+ 0
+ }
+ ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
+ }.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ // startForeground must be called IMMEDIATELY after the token check —
+ // before any heavier work like getMediaProjection(). The service type
+ // must match the work; pass it explicitly via ServiceCompat so we stay
+ // compatible back to API 24. The MEDIA_PROJECTION type is only used
+ // here once resultData is confirmed non-null (checked above).
try {
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
var t = if (useRoot) {
@@ -152,20 +188,13 @@ class CaptureService : Service() {
// otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true
- if (intent == null && !useRoot) {
- // MediaProjection mode can't recover from a redelivery —
- // the consent token in the original intent is single-use.
- Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
- isRunning = false
- stopSelf()
- return START_NOT_STICKY
- }
-
try {
if (useRoot) {
startRootCapture(url)
} else {
- startMediaProjectionCapture(intent!!, url)
+ // mediaProjectionResultData is guaranteed non-null here — the
+ // token was validated before startForeground above.
+ startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
@@ -294,20 +323,24 @@ class CaptureService : Service() {
}
}
- private fun startMediaProjectionCapture(intent: Intent, url: String) {
- val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
+ /**
+ * Extract the single-use MediaProjection consent token from the start
+ * intent, or null if the intent is missing/redelivered without it.
+ * Called BEFORE startForeground so the mediaProjection FGS type is only
+ * ever requested when a valid token is present (see onStartCommand).
+ */
+ private fun extractProjectionResultData(intent: Intent?): Intent? {
+ if (intent == null) return null
@Suppress("DEPRECATION")
- val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent.getParcelableExtra(EXTRA_RESULT_DATA)
}
+ }
- if (resultData == null) {
- Log.e(TAG, "No MediaProjection result data")
- stopSelf()
- return
- }
+ private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
+ val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
diff --git a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt
index ea79608..14f92bb 100644
--- a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt
+++ b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt
@@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification
import android.util.Log
import com.chaquo.python.Python
import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
/**
* Captures posted OS notifications and forwards the posting app's display
@@ -25,7 +27,20 @@ class LedGrabNotificationListener : NotificationListenerService() {
// Serial executor: the Python receiver does a (non-concurrency-safe) history
// disk write and may play a sound, so pushes must not overlap. Off the main
// looper to keep the system service responsive.
- private val pushExecutor = Executors.newSingleThreadExecutor()
+ //
+ // Tied to the listener-connection lifecycle (onListenerConnected /
+ // onListenerDisconnected), NOT onDestroy: this is a system-rebindable
+ // service, so it can be connected/disconnected multiple times across a
+ // single onCreate..onDestroy span. Managing the executor here — combined
+ // with the runCatching guard at the submit site — keeps a notification
+ // that races teardown from triggering RejectedExecutionException on a
+ // shut-down executor. @Volatile so the connect/disconnect callbacks (which
+ // may run on a different thread than onNotificationPosted) publish safely.
+ @Volatile private var pushExecutor: ExecutorService? = null
+
+ // Guards executor creation so the lazy submit-site fallback and
+ // onListenerConnected can't race two executors into existence.
+ private val executorLock = Any()
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
@@ -51,17 +66,34 @@ class LedGrabNotificationListener : NotificationListenerService() {
val label = resolveAppLabel(notification.packageName)
- pushExecutor.execute {
- try {
- Python.getInstance()
- .getModule(PY_MODULE)
- .callAttr("push_notification", label)
- } catch (t: Throwable) {
- // Never crash a system-bound service. Python.getInstance() throws
- // IllegalStateException if Python.start() hasn't run (e.g. the
- // service was bound at boot before the app process initialized).
- // Log at debug — the label is potentially sensitive on a shared TV.
- Log.d(TAG, "push_notification failed: ${t.message}")
+ // Obtain (creating if needed) the executor. onListenerConnected normally
+ // creates it, but that callback is not reliably invoked on every
+ // OEM/version (re)bind, and a notification can arrive before it fires —
+ // lazily creating here keeps a missing/late onListenerConnected from
+ // permanently disabling notification forwarding. A late submit onto an
+ // executor that onListenerDisconnected is shutting down throws
+ // RejectedExecutionException — guard with runCatching so a notification
+ // racing teardown can never crash this system-bound service.
+ val executor = ensureExecutor()
+ runCatching {
+ executor.execute {
+ try {
+ Python.getInstance()
+ .getModule(PY_MODULE)
+ .callAttr("push_notification", label)
+ } catch (t: Throwable) {
+ // Never crash a system-bound service. Python.getInstance() throws
+ // IllegalStateException if Python.start() hasn't run (e.g. the
+ // service was bound at boot before the app process initialized).
+ // Log at debug — the label is potentially sensitive on a shared TV.
+ Log.d(TAG, "push_notification failed: ${t.message}")
+ }
+ }
+ }.onFailure { e ->
+ if (e is RejectedExecutionException) {
+ Log.d(TAG, "push rejected — listener disconnecting")
+ } else {
+ throw e
}
}
}
@@ -69,24 +101,59 @@ class LedGrabNotificationListener : NotificationListenerService() {
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
private fun resolveAppLabel(pkg: String): String {
labelCache[pkg]?.let { return it }
+ // Only cache SUCCESSFUL resolutions. Caching the package-name fallback
+ // would permanently pin a wrong label if the PackageManager lookup
+ // failed transiently (e.g. the app was mid-install / still updating).
val resolved = runCatching {
val info = packageManager.getApplicationInfo(pkg, 0)
packageManager.getApplicationLabel(info).toString()
- }.getOrDefault(pkg)
- labelCache[pkg] = resolved
- return resolved
+ }.getOrNull()
+ if (resolved != null) {
+ labelCache[pkg] = resolved
+ return resolved
+ }
+ return pkg
+ }
+
+ /**
+ * Return the push executor, creating it under [executorLock] if absent.
+ * Safe against a concurrent onListenerConnected/onNotificationPosted race
+ * (single executor) and against a missing onListenerConnected callback.
+ */
+ private fun ensureExecutor(): ExecutorService {
+ pushExecutor?.let { return it }
+ synchronized(executorLock) {
+ return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
+ }
}
override fun onListenerConnected() {
Log.i(TAG, "Notification listener connected")
+ // Spin up the push executor on connect. The system can disconnect and
+ // later reconnect this service without destroying it, so own the
+ // executor here rather than in onCreate/onDestroy. onNotificationPosted
+ // also lazily creates it (via ensureExecutor) in case this callback is
+ // late or skipped on some ROMs.
+ ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
+ // Tear the executor down on disconnect; a fresh one is created on the
+ // next onListenerConnected. Null out first so any in-flight
+ // onNotificationPosted snapshots see null (skips submit) rather than
+ // racing a shutdown executor.
+ pushExecutor?.let { exec ->
+ pushExecutor = null
+ exec.shutdown()
+ }
}
override fun onDestroy() {
- pushExecutor.shutdown()
+ // Defensive: onListenerDisconnected normally clears this first, but
+ // shut down here too in case onDestroy fires without a prior disconnect.
+ pushExecutor?.shutdown()
+ pushExecutor = null
super.onDestroy()
}
diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
index d6a3ff3..b28831c 100644
--- a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
+++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
/**
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
private const val REQUEST_POST_NOTIFICATIONS = 1002
private const val REQUEST_RECORD_AUDIO = 1003
private const val REQUEST_CAMERA = 1004
+ private const val REQUEST_BLUETOOTH = 1005
private const val QR_SIZE_PX = 560
private const val NOTIF_PREFS = "ledgrab_notif"
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
toggleButton.text = getString(R.string.btn_starting)
statusText.text = getString(R.string.status_checking_root)
uiScope.launch(Dispatchers.IO) {
- val rooted = Root.requestGrant()
+ // runInterruptible so a config change (rotation) during the
+ // up-to-10s `su` probe cancels the coroutine AND interrupts the
+ // blocking probe thread — Root.requestGrant honours the interrupt,
+ // destroys the su child, and rethrows, so we don't leak the
+ // process + drain thread. Without this, IO-dispatcher cancellation
+ // would not interrupt the blocking waitFor().
+ val rooted = runInterruptible { Root.requestGrant() }
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
toggleButton.text = originalText
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
ensureNotificationPermission()
ensureNotificationListenerAccess()
ensureCameraPermission()
+ ensureBluetoothPermissions()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
ensureNotificationListenerAccess()
ensureAudioPermission()
ensureCameraPermission()
+ ensureBluetoothPermissions()
val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent)
updateUI()
@@ -536,6 +546,30 @@ class MainActivity : Activity() {
}
}
+ /**
+ * Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
+ * server can discover and drive BLE LED controllers (SP110E / Triones /
+ * Zengge). On API < 31 these are install-time legacy permissions
+ * (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
+ * need no runtime grant — so this is a no-op there. Fire-and-forget,
+ * like [ensureAudioPermission]: screen capture works without BLE, and
+ * BleBridge degrades gracefully (empty scan / failed connect) when the
+ * grant is denied, so we don't block on the result. If first granted
+ * here, BLE devices become reachable on the next scan/connect.
+ */
+ private fun ensureBluetoothPermissions() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
+ val needed = listOf(
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ ).filter {
+ checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (needed.isEmpty()) return
+ @Suppress("DEPRECATION")
+ requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
+ }
+
/** Whether the user has granted notification-listener access to this app. */
private fun isNotificationAccessGranted(): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
diff --git a/android/app/src/main/java/com/ledgrab/android/Root.kt b/android/app/src/main/java/com/ledgrab/android/Root.kt
index 2576bc7..e3ab186 100644
--- a/android/app/src/main/java/com/ledgrab/android/Root.kt
+++ b/android/app/src/main/java/com/ledgrab/android/Root.kt
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
object Root {
private const val TAG = "Root"
+ // Slice length for the cancellation-aware su probe wait loop. Short
+ // enough that coroutine cancellation is honoured promptly, long enough
+ // to avoid busy-spinning while Magisk's grant dialog is up.
+ private const val POLL_SLICE_MS = 100L
+
private val SU_PATHS = listOf(
"/system/bin/su",
"/system/xbin/su",
@@ -49,17 +54,19 @@ object Root {
return false
}
+ var process: Process? = null
val granted = try {
// 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")
+ val proc = ProcessBuilder("su", "-c", "id")
.redirectErrorStream(true)
.start()
+ process = proc
val outputBuilder = StringBuilder()
val drain = Thread({
try {
- BufferedReader(InputStreamReader(process.inputStream)).use { r ->
+ BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
val buf = CharArray(512)
while (true) {
val n = r.read(buf)
@@ -72,17 +79,35 @@ object Root {
}
}, "Root-su-drain").apply { isDaemon = true; start() }
- val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
+ // Cancellation-aware wait: callers run this on a coroutine
+ // (MainActivity wraps it in runInterruptible), so a config change
+ // mid-probe cancels the coroutine and interrupts this thread.
+ // Poll waitFor() in short slices and honour interruption so we
+ // don't leak the `su` child + its drain thread for up to 10s.
+ // The catch(InterruptedException) below destroys the process; we
+ // re-arm the interrupt and rethrow so coroutine cancellation
+ // propagates cleanly.
+ val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
+ var finished = false
+ while (System.nanoTime() < deadlineNanos) {
+ if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
+ finished = true
+ break
+ }
+ // Throws InterruptedException if the thread was interrupted
+ // by coroutine cancellation — handled below to tear down.
+ if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
+ }
if (!finished) {
- process.destroyForcibly()
+ proc.destroyForcibly()
drain.join(500)
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
false
} else {
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()}'")
+ if (proc.exitValue() != 0) {
+ Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
false
} else {
val rooted = output.contains("uid=0")
@@ -90,8 +115,17 @@ object Root {
rooted
}
}
+ } catch (e: InterruptedException) {
+ // Coroutine cancelled mid-probe (e.g. config change). Kill the
+ // su child so it doesn't outlive the cancelled work, re-arm the
+ // interrupt flag, and rethrow so the coroutine cancels cleanly.
+ // Do NOT cache a result — the probe never completed.
+ runCatching { process?.destroyForcibly() }
+ Thread.currentThread().interrupt()
+ throw e
} catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}")
+ runCatching { process?.destroyForcibly() }
false
}
diff --git a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt
index 93d9534..8547940 100644
--- a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt
+++ b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt
@@ -89,14 +89,15 @@ class RootScreenrecord(
running = true
try {
- imageReader = buildImageReader()
- decoder = buildDecoder(imageReader!!)
- process = spawnScreenrecord() ?: run {
+ val reader = buildImageReader().also { imageReader = it }
+ val codec = buildDecoder(reader).also { decoder = it }
+ val proc = spawnScreenrecord() ?: run {
stop()
return false
}
- startInputPump(process!!.inputStream, decoder!!)
- startOutputDrain(decoder!!)
+ process = proc
+ startInputPump(proc.inputStream, codec)
+ startOutputDrain(codec)
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
return true
} catch (e: Exception) {
@@ -178,6 +179,14 @@ class RootScreenrecord(
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
}
+ // CONTRACT: frameBuffer is REUSED across frames (single-threaded
+ // reader callback — no copy here). Safety depends on the Python
+ // receiver copying the bytes before this callback returns and
+ // overwrites the buffer for the next frame. It does:
+ // PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
+ // (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
+ // does `rgba[:, :, :3].copy()`, so the queued frame owns its
+ // pixels independently of this buffer. Do NOT remove that copy.
bridge.pushRootFrame(frameBuffer, width, height)
framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
diff --git a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
index 2ce7c52..53ed275 100644
--- a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
+++ b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
@@ -147,6 +147,14 @@ class ScreenCapture(
}
}
+ // CONTRACT: frameBuffer is REUSED across frames (single-threaded
+ // capture handler — no copy here). Safety depends on the Python
+ // receiver copying the bytes before this callback returns and
+ // overwrites the buffer for the next frame. It does:
+ // PythonBridge.pushFrame → mediaprojection_engine.push_frame
+ // (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
+ // does `rgba[:, :, :3].copy()`, so the queued frame owns its
+ // pixels independently of this buffer. Do NOT remove that copy.
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
// Advance the pacing accumulator. If we fell badly behind
diff --git a/server/src/ledgrab/api/routes/game_integration.py b/server/src/ledgrab/api/routes/game_integration.py
index b07f7e5..40b06ea 100644
--- a/server/src/ledgrab/api/routes/game_integration.py
+++ b/server/src/ledgrab/api/routes/game_integration.py
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
import threading
import time
+from collections import defaultdict
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -57,6 +58,73 @@ _prev_states: dict[str, dict[str, Any]] = {}
_integration_stats: dict[str, dict[str, Any]] = {}
+# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
+#
+# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
+# rate-limit every event — that would throttle legitimate gameplay traffic.
+# Instead we throttle only FAILED-auth attempts per source IP (the only thing
+# an attacker without the token can produce). This mirrors the IP-based
+# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
+# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
+# while authenticated high-rate ingestion is completely unaffected.
+_AUTH_FAIL_LIMIT = 30
+_AUTH_FAIL_WINDOW = 60.0 # seconds
+_AUTH_FAIL_HITS_HARD_CAP = 1024
+_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
+_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
+_auth_fail_lock = threading.Lock()
+
+
+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.
+ """
+ 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
+
+
+def _check_auth_fail_rate_limit(client_ip: str) -> None:
+ """Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
+ now = time.time()
+ window_start = now - _AUTH_FAIL_WINDOW
+ with _auth_fail_lock:
+ timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
+ _auth_fail_hits[client_ip] = timestamps
+ if len(timestamps) >= _AUTH_FAIL_LIMIT:
+ raise HTTPException(
+ status_code=429,
+ detail="Too many failed authentication attempts. Try again later.",
+ )
+
+
+def _record_auth_failure(client_ip: str) -> None:
+ """Record a failed-auth attempt for *client_ip* (bounded memory)."""
+ now = time.time()
+ window_start = now - _AUTH_FAIL_WINDOW
+ with _auth_fail_lock:
+ _auth_fail_hits[client_ip].append(now)
+ # Periodic cleanup of stale IPs to prevent unbounded growth.
+ if len(_auth_fail_hits) > 100:
+ stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
+ for ip in stale:
+ del _auth_fail_hits[ip]
+ # Hard cap against an attacker spraying many distinct X-Forwarded-For
+ # values; drop the oldest-touched IPs.
+ if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
+ ordered = sorted(
+ _auth_fail_hits.items(),
+ key=lambda kv: kv[1][-1] if kv[1] else 0.0,
+ )
+ for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
+ _auth_fail_hits.pop(ip, None)
+
+
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert a JSON Schema object into a flat list of field descriptors.
@@ -387,7 +455,16 @@ async def ingest_event(
called before standard API auth.
No AuthRequired dependency — adapter-level auth is used instead.
+
+ Rate limiting is scoped to FAILED-auth attempts per source IP (see
+ ``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
+ never throttled, but a brute-forcer is locked out after the threshold.
"""
+ client_ip = _rate_limit_key(request)
+ # Block IPs that have already burned through the failed-auth budget,
+ # before doing any work (cheap brute-force lockout).
+ _check_auth_fail_rate_limit(client_ip)
+
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
@@ -405,6 +482,7 @@ async def ingest_event(
# Adapter-level auth check
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
+ _record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Adapter authentication failed")
# Parse payload through adapter
diff --git a/server/src/ledgrab/api/routes/home_assistant.py b/server/src/ledgrab/api/routes/home_assistant.py
index d982831..0ffeceb 100644
--- a/server/src/ledgrab/api/routes/home_assistant.py
+++ b/server/src/ledgrab/api/routes/home_assistant.py
@@ -2,6 +2,7 @@
import asyncio
import json
+from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.home_assistant_source import HomeAssistantSource
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.utils import get_logger
+from ledgrab.utils.net_classify import validate_lan_host
logger = get_logger(__name__)
@@ -37,6 +39,23 @@ router = APIRouter()
_REDACTED_TOKEN = "***"
+def _validate_ha_host(host: str | None) -> None:
+ """Reject literal public/link-local/metadata IPs for a HA source host.
+
+ HA sources are LAN-by-design (loopback + private ranges allowed), so we
+ gate the user-supplied ``host`` with the same shared classifier the LED
+ device providers use (``validate_lan_host``). The HA host is stored as
+ ``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
+ ``urlparse`` — which also handles bracketed IPv6 literals. Hostnames /
+ mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
+ on a literal public IP, which the callers translate to HTTP 400.
+ """
+ if not host:
+ return
+ bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
+ validate_lan_host(bare_host)
+
+
def _to_response(
source: HomeAssistantSource,
manager: HomeAssistantManager,
@@ -99,6 +118,7 @@ async def create_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
+ _validate_ha_host(data.host)
source = store.create_source(
name=data.name,
host=data.host,
@@ -153,6 +173,7 @@ async def update_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
+ _validate_ha_host(data.host)
source = store.update_source(
source_id,
name=data.name,
diff --git a/server/src/ledgrab/core/capture/calibration.py b/server/src/ledgrab/core/capture/calibration.py
index 2d3af4c..b29c112 100644
--- a/server/src/ledgrab/core/capture/calibration.py
+++ b/server/src/ledgrab/core/capture/calibration.py
@@ -824,6 +824,30 @@ def create_default_calibration(
right_count = max(1, right_count)
left_count = max(1, left_count)
+ # The max(1, ...) floors above can push the total above led_count for
+ # small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6).
+ # Trim the largest edge that stays >= 1 until the total matches exactly.
+ edge_order = ["bottom", "top", "right", "left"]
+ counts = {
+ "bottom": bottom_count,
+ "top": top_count,
+ "right": right_count,
+ "left": left_count,
+ }
+ overshoot = sum(counts.values()) - led_count
+ while overshoot > 0:
+ # Pick the largest edge that can still be reduced (stays >= 1).
+ trimmable = [e for e in edge_order if counts[e] > 1]
+ if not trimmable:
+ break
+ target_edge = max(trimmable, key=lambda e: counts[e])
+ counts[target_edge] -= 1
+ overshoot -= 1
+ bottom_count = counts["bottom"]
+ top_count = counts["top"]
+ right_count = counts["right"]
+ left_count = counts["left"]
+
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
diff --git a/server/src/ledgrab/core/devices/ddp_client.py b/server/src/ledgrab/core/devices/ddp_client.py
index 7c3ac41..d93e6af 100644
--- a/server/src/ledgrab/core/devices/ddp_client.py
+++ b/server/src/ledgrab/core/devices/ddp_client.py
@@ -154,6 +154,15 @@ class DDPClient:
all buses (observed in multi-bus setups). We reorder pixel channels
here so the hardware receives the correct byte order directly.
+ TODO(ddp-multibus): currently UNUSED — ``send_pixels_numpy`` (the hot
+ send path) does NOT call this, so the per-bus color-order config
+ captured by ``set_buses`` is never applied to outgoing pixels. This
+ is intentionally left in place (not deleted) because it encodes real
+ multi-bus handling: if a multi-bus WLED setup needs per-bus byte
+ reordering, wire this into ``send_pixels_numpy`` before the payload
+ view is built (note it allocates a copy, so only call it when
+ ``self._buses`` actually requires reordering).
+
Args:
pixel_array: (N, 3) uint8 numpy array in RGB order
diff --git a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py
index 4355b38..a63f54e 100644
--- a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py
+++ b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py
@@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter):
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
- return bool(actual_token and actual_token == expected_token)
+ if not actual_token:
+ return False
+ # Constant-time comparison to avoid a timing oracle.
+ return secrets.compare_digest(actual_token, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
diff --git a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py
index 0c1a1ba..35a170e 100644
--- a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py
+++ b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py
@@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter):
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
- return bool(actual_token and actual_token == expected_token)
+ if not actual_token:
+ return False
+ # Constant-time comparison to avoid a timing oracle.
+ return secrets.compare_digest(actual_token, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
diff --git a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py
index c699d3e..f875773 100644
--- a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py
+++ b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py
@@ -4,6 +4,7 @@ Allows users to define custom JSON path mappings via the adapter_config
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
"""
+import secrets
from typing import Any, ClassVar
from ledgrab.core.game_integration.base_adapter import GameAdapter
@@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter):
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
- """Validate auth using a configurable header token."""
+ """Validate auth using a configurable header token.
+
+ Secure-by-default: this adapter is explicitly network-facing
+ (it accepts unauthenticated HTTP POSTs from anywhere on the LAN),
+ so a missing/empty ``auth_token`` REJECTS the request rather than
+ accepting it. A token must be configured for ingestion to work.
+ """
expected_token = adapter_config.get("auth_token")
if not expected_token:
- # No auth configured
- return True
+ # No token configured — reject (secure-by-default for a
+ # network-facing adapter; an open webhook is a LAN attack surface).
+ return False
auth_header = adapter_config.get("auth_header", "Authorization")
actual_value = headers.get(auth_header, "")
@@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter):
if actual_value.startswith("Bearer "):
actual_value = actual_value[7:]
- return bool(actual_value and actual_value == expected_token)
+ if not actual_value:
+ return False
+
+ # Constant-time comparison to avoid a token-length/timing oracle.
+ return secrets.compare_digest(actual_value, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return generic webhook config schema."""
return {
"type": "object",
+ "required": ["auth_token"],
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
- "description": "Optional token for authenticating incoming webhooks.",
+ "description": (
+ "Required token for authenticating incoming webhooks. "
+ "Without it, ingestion is rejected (this adapter is "
+ "network-facing and secure-by-default)."
+ ),
},
"auth_header": {
"type": "string",
@@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter):
"HTTP POST requests with JSON payloads.\n\n"
"**Steps:**\n"
"1. Configure your event mappings above — map JSON paths to standard events\n"
- "2. Set an auth token (optional but recommended)\n"
+ "2. Set an auth token (REQUIRED — without it incoming webhooks are rejected)\n"
"3. Point your game/application to:\n"
" `POST http://:8080/api/v1/game-integrations//event`\n\n"
"**Mapping example:**\n"
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
- "**Auth:**\n"
+ "**Auth (required):**\n"
"- Set `Authorization: Bearer ` header in your webhook sender\n"
"- Or configure a custom auth header name in the adapter config\n"
+ "- A token is mandatory: this endpoint is reachable from the LAN, so\n"
+ " an unconfigured token rejects all incoming events.\n"
)
diff --git a/server/src/ledgrab/core/game_integration/mapping_adapter.py b/server/src/ledgrab/core/game_integration/mapping_adapter.py
index d59a2e6..808daaa 100644
--- a/server/src/ledgrab/core/game_integration/mapping_adapter.py
+++ b/server/src/ledgrab/core/game_integration/mapping_adapter.py
@@ -10,6 +10,7 @@ The MappingAdapter class is a concrete GameAdapter whose behavior is
entirely driven by the parsed YAML definition.
"""
+import secrets
import time
from pathlib import Path
from typing import Any
@@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter):
expected_key = "auth_token"
expected_value = adapter_config.get(expected_key, "")
actual_value = headers.get(header_name, "")
- return bool(expected_value and actual_value == expected_value)
+ if not (expected_value and actual_value):
+ return False
+ # Constant-time comparison to avoid a timing oracle.
+ return secrets.compare_digest(actual_value, expected_value)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
diff --git a/server/src/ledgrab/core/processing/composite_stream.py b/server/src/ledgrab/core/processing/composite_stream.py
index e3a5c9d..1f53b6d 100644
--- a/server/src/ledgrab/core/processing/composite_stream.py
+++ b/server/src/ledgrab/core/processing/composite_stream.py
@@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream):
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
+ # (src_len, target_n) -> (src_x, dst_x) cache for full-strip resizing
+ # (output reuses the preallocated self._resize_buf from _ensure_pool)
+ self._resize_linspace_cache: Dict[tuple, tuple] = {}
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream)
@@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream):
n_src = len(colors)
if n_src == target_n:
return colors
- src_x = np.linspace(0, 1, n_src)
- dst_x = np.linspace(0, 1, target_n)
+ # Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n)
+ # exactly like the zone path, so they are not reallocated every frame.
+ lkey = (n_src, target_n)
+ linspaces = self._resize_linspace_cache.get(lkey)
+ if linspaces is None:
+ linspaces = (np.linspace(0, 1, n_src), np.linspace(0, 1, target_n))
+ self._resize_linspace_cache[lkey] = linspaces
+ src_x, dst_x = linspaces
buf = self._resize_buf
for ch in range(3):
np.copyto(
diff --git a/server/src/ledgrab/core/processing/device_test_mode.py b/server/src/ledgrab/core/processing/device_test_mode.py
index 7a8227f..aa15226 100644
--- a/server/src/ledgrab/core/processing/device_test_mode.py
+++ b/server/src/ledgrab/core/processing/device_test_mode.py
@@ -137,16 +137,34 @@ class DeviceTestModeMixin:
await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None:
- """Send all-black pixels to clear LED output."""
+ """Send all-black pixels to clear LED output.
+
+ This is the explicit teardown path — unlike the per-frame
+ ``_send_pixels_to_device`` swallow, a clear that must actually take
+ effect retries once before giving up, so a single transient send
+ error doesn't leave the device lit after a session ends.
+ """
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
- await self._send_pixels_to_device(device_id, pixels)
+ try:
+ client = await self._get_idle_client(device_id)
+ await client.send_pixels(pixels)
+ except Exception as e:
+ logger.warning(f"Clear send to {device_id} failed, retrying once: {e}")
+ client = await self._get_idle_client(device_id)
+ await client.send_pixels(pixels)
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices).
+
+ Send failures are logged and swallowed (best-effort per-frame send).
+ Callers that need a *guaranteed* clear — e.g. session teardown that
+ must "never leave the device dark" — must NOT rely on this returning
+ cleanly; use ``_send_clear_pixels`` (which retries once) and treat a
+ propagated exception as a failed clear.
"""
try:
client = await self._get_idle_client(device_id)
diff --git a/server/src/ledgrab/core/processing/effect_stream.py b/server/src/ledgrab/core/processing/effect_stream.py
index 4b98069..e63b61c 100644
--- a/server/src/ledgrab/core/processing/effect_stream.py
+++ b/server/src/ledgrab/core/processing/effect_stream.py
@@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream):
# Use noise at very low frequency for blob movement
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
- # Two blob layers at different speeds for organic movement
+ # Two blob layers at different speeds for organic movement.
+ # fbm() returns a shared internal buffer that the next fbm() call
+ # overwrites, so each layer must be copied out — write into the
+ # preallocated scratch buffers instead of allocating per frame.
self._s_f32_a += t * speed * 0.1
- layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()
+ layer1 = self._s_layer1
+ np.copyto(layer1, self._noise.fbm(self._s_f32_a, octaves=3))
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
self._s_f32_a += t * speed * 0.07 + 100.0
- layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()
+ layer2 = self._s_layer2
+ np.copyto(layer2, self._noise.fbm(self._s_f32_a, octaves=2))
# Combine: create blob-like shapes with soft edges
combined = self._s_f32_a
diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py
index b820522..6bd0a59 100644
--- a/server/src/ledgrab/core/processing/wled_target_processor.py
+++ b/server/src/ledgrab/core/processing/wled_target_processor.py
@@ -739,10 +739,19 @@ class WledTargetProcessor(TargetProcessor):
self._last_preview_data = data
- async def _send_safe(ws):
+ # Bound each per-client send to roughly one frame interval so a slow or
+ # backpressured preview WebSocket can never throttle the device send
+ # cadence. Clients that time out or error are dropped from the set.
+ eff_fps = self._effective_fps if self._effective_fps > 0 else 30
+ send_timeout = 1.0 / eff_fps
+
+ async def _send_safe(ws) -> bool:
try:
- await ws.send_bytes(data)
+ await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout)
return True
+ except asyncio.TimeoutError:
+ logger.debug("LED preview broadcast WS send timed out (slow client dropped)")
+ return False
except Exception as e:
logger.debug("LED preview broadcast WS send failed: %s", e)
return False
diff --git a/server/src/ledgrab/core/scenes/playlist_engine.py b/server/src/ledgrab/core/scenes/playlist_engine.py
index 71a0763..1a401ef 100644
--- a/server/src/ledgrab/core/scenes/playlist_engine.py
+++ b/server/src/ledgrab/core/scenes/playlist_engine.py
@@ -138,22 +138,42 @@ class PlaylistEngine:
"""Stop the playlist only if ``playlist_id`` is the one running.
Used when a playlist is deleted or edited so a stale snapshot can't keep
- cycling.
+ cycling. The read-compare and the stop happen atomically under the
+ lifecycle lock so a concurrent natural-end / start can't slip a
+ different playlist in between the check and the stop.
"""
- if self._state is not None and self._state.playlist_id == playlist_id:
- await self.stop()
+ async with self._lifecycle_lock:
+ state = self._state
+ if state is None or state.playlist_id != playlist_id:
+ return
+ was_running = self._task is not None
+ await self._cancel_task()
+ stopped_id = self._state.playlist_id if self._state else playlist_id
+ self._state = None
+ if was_running:
+ self._fire_event("stopped", playlist_id=stopped_id)
+ logger.info("Playlist stopped")
# ===== Query API (used by routes) =====
def is_running(self) -> bool:
- return self._task is not None and not self._task.done()
+ # Snapshot the task ref so a concurrent clear (set to None at an await
+ # boundary) can't turn the deref into an attribute error mid-read.
+ task = self._task
+ return task is not None and not task.done()
def get_running_playlist_id(self) -> str | None:
- return self._state.playlist_id if self._state else None
+ state = self._state
+ return state.playlist_id if state else None
def get_state(self) -> dict:
- if self._state is not None and self.is_running():
- return self._state.to_dict()
+ # Snapshot both refs once: the event loop won't preempt this sync method
+ # between the reads, but snapshotting also guards against ever returning
+ # a half-cleared state (running True while _state is already None).
+ state = self._state
+ task = self._task
+ if state is not None and task is not None and not task.done():
+ return state.to_dict()
return dict(_IDLE_STATE)
# ===== Internal =====
@@ -201,13 +221,25 @@ class PlaylistEngine:
break
# Natural end (non-loop or guard). Clear state without recursing
- # through stop() (which would try to cancel this very task). Guard
- # against a concurrent start_playlist having already replaced us:
- # only clear if we are still the engine's current task.
- if self._task is asyncio.current_task():
- self._task = None
- ended_id = self._state.playlist_id if self._state else None
- self._state = None
+ # through stop() (which would try to cancel this very task). Take
+ # the lifecycle lock so the clear + 'stopped' event are atomic with
+ # respect to a concurrent start/stop/stop_if_running — otherwise the
+ # two could interleave and emit duplicate or contradictory terminal
+ # events. _run never calls _cancel_task and never otherwise holds
+ # this lock, so acquiring it here cannot deadlock; if a canceller
+ # holding the lock cancels us while we wait to acquire it, the
+ # acquire raises CancelledError and we fall through to the handler.
+ ended_id = None
+ should_fire = False
+ async with self._lifecycle_lock:
+ # Re-check under the lock: a concurrent start_playlist may have
+ # replaced us while we waited. Only clear if we're still current.
+ if self._task is asyncio.current_task():
+ self._task = None
+ ended_id = self._state.playlist_id if self._state else None
+ self._state = None
+ should_fire = True
+ if should_fire:
self._fire_event("stopped", playlist_id=ended_id)
logger.info("Playlist '%s' finished", playlist_id)
except asyncio.CancelledError:
diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py
index a28402b..2a1b08c 100644
--- a/server/src/ledgrab/main.py
+++ b/server/src/ledgrab/main.py
@@ -453,6 +453,13 @@ async def lifespan(app: FastAPI):
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
+ # Tear down any active calibration session BEFORE stop_all so the device
+ # isn't left stuck in the white-chase and its prior target is restored.
+ # stop() is a no-op when no session is active.
+ from ledgrab.core.capture.calibration_session import get_calibration_session
+
+ await _bounded("calibration_session.stop", get_calibration_session().stop(), timeout=1.0)
+
# Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None:
diff --git a/server/src/ledgrab/static/css/calibration.css b/server/src/ledgrab/static/css/calibration.css
index b4c5b3c..6cf8f99 100644
--- a/server/src/ledgrab/static/css/calibration.css
+++ b/server/src/ledgrab/static/css/calibration.css
@@ -79,6 +79,12 @@
opacity: 1;
}
+.preview-screen-total:focus-visible {
+ opacity: 1;
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
.preview-screen-total.mismatch {
color: #FFC107;
}
@@ -123,6 +129,12 @@
background: rgba(128, 128, 128, 0.25);
}
+.edge-toggle:focus-visible {
+ background: rgba(128, 128, 128, 0.25);
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
.preview-edge.edge-disabled {
opacity: 0.25;
pointer-events: none;
@@ -374,6 +386,13 @@
color: rgba(76, 175, 80, 0.6);
}
+.preview-corner:focus-visible {
+ color: rgba(76, 175, 80, 0.6);
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
.preview-corner.active:hover {
transform: none;
}
@@ -412,6 +431,12 @@
background: rgba(255, 255, 255, 0.25);
}
+.direction-toggle:focus-visible {
+ background: rgba(255, 255, 255, 0.25);
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
.direction-toggle #direction-icon {
font-size: 14px;
}
diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css
index 2f390dc..d1f8bb5 100644
--- a/server/src/ledgrab/static/css/components.css
+++ b/server/src/ledgrab/static/css/components.css
@@ -328,7 +328,7 @@ select.field-invalid {
display: block;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
- background: var(--surface-2, color-mix(in srgb, var(--text-color) 6%, var(--bg-color)));
+ background: var(--lux-bg-2);
overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@@ -396,10 +396,10 @@ select.field-invalid {
/* Token palette — restrained, three accents plus muted operators. */
.jinja-hl .tok-str { color: var(--success-color); }
-.jinja-hl .tok-num { color: #d19a66; }
+.jinja-hl .tok-num { color: var(--ch-amber); }
.jinja-hl .tok-fn { color: var(--primary-color); font-weight: 600; }
-.jinja-hl .tok-raw { color: #c678dd; font-style: italic; }
-.jinja-hl .tok-var { color: #61afef; }
+.jinja-hl .tok-raw { color: var(--ch-violet); font-style: italic; }
+.jinja-hl .tok-var { color: var(--ch-cyan); }
.jinja-hl .tok-op { color: var(--text-muted); }
/* ── Template-input rows ─────────────────────────────────────── */
@@ -496,8 +496,8 @@ select.field-invalid {
font-size: 0.76rem;
padding: 1px 6px;
border-radius: var(--radius-pill);
- background: color-mix(in srgb, #61afef 16%, transparent);
- color: #61afef;
+ background: color-mix(in srgb, var(--ch-cyan) 16%, transparent);
+ color: var(--ch-cyan);
}
.jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; }
@@ -918,7 +918,12 @@ input:-webkit-autofill:focus {
.discovery-item:focus-visible,
.tag-chip-remove:focus-visible,
.cs-filter-reset:focus-visible,
-.graph-filter-clear:focus-visible {
+.graph-filter-clear:focus-visible,
+.wizard-discovery-item:focus-visible,
+.wizard-display-item:focus-visible,
+.autocal-corner-btn:focus-visible,
+.autocal-direction-btn:focus-visible,
+.scene-target-add-slot:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
@@ -1118,7 +1123,8 @@ textarea:focus-visible {
font-weight: 700;
line-height: 1;
}
-.scene-target-add-slot:hover:not(:disabled) {
+.scene-target-add-slot:hover:not(:disabled),
+.scene-target-add-slot:focus-visible:not(:disabled) {
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
@@ -1625,14 +1631,14 @@ textarea:focus-visible {
padding: 6px 12px;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
- background: var(--surface-2, #1e1e2e);
+ background: var(--lux-bg-2);
color: var(--text-secondary, #999);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.type-picker-tab:hover {
- background: var(--surface-3, #2a2a3e);
+ background: var(--lux-bg-3);
color: var(--text-primary, #e0e0e0);
}
.type-picker-tab.active {
@@ -2326,7 +2332,7 @@ textarea:focus-visible {
.autocal-step-desc {
font-size: 0.85rem;
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
@@ -2355,7 +2361,8 @@ textarea:focus-visible {
font-weight: 500;
text-align: center;
}
-.autocal-corner-btn:hover {
+.autocal-corner-btn:hover,
+.autocal-corner-btn:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
@@ -2424,7 +2431,8 @@ textarea:focus-visible {
font-weight: 500;
text-align: center;
}
-.autocal-direction-btn:hover {
+.autocal-direction-btn:hover,
+.autocal-direction-btn:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
@@ -2442,7 +2450,7 @@ textarea:focus-visible {
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
border-radius: var(--radius-sm, 6px);
font-size: 0.8rem;
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
}
.autocal-led-dot {
@@ -2489,7 +2497,7 @@ textarea:focus-visible {
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
transition: border-color 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0;
}
@@ -2618,7 +2626,7 @@ textarea:focus-visible {
}
.autocal-solved-key {
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
flex-shrink: 0;
min-width: 68px;
}
@@ -2720,7 +2728,7 @@ textarea:focus-visible {
font-size: 0.7rem;
font-weight: 700;
background: var(--bg-secondary, var(--bg-2, #2a2a2a));
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
border: 1.5px solid var(--border-color);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
@@ -2775,7 +2783,7 @@ textarea:focus-visible {
}
.wizard-step-desc {
font-size: 0.85rem;
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
@@ -2829,7 +2837,7 @@ textarea:focus-visible {
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
padding-bottom: 4px;
}
.wizard-section-label--scan {
@@ -2860,7 +2868,7 @@ textarea:focus-visible {
gap: 10px;
padding: 14px 12px;
font-size: 0.85rem;
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
@@ -2868,7 +2876,7 @@ textarea:focus-visible {
.wizard-discovery-empty {
padding: 14px 12px;
font-size: 0.85rem;
- color: var(--text-muted, var(--secondary-text-color));
+ color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
@@ -2891,7 +2899,8 @@ textarea:focus-visible {
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
-.wizard-discovery-item:hover {
+.wizard-discovery-item:hover,
+.wizard-discovery-item:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
@@ -2899,7 +2908,7 @@ textarea:focus-visible {
.wizard-discovery-icon .icon { width: 20px; height: 20px; }
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-badge {
font-size: 0.68rem;
font-weight: 700;
@@ -2926,7 +2935,8 @@ textarea:focus-visible {
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
-.wizard-display-item:hover {
+.wizard-display-item:hover,
+.wizard-display-item:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
@@ -2938,7 +2948,7 @@ textarea:focus-visible {
.wizard-display-icon .icon { width: 20px; height: 20px; }
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); }
-.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); }
+.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted); }
.wizard-display-check { color: var(--primary-color); }
.wizard-display-check .icon { width: 16px; height: 16px; }
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
@@ -2953,7 +2963,7 @@ textarea:focus-visible {
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
-.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); }
+.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted); }
/* Calibrate container */
.wizard-calibrate-container {
@@ -2990,7 +3000,7 @@ textarea:focus-visible {
padding: 12px 16px;
}
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; }
-.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); }
+.wizard-done-label { color: var(--text-muted); }
.wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
/* Wizard form rows */
diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css
index 8ca6cd2..718b500 100644
--- a/server/src/ledgrab/static/css/modal.css
+++ b/server/src/ledgrab/static/css/modal.css
@@ -2512,7 +2512,11 @@
padding-top: 8px;
}
-.error-message {
+/* `.modal-error` is the convention recommended in contexts/frontend.md and is
+ used by several modals; it aliases `.error-message` so both render the same
+ inline error banner. */
+.error-message,
+.modal-error {
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
border: 1px solid var(--danger-color);
color: var(--danger-color);
diff --git a/server/src/ledgrab/static/js/features/auto-calibration.ts b/server/src/ledgrab/static/js/features/auto-calibration.ts
index ca583c8..d64a908 100644
--- a/server/src/ledgrab/static/js/features/auto-calibration.ts
+++ b/server/src/ledgrab/static/js/features/auto-calibration.ts
@@ -98,6 +98,9 @@ export interface AutoCalOptions {
let _state: AutoCalState | null = null;
let _opts: AutoCalOptions | null = null;
let _deviceEntitySelect: EntitySelect | null = null;
+/** First render after mount: let the containing Modal's autofocus win; only
+ * steal focus on subsequent step transitions (for D-pad / TV navigation). */
+let _firstRender = true;
// ── Public API ─────────────────────────────────────────────────────────────
@@ -119,6 +122,7 @@ let _deviceEntitySelect: EntitySelect | null = null;
*/
export async function mountAutoCalibration(opts: AutoCalOptions): Promise {
await unmountAutoCalibration();
+ _firstRender = true;
_opts = opts;
let cssSourceType = 'picture';
@@ -177,6 +181,27 @@ function _render(): void {
case 'corners': _renderCorners(); break;
case 'preview': _renderPreview(); break;
}
+ _focusFirstControl();
+}
+
+/**
+ * Move focus to the step's first focusable control after a re-render so D-pad /
+ * TV WebView users don't land on a node that was just removed. Skipped on the
+ * very first render so it doesn't fight the Modal's own initial autofocus.
+ */
+function _focusFirstControl(): void {
+ if (_firstRender) {
+ _firstRender = false;
+ return;
+ }
+ const container = _opts?.container;
+ if (!container) return;
+ requestAnimationFrame(() => {
+ const el = container.querySelector(
+ 'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
+ ) as HTMLElement | null;
+ if (el && el.offsetParent !== null) el.focus();
+ });
}
// ── Step 1: Device picker ──────────────────────────────────────────────────
@@ -309,10 +334,15 @@ export async function autoCalSetCorner(position: StartPosition): Promise {
// LED is at index 0; advance to ~5% to show movement direction
await _setPosition(0);
await _delay(350);
+ // User may have cancelled during the delay (unmount sets _state = null).
+ if (!_state) return;
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
await _setPosition(advance);
+ if (!_state) return;
_state.busy = false;
} catch (err: unknown) {
+ // _state may have been torn down mid-await by a cancel.
+ if (!_state) return;
_state.busy = false;
_state.errorMsg = _errMsg(err);
_state.step = 'corner'; // revert on error
diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts
index 63545d3..d9d578c 100644
--- a/server/src/ledgrab/static/js/features/calibration.ts
+++ b/server/src/ledgrab/static/js/features/calibration.ts
@@ -64,6 +64,14 @@ class CalibrationModal extends Modal {
if (testGroup) testGroup.style.display = 'none';
const testSection = document.getElementById('calibration-test-setup-section');
if (testSection) testSection.style.display = 'none';
+ // Destroy the test-device EntitySelect; otherwise the instance leaks and
+ // leaves a stale enhanced trigger button in the DOM until the next open.
+ if (_calTestDeviceEntitySelect) {
+ _calTestDeviceEntitySelect.destroy();
+ _calTestDeviceEntitySelect = null;
+ }
+ const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
+ if (testDeviceSelect) testDeviceSelect.onchange = null;
} else {
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
if (deviceId) clearTestMode(deviceId);
diff --git a/server/src/ledgrab/static/js/features/color-strips/test.ts b/server/src/ledgrab/static/js/features/color-strips/test.ts
index aca20c0..23529b0 100644
--- a/server/src/ledgrab/static/js/features/color-strips/test.ts
+++ b/server/src/ledgrab/static/js/features/color-strips/test.ts
@@ -151,6 +151,15 @@ let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
let _cssTestMeta: any = null;
+// Set true when a WS frame updates the latest RGB / layer data; the render loop
+// only repaints when a new frame has arrived, then clears it. Avoids re-rendering
+// the same frame every RAF tick on low-powered devices (e.g. Android TV WebView).
+let _cssTestFrameDirty: boolean = false;
+// Per-canvas render caches: reuse the 2d context and a single ImageData buffer
+// instead of calling getContext()/createImageData() and resetting width/height
+// every frame. ImageData is rebuilt only when the canvas LED count changes.
+const _cssTestCtxCache = new WeakMap();
+const _cssTestImageDataCache = new WeakMap();
let _cssTestSourceId: string | null = null;
let _cssTestIsComposite: boolean = false;
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
@@ -234,6 +243,7 @@ function _testKeyColorsSource(sourceId: string) {
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
+ _cssTestFrameDirty = false;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
@@ -414,6 +424,7 @@ function _openTestModal(sourceId: string) {
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
+ _cssTestFrameDirty = false;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
@@ -636,6 +647,8 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
// Standard format: raw RGB
_cssTestLatestRgb = raw;
}
+ // Mark a new frame so the render loop repaints on the next RAF tick.
+ _cssTestFrameDirty = true;
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
@@ -756,6 +769,7 @@ export function applyCssTestSettings() {
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
+ _cssTestFrameDirty = false;
// Read selected input source from selector (both CSS and CSPT modes)
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
@@ -774,6 +788,9 @@ export function applyCssTestSettings() {
function _cssTestRenderLoop() {
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
if (!_cssTestMeta) return;
+ // Skip repaint when no new WS frame has arrived since the last render.
+ if (!_cssTestFrameDirty) return;
+ _cssTestFrameDirty = false;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
@@ -786,14 +803,40 @@ function _cssTestRenderLoop() {
}
}
+// Returns a cached 2d context and a reusable ImageData for the canvas, only
+// resizing the canvas / rebuilding the ImageData when the requested dimensions
+// differ from the cached ones. Returns null if a 2d context is unavailable.
+function _cssTestAcquireCanvas(
+ canvas: HTMLCanvasElement,
+ w: number,
+ h: number,
+): { ctx: CanvasRenderingContext2D; imageData: ImageData } | null {
+ let ctx = _cssTestCtxCache.get(canvas);
+ if (!ctx) {
+ const got = canvas.getContext('2d');
+ if (!got) return null;
+ ctx = got;
+ _cssTestCtxCache.set(canvas, ctx);
+ }
+ // Only touch width/height when they actually change (resetting them clears
+ // the backing store and is expensive).
+ if (canvas.width !== w) canvas.width = w;
+ if (canvas.height !== h) canvas.height = h;
+ let cached = _cssTestImageDataCache.get(canvas);
+ if (!cached || cached.w !== w || cached.h !== h) {
+ cached = { w, h, imageData: ctx.createImageData(w, h) };
+ _cssTestImageDataCache.set(canvas, cached);
+ }
+ return { ctx, imageData: cached.imageData };
+}
+
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ledCount = rgbBytes.length / 3;
- canvas.width = ledCount;
- canvas.height = 1;
- const ctx = canvas.getContext('2d')!;
- const imageData = ctx.createImageData(ledCount, 1);
+ const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
+ if (!acquired) return;
+ const { ctx, imageData } = acquired;
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
@@ -824,10 +867,9 @@ function _cssTestRenderLayers(data: any) {
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return;
- canvas.width = ledCount;
- canvas.height = 1;
- const ctx = canvas.getContext('2d')!;
- const imageData = ctx.createImageData(ledCount, 1);
+ const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
+ if (!acquired) return;
+ const { ctx, imageData } = acquired;
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
@@ -852,13 +894,17 @@ function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
if (!canvas) continue;
const count = indices.length;
- if (count === 0) { canvas.width = 0; continue; }
+ if (count === 0) {
+ if (canvas.width !== 0) canvas.width = 0;
+ continue;
+ }
const isH = edge === 'top' || edge === 'bottom';
- canvas.width = isH ? count : 1;
- canvas.height = isH ? 1 : count;
- const ctx = canvas.getContext('2d')!;
- const imageData = ctx.createImageData(canvas.width, canvas.height);
+ const w = isH ? count : 1;
+ const h = isH ? 1 : count;
+ const acquired = _cssTestAcquireCanvas(canvas, w, h);
+ if (!acquired) continue;
+ const { ctx, imageData } = acquired;
const px = imageData.data;
for (let i = 0; i < count; i++) {
const si = indices[i] * 3;
@@ -1185,6 +1231,7 @@ export function closeTestCssSourceModal() {
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
+ _cssTestFrameDirty = false;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
diff --git a/server/src/ledgrab/static/js/features/setup-wizard.ts b/server/src/ledgrab/static/js/features/setup-wizard.ts
index a218390..3cc05ea 100644
--- a/server/src/ledgrab/static/js/features/setup-wizard.ts
+++ b/server/src/ledgrab/static/js/features/setup-wizard.ts
@@ -76,6 +76,9 @@ interface WizardState {
let _state: WizardState | null = null;
let _modal: SetupWizardModal | null = null;
+/** First render after open: let the Modal's own autofocus win; only steal focus
+ * on subsequent step transitions (for D-pad / TV navigation). */
+let _firstRender = true;
const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done'];
@@ -95,6 +98,7 @@ class SetupWizardModal extends Modal {
/** Open the wizard (first-run or on-demand). */
export function openSetupWizard(): void {
if (!_modal) _modal = new SetupWizardModal();
+ _firstRender = true;
_state = {
step: 'welcome',
deviceId: '',
@@ -201,8 +205,18 @@ export async function wizardNext(): Promise {
_renderStep();
await _runScaffold();
} else if (step === 'calibrate') {
- // "Skip calibration" path — move to start
- void unmountAutoCalibration();
+ // "Skip calibration" path — move to start.
+ // Mark busy BEFORE the await so a fast second tap on Skip/Next can't
+ // re-enter wizardNext (gated at the top) and fire a second teardown +
+ // start during the up-to-10s unmount await. _startOutput() manages
+ // busy itself afterward.
+ _state.busy = true;
+ _renderStep();
+ // Await teardown first: unmountAutoCalibration() POSTs session/stop, which
+ // restores the device's prior target. Starting output before that completes
+ // would let the stop clobber the just-started target.
+ await unmountAutoCalibration();
+ if (!_state) return;
_state.step = 'start';
_renderStep();
await _startOutput();
@@ -361,12 +375,15 @@ async function _loadDisplays(): Promise {
}
}
-export function wizardSelectDisplay(index: number, displayName: string): void {
+export function wizardSelectDisplay(index: number, displayName: string, skipRender = false): void {
if (!_state) return;
_state.displayIndex = index;
_state.displayName = displayName;
_state.errorMsg = '';
- _renderStep();
+ // The manual display-index fallback passes skipRender=true: a full
+ // _renderStep() rebuilds the container via innerHTML, destroying the input
+ // and losing focus/caret on every keystroke (unusable on Android TV WebView).
+ if (!skipRender) _renderStep();
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -469,6 +486,25 @@ function _renderStep(): void {
const html = _buildStepHtml(_state);
container.innerHTML = html;
_attachStepListeners(_state.step);
+ _focusFirstControl(container);
+}
+
+/**
+ * Move focus to the step's first focusable control after a re-render so D-pad /
+ * TV WebView users don't land on a node that was just removed. Skipped on the
+ * very first render so it doesn't fight the Modal's own initial autofocus.
+ */
+function _focusFirstControl(container: HTMLElement): void {
+ if (_firstRender) {
+ _firstRender = false;
+ return;
+ }
+ requestAnimationFrame(() => {
+ const el = container.querySelector(
+ 'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
+ ) as HTMLElement | null;
+ if (el && el.offsetParent !== null) el.focus();
+ });
}
function _renderProgressBar(): void {
@@ -654,7 +690,7 @@ function _buildDisplayStep(state: WizardState): string {
+ oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value, true)">
`;
} else {
diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json
index 33d6e27..a36cfec 100644
--- a/server/src/ledgrab/static/locales/en.json
+++ b/server/src/ledgrab/static/locales/en.json
@@ -645,6 +645,16 @@
"calibration.roi.width": "Width (%)",
"calibration.roi.height": "Height (%)",
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
+ "calibration.edge.top": "Toggle top edge test LEDs",
+ "calibration.edge.right": "Toggle right edge test LEDs",
+ "calibration.edge.bottom": "Toggle bottom edge test LEDs",
+ "calibration.edge.left": "Toggle left edge test LEDs",
+ "calibration.corner.top_left": "Set start position: top left",
+ "calibration.corner.top_right": "Set start position: top right",
+ "calibration.corner.bottom_right": "Set start position: bottom right",
+ "calibration.corner.bottom_left": "Set start position: bottom left",
+ "calibration.direction.toggle": "Toggle direction",
+ "calibration.edge_inputs.toggle": "Toggle edge LED inputs",
"calibration.button.cancel": "Cancel",
"calibration.button.save": "Save",
"calibration.saved": "Calibration saved",
diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json
index 4f5047c..f907246 100644
--- a/server/src/ledgrab/static/locales/ru.json
+++ b/server/src/ledgrab/static/locales/ru.json
@@ -702,6 +702,16 @@
"calibration.roi.width": "Ширина (%)",
"calibration.roi.height": "Высота (%)",
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
+ "calibration.edge.top": "Переключить тестовые светодиоды верхнего края",
+ "calibration.edge.right": "Переключить тестовые светодиоды правого края",
+ "calibration.edge.bottom": "Переключить тестовые светодиоды нижнего края",
+ "calibration.edge.left": "Переключить тестовые светодиоды левого края",
+ "calibration.corner.top_left": "Задать начальную позицию: верхний левый угол",
+ "calibration.corner.top_right": "Задать начальную позицию: верхний правый угол",
+ "calibration.corner.bottom_right": "Задать начальную позицию: нижний правый угол",
+ "calibration.corner.bottom_left": "Задать начальную позицию: нижний левый угол",
+ "calibration.direction.toggle": "Переключить направление",
+ "calibration.edge_inputs.toggle": "Переключить поля ввода светодиодов по краям",
"calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка сохранена",
diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json
index 66097cb..2058c38 100644
--- a/server/src/ledgrab/static/locales/zh.json
+++ b/server/src/ledgrab/static/locales/zh.json
@@ -698,6 +698,16 @@
"calibration.roi.width": "宽度 (%)",
"calibration.roi.height": "高度 (%)",
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)",
+ "calibration.edge.top": "切换顶部边缘测试 LED",
+ "calibration.edge.right": "切换右侧边缘测试 LED",
+ "calibration.edge.bottom": "切换底部边缘测试 LED",
+ "calibration.edge.left": "切换左侧边缘测试 LED",
+ "calibration.corner.top_left": "设置起始位置:左上角",
+ "calibration.corner.top_right": "设置起始位置:右上角",
+ "calibration.corner.bottom_right": "设置起始位置:右下角",
+ "calibration.corner.bottom_left": "设置起始位置:左下角",
+ "calibration.direction.toggle": "切换方向",
+ "calibration.edge_inputs.toggle": "切换边缘 LED 输入",
"calibration.button.cancel": "取消",
"calibration.button.save": "保存",
"calibration.saved": "校准已保存",
diff --git a/server/src/ledgrab/storage/game_integration.py b/server/src/ledgrab/storage/game_integration.py
index 080b4d9..3669e2b 100644
--- a/server/src/ledgrab/storage/game_integration.py
+++ b/server/src/ledgrab/storage/game_integration.py
@@ -10,6 +10,48 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, List
+from ledgrab.utils import secret_box
+
+# Keys inside ``adapter_config`` that hold secrets and must be encrypted at
+# rest (and decrypted on load). Mirrors the secret_box pattern used by
+# storage/http_endpoint.py for its ``auth_token`` field.
+_SECRET_CONFIG_KEYS = ("auth_token",)
+
+
+def _encrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
+ """Return a copy of *adapter_config* with secret values encrypted.
+
+ ``secret_box.encrypt`` is a no-op on already-encrypted envelopes, so this
+ is safe to call repeatedly and on configs that were never plaintext.
+ """
+ result = dict(adapter_config)
+ for key in _SECRET_CONFIG_KEYS:
+ value = result.get(key)
+ if isinstance(value, str) and value:
+ result[key] = secret_box.encrypt(value)
+ return result
+
+
+def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
+ """Return a copy of *adapter_config* with secret values decrypted.
+
+ Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it
+ is not an encryption envelope, so legacy plaintext rows still load. On a
+ corrupt/undecryptable envelope, fall back to dropping the value rather than
+ crashing the whole store load.
+ """
+ result = dict(adapter_config)
+ for key in _SECRET_CONFIG_KEYS:
+ value = result.get(key)
+ if isinstance(value, str) and value:
+ if secret_box.is_encrypted(value):
+ try:
+ result[key] = secret_box.decrypt(value)
+ except Exception:
+ result[key] = ""
+ # else: legacy plaintext — leave as-is (migration-safe read path).
+ return result
+
@dataclass
class EventMapping:
@@ -89,7 +131,8 @@ class GameIntegrationConfig:
"name": self.name,
"adapter_type": self.adapter_type,
"enabled": self.enabled,
- "adapter_config": dict(self.adapter_config),
+ # Encrypt secret values (e.g. webhook auth_token) at rest.
+ "adapter_config": _encrypt_adapter_config(self.adapter_config),
"event_mappings": [m.to_dict() for m in self.event_mappings],
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
@@ -110,7 +153,8 @@ class GameIntegrationConfig:
name=data["name"],
adapter_type=data["adapter_type"],
enabled=data.get("enabled", True),
- adapter_config=data.get("adapter_config", {}),
+ # Decrypt secret values; tolerant of legacy-plaintext rows.
+ adapter_config=_decrypt_adapter_config(data.get("adapter_config", {})),
event_mappings=mappings,
created_at=(
datetime.fromisoformat(data["created_at"])
diff --git a/server/src/ledgrab/templates/modals/calibration.html b/server/src/ledgrab/templates/modals/calibration.html
index 2e45d5d..0f2b5ab 100644
--- a/server/src/ledgrab/templates/modals/calibration.html
+++ b/server/src/ledgrab/templates/modals/calibration.html
@@ -56,10 +56,10 @@
-
-
+
@@ -170,13 +170,13 @@
-
+
?
Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don't pollute the border colours. Full frame = X/Y 0, Width/Height 100.
-
+
diff --git a/server/tests/api/routes/test_game_integration_routes.py b/server/tests/api/routes/test_game_integration_routes.py
index 542a2e8..7b293ad 100644
--- a/server/tests/api/routes/test_game_integration_routes.py
+++ b/server/tests/api/routes/test_game_integration_routes.py
@@ -341,6 +341,64 @@ class TestEventIngestion:
assert len(recent) == 1
+# ---------------------------------------------------------------------------
+# Failed-auth rate limiting (brute-force defence on the ingest route)
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def _reset_auth_fail_limiter():
+ """Clear the module-level failed-auth hit map before and after each test.
+
+ The limiter keeps per-IP state in a process-global dict, so without this
+ reset, attempts from earlier tests would bleed into later ones.
+ """
+ from ledgrab.api.routes import game_integration as gi
+
+ gi._auth_fail_hits.clear()
+ yield
+ gi._auth_fail_hits.clear()
+
+
+class TestIngestRateLimiting:
+ def test_failed_auth_attempts_are_rate_limited(self, client, _reset_auth_fail_limiter):
+ from ledgrab.api.routes import game_integration as gi
+
+ created = _create_integration(
+ client,
+ adapter_config={"auth_token": "correct_token"},
+ )
+ integration_id = created["id"]
+ url = f"/api/v1/game-integrations/{integration_id}/event"
+ bad = {"x-auth-token": "wrong_token"}
+
+ # Burn through the failed-auth budget — each returns 403.
+ for _ in range(gi._AUTH_FAIL_LIMIT):
+ resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
+ assert resp.status_code == 403
+
+ # The next attempt from the same IP is throttled with 429.
+ resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
+ assert resp.status_code == 429
+
+ def test_successful_ingest_not_rate_limited(self, client, event_bus, _reset_auth_fail_limiter):
+ from ledgrab.api.routes import game_integration as gi
+
+ created = _create_integration(
+ client,
+ adapter_config={"auth_token": "correct_token"},
+ )
+ integration_id = created["id"]
+ url = f"/api/v1/game-integrations/{integration_id}/event"
+ good = {"x-auth-token": "correct_token"}
+
+ # High-rate legitimate ingestion well past the failed-auth threshold
+ # must NOT be throttled — only failures count toward the limit.
+ for _ in range(gi._AUTH_FAIL_LIMIT + 10):
+ resp = client.post(url, json={"data": {"health": 50}}, headers=good)
+ assert resp.status_code == 204
+
+
# ---------------------------------------------------------------------------
# Status / diagnostics tests
# ---------------------------------------------------------------------------
diff --git a/server/tests/core/test_generic_webhook_adapter.py b/server/tests/core/test_generic_webhook_adapter.py
index 5e3544a..aaec6e4 100644
--- a/server/tests/core/test_generic_webhook_adapter.py
+++ b/server/tests/core/test_generic_webhook_adapter.py
@@ -102,9 +102,20 @@ class TestGenericWebhookParsing:
class TestGenericWebhookAuth:
- def test_no_auth_configured(self) -> None:
+ def test_no_auth_configured_rejects(self) -> None:
+ # Secure-by-default: a network-facing webhook adapter with no token
+ # configured must REJECT, not accept (otherwise it is open to the LAN).
result = GenericWebhookAdapter.validate_auth({}, {}, {})
- assert result is True
+ assert result is False
+
+ def test_empty_token_rejects(self) -> None:
+ # An explicitly empty token is still "no token" → reject.
+ result = GenericWebhookAdapter.validate_auth(
+ {"Authorization": "Bearer "},
+ {},
+ {"auth_token": ""},
+ )
+ assert result is False
def test_bearer_auth_valid(self) -> None:
result = GenericWebhookAdapter.validate_auth(
@@ -156,6 +167,8 @@ class TestGenericWebhookMetadata:
assert "auth_token" in schema["properties"]
assert "mappings" in schema["properties"]
assert "auth_header" in schema["properties"]
+ # auth_token is mandatory (secure-by-default).
+ assert "auth_token" in schema.get("required", [])
def test_setup_instructions(self) -> None:
instructions = GenericWebhookAdapter.get_setup_instructions()
diff --git a/server/tests/core/test_playlist_engine.py b/server/tests/core/test_playlist_engine.py
index 6c9c039..e0b8bb7 100644
--- a/server/tests/core/test_playlist_engine.py
+++ b/server/tests/core/test_playlist_engine.py
@@ -315,3 +315,175 @@ class TestEvents:
async def test_stop_when_idle_fires_nothing(self, engine):
await engine.stop()
assert _fired_actions(engine) == []
+
+
+# ---------------------------------------------------------------------------
+# Concurrency — delete-mid-play race (H5). The engine reads/mutates _task and
+# _state across await boundaries; a deletion firing stop_if_running() while the
+# _run loop is between its get_playlist re-read and the natural-end clear must
+# still produce exactly ONE terminal 'stopped' transition (no duplicate /
+# contradictory WS events), and get_state() must never raise nor return a
+# half-cleared snapshot during the window.
+# ---------------------------------------------------------------------------
+
+
+class _DeletableStore:
+ """Wraps a real playlist store; ``delete_playlist`` records a tombstone so
+ a later ``get_playlist`` re-read raises (like the real store after a delete)
+ and the engine's ``_run`` loop breaks out at the cycle boundary.
+ """
+
+ def __init__(self, real):
+ self._real = real
+ self._deleted: set[str] = set()
+
+ def get_playlist(self, playlist_id):
+ if playlist_id in self._deleted:
+ raise KeyError(playlist_id)
+ return self._real.get_playlist(playlist_id)
+
+ def delete_playlist(self, playlist_id):
+ self._deleted.add(playlist_id)
+
+
+class TestConcurrencyDeleteRace:
+ async def test_delete_and_stop_if_running_emit_single_stopped(
+ self, engine, playlist_store, applied
+ ):
+ # A looping playlist so _run re-reads get_playlist every cycle, giving
+ # us a deterministic parking point to interleave the delete + stop.
+ pl = _make_playlist(playlist_store, "Racy", [("scene_a", 50), ("scene_b", 50)], loop=True)
+
+ gated = _DeletableStore(playlist_store)
+ engine._playlist_store = gated
+
+ # The dwell sleep is the engine's only await between the get_playlist
+ # re-read and the natural-end clear. We gate the FIRST dwell: when _run
+ # parks there we set `entered`, then await `release`. While _run is
+ # suspended the test deletes the playlist and kicks stop_if_running, so
+ # they race _run's resumed natural-end clear under the lifecycle lock.
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ first_dwell = {"done": False}
+
+ async def _interleaving_sleep(_duration):
+ if not first_dwell["done"]:
+ first_dwell["done"] = True
+ entered.set()
+ await release.wait()
+ await _REAL_SLEEP(0.005)
+
+ with patch("asyncio.sleep", _interleaving_sleep):
+ await engine.start_playlist(pl.id)
+
+ # Wait until _run is parked inside the first dwell (mid-cycle).
+ await asyncio.wait_for(entered.wait(), timeout=2.0)
+
+ # get_state during the window must not raise and must be coherent:
+ # either fully running or fully idle — never half-cleared.
+ snap = engine.get_state()
+ assert isinstance(snap, dict)
+ assert "is_running" in snap
+
+ # Concurrently: delete from the store AND call stop_if_running for
+ # the same id. Kick stop_if_running first as a task so it contends
+ # for the lifecycle lock with the (soon to resume) _run natural-end.
+ gated.delete_playlist(pl.id)
+ stop_task = asyncio.create_task(engine.stop_if_running(pl.id))
+
+ # Release _run: it finishes the dwell, re-reads (KeyError -> break),
+ # and races stop_task into the natural-end clear under the lock.
+ release.set()
+
+ await asyncio.wait_for(stop_task, timeout=2.0)
+ # Drain whatever remains of the run task.
+ run_task = engine._task
+ if run_task is not None:
+ try:
+ await asyncio.wait_for(asyncio.shield(run_task), timeout=2.0)
+ except (asyncio.CancelledError, KeyError):
+ pass
+
+ # Exactly one terminal 'stopped' transition (no duplicate/contradictory).
+ stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"]
+ assert len(stopped) == 1, f"expected exactly one stopped, got {len(stopped)}: {stopped}"
+ assert stopped[0]["playlist_id"] == pl.id
+
+ # Engine fully idle and get_state coherent afterwards.
+ assert engine.is_running() is False
+ final = engine.get_state()
+ assert final["is_running"] is False
+ assert final["playlist_id"] is None
+
+ async def test_natural_end_after_delete_emits_single_stopped(
+ self, engine, playlist_store, applied
+ ):
+ # Drive the OTHER ordering: the delete causes _run to break and run its
+ # (now lock-wrapped) natural-end clear+'stopped', while a stop_if_running
+ # for the same id is fired AFTER the dwell is released but races into the
+ # same lock. Whoever wins, exactly one 'stopped' must surface and the
+ # late stop_if_running must be a clean no-op (state already cleared).
+ pl = _make_playlist(playlist_store, "Ending", [("scene_a", 50)], loop=False)
+
+ gated = _DeletableStore(playlist_store)
+ engine._playlist_store = gated
+
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ first_dwell = {"done": False}
+
+ async def _interleaving_sleep(_duration):
+ if not first_dwell["done"]:
+ first_dwell["done"] = True
+ entered.set()
+ await release.wait()
+ await _REAL_SLEEP(0.005)
+
+ with patch("asyncio.sleep", _interleaving_sleep):
+ await engine.start_playlist(pl.id)
+ await asyncio.wait_for(entered.wait(), timeout=2.0)
+
+ # Delete and release so _run resumes -> non-loop break -> natural end.
+ gated.delete_playlist(pl.id)
+ release.set()
+
+ # Let the run task drive its natural-end clear to completion.
+ run_task = engine._task
+ if run_task is not None:
+ await asyncio.wait_for(asyncio.shield(run_task), timeout=2.0)
+
+ # A late stop_if_running for the same id is now a clean no-op.
+ await engine.stop_if_running(pl.id)
+
+ stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"]
+ assert len(stopped) == 1, f"expected exactly one stopped, got {len(stopped)}: {stopped}"
+ assert stopped[0]["playlist_id"] == pl.id
+ assert engine.is_running() is False
+ assert engine.get_state()["playlist_id"] is None
+
+ async def test_get_state_never_half_cleared_under_concurrent_stop(self, engine, playlist_store):
+ # Hammer get_state() while start/stop churn the lifecycle, asserting it
+ # never raises and never reports running with a None playlist_id.
+ pl = _make_playlist(playlist_store, "Churn", [("scene_a", 50)], loop=True)
+
+ async def _churn():
+ for _ in range(20):
+ await engine.start_playlist(pl.id)
+ await engine.stop()
+
+ async def _poll():
+ for _ in range(500):
+ s = engine.get_state()
+ # Coherence invariant: running implies a concrete playlist_id.
+ if s["is_running"]:
+ assert s["playlist_id"] is not None
+ await _REAL_SLEEP(0)
+
+ async def _fast(_duration):
+ await _REAL_SLEEP(0)
+
+ with patch("asyncio.sleep", _fast):
+ await asyncio.gather(_churn(), _poll())
+
+ await engine.stop()
+ assert engine.is_running() is False
diff --git a/server/tests/storage/test_game_integration_store.py b/server/tests/storage/test_game_integration_store.py
index e686647..66afc8f 100644
--- a/server/tests/storage/test_game_integration_store.py
+++ b/server/tests/storage/test_game_integration_store.py
@@ -5,6 +5,7 @@ import pytest
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping, GameIntegrationConfig
from ledgrab.storage.game_integration_store import GameIntegrationStore
+from ledgrab.utils import secret_box
@pytest.fixture
@@ -272,3 +273,83 @@ class TestGetReferences:
config = store.create_integration(name="Test", adapter_type="webhook")
refs = store.get_references(config.id)
assert refs == []
+
+
+# ---------------------------------------------------------------------------
+# Secret encryption (adapter_config.auth_token) tests
+# ---------------------------------------------------------------------------
+
+
+class TestAdapterConfigSecretEncryption:
+ def test_auth_token_round_trips(self):
+ config = GameIntegrationConfig.create_from_kwargs(
+ name="Webhook",
+ adapter_type="generic_webhook",
+ adapter_config={"auth_token": "super-secret", "auth_header": "X-Token"},
+ )
+ restored = GameIntegrationConfig.from_dict(config.to_dict())
+ # Decrypted at runtime — plaintext is recovered.
+ assert restored.adapter_config["auth_token"] == "super-secret"
+ # Non-secret fields are untouched.
+ assert restored.adapter_config["auth_header"] == "X-Token"
+
+ def test_stored_token_is_not_plaintext(self):
+ config = GameIntegrationConfig.create_from_kwargs(
+ name="Webhook",
+ adapter_type="generic_webhook",
+ adapter_config={"auth_token": "plaintext-secret"},
+ )
+ stored = config.to_dict()
+ raw_token = stored["adapter_config"]["auth_token"]
+ assert raw_token != "plaintext-secret"
+ assert secret_box.is_encrypted(raw_token)
+
+ def test_legacy_plaintext_row_still_decrypts(self):
+ # Simulate a row written before encryption was added: auth_token is
+ # bare plaintext (not an ENC: envelope). It must still load.
+ legacy = {
+ "id": "gi_legacy01",
+ "name": "Legacy Webhook",
+ "adapter_type": "generic_webhook",
+ "enabled": True,
+ "adapter_config": {"auth_token": "legacy-plaintext"},
+ "event_mappings": [],
+ "created_at": "2024-01-01T00:00:00+00:00",
+ "updated_at": "2024-01-01T00:00:00+00:00",
+ }
+ restored = GameIntegrationConfig.from_dict(legacy)
+ assert restored.adapter_config["auth_token"] == "legacy-plaintext"
+
+ def test_no_auth_token_key_is_safe(self):
+ config = GameIntegrationConfig.create_from_kwargs(
+ name="NoSecret",
+ adapter_type="cs2",
+ adapter_config={"max_gold": 99999},
+ )
+ restored = GameIntegrationConfig.from_dict(config.to_dict())
+ assert restored.adapter_config == {"max_gold": 99999}
+
+ def test_db_stores_token_encrypted(self, tmp_db):
+ store = GameIntegrationStore(tmp_db)
+ store.create_integration(
+ name="Webhook",
+ adapter_type="generic_webhook",
+ adapter_config={"auth_token": "db-secret"},
+ )
+ rows = tmp_db.load_all("game_integrations")
+ assert len(rows) == 1
+ raw_token = rows[0]["adapter_config"]["auth_token"]
+ assert raw_token != "db-secret"
+ assert secret_box.is_encrypted(raw_token)
+
+ def test_db_round_trip_after_reload(self, tmp_db):
+ store1 = GameIntegrationStore(tmp_db)
+ created = store1.create_integration(
+ name="Webhook",
+ adapter_type="generic_webhook",
+ adapter_config={"auth_token": "reload-secret"},
+ )
+ # New store instance reads from disk and must decrypt.
+ store2 = GameIntegrationStore(tmp_db)
+ fetched = store2.get_integration(created.id)
+ assert fetched.adapter_config["auth_token"] == "reload-secret"
diff --git a/server/tests/test_calibration.py b/server/tests/test_calibration.py
index 7170e0e..dfd0c95 100644
--- a/server/tests/test_calibration.py
+++ b/server/tests/test_calibration.py
@@ -291,6 +291,22 @@ def test_create_default_calibration_small_count():
assert config.get_total_leds() == 4
+@pytest.mark.parametrize("led_count", [4, 5, 6, 7, 9, 11, 13, 60, 144, 300])
+def test_create_default_calibration_total_matches_count(led_count):
+ """Total LED count must equal led_count exactly, with every edge >= 1.
+
+ Regression for small odd counts where the per-edge max(1, ...) floor used
+ to push the total above led_count (e.g. led_count=5 produced a total of 6).
+ """
+ config = create_default_calibration(led_count)
+
+ assert config.get_total_leds() == led_count
+ assert config.leds_top >= 1
+ assert config.leds_right >= 1
+ assert config.leds_bottom >= 1
+ assert config.leds_left >= 1
+
+
def test_create_default_calibration_invalid():
"""Test default calibration with invalid LED count."""
with pytest.raises(ValueError):
diff --git a/server/tests/test_home_assistant_host_validation.py b/server/tests/test_home_assistant_host_validation.py
new file mode 100644
index 0000000..c0028b2
--- /dev/null
+++ b/server/tests/test_home_assistant_host_validation.py
@@ -0,0 +1,52 @@
+"""Tests for Home Assistant source host classification.
+
+The HA source host is user-supplied and stored as ``host:port`` (e.g.
+``192.168.1.100:8123``). Before the WebSocket runtime connects to it, the
+route layer gates the host with the shared LAN classifier so an
+(authenticated or default-anonymous) caller cannot weaponise it into a
+public network-scan oracle — mirroring the LED device providers.
+"""
+
+import pytest
+
+from ledgrab.api.routes.home_assistant import _validate_ha_host
+
+
+@pytest.mark.parametrize(
+ "host",
+ [
+ "127.0.0.1:8123",
+ "10.0.0.5:8123",
+ "192.168.1.100:8123",
+ "172.16.0.10", # no port
+ "homeassistant.local:8123", # mDNS label
+ "hass", # bare hostname
+ "[fe80::1]:8123", # bracketed IPv6 link-local + port
+ "[fc00::1]:8123", # bracketed IPv6 ULA + port
+ "169.254.169.254:80", # link-local — allowed by the shared LAN policy
+ "", # empty passes (upstream schema requires non-empty)
+ None, # update path may pass None (host unchanged)
+ ],
+)
+def test_validate_ha_host_accepts_lan(host) -> None:
+ """Loopback / private / link-local / hostnames must not raise.
+
+ Link-local (169.254/16, fe80::/10) is part of the shared
+ ``validate_lan_host`` LAN policy used by every LED device provider, so
+ HA reuses it unchanged rather than hand-rolling a stricter variant.
+ """
+ _validate_ha_host(host)
+
+
+@pytest.mark.parametrize(
+ "host",
+ [
+ "1.1.1.1:8123", # public IPv4 + port
+ "8.8.8.8", # public IPv4, no port
+ "[2606:4700:4700::1111]:8123", # public IPv6 + port
+ ],
+)
+def test_validate_ha_host_rejects_public(host) -> None:
+ """Genuinely-public IPs (the network-scan-oracle risk) are rejected."""
+ with pytest.raises(ValueError, match="LedGrab is LAN-only"):
+ _validate_ha_host(host)