fix: resolve comprehensive review findings (security, concurrency, perf, Android, UI)

Multi-dimension review of v0.8.2. Excludes the deliberately deferred
default_config.yaml weak-default-key item (C1).

Backend:
- calibration: create_default_calibration no longer exceeds led_count for small
  odd counts (bounded trim + regression test)
- game-integration: generic webhook now requires auth_token; constant-time
  compare_digest in all adapters; per-IP failed-auth rate limit on the ingest
  route; auth_token encrypted at rest via secret_box (migration-safe)
- playlist engine: serialize _state/_task under the lifecycle lock to close a
  delete-mid-play race (+ concurrency tests)
- main: stop the calibration session on shutdown (restore prior target)
- home_assistant: validate HA host via the LAN classifier on create/update
- perf: drop slow preview-WS clients instead of blocking the send loop; cache
  composite full-strip resize linspaces; effect_stream lava reuses scratch

Frontend:
- setup/auto-calibration wizard: guard _state after awaits (cancel-safe), await
  session teardown before output start, busy-gate skip-calibration, manual
  display input keeps focus, move focus on step change
- calibration: destroy EntitySelect on modal close
- color-strips test: dirty-flag-gated render + cached ctx/ImageData
- a11y/TV: focus-visible for new wizard/auto-cal/corner controls, aria-labels on
  the spatial corner/edge picker; theme-aware syntax tokens; dead/undefined CSS
  tokens removed; .modal-error styled; i18n titles (en/ru/zh)

Android:
- ApiKeyManager: EncryptedSharedPreferences with verified, data-safe legacy
  migration that never rotates an existing key
- CaptureService: validate MediaProjection token before promoting; satisfy the
  startForeground 5s contract on the bail path
- NotificationListener: connection-scoped executor with lazy fallback
- BLE: request BLUETOOTH_SCAN/CONNECT at runtime + guard handler-thread
  SecurityExceptions
- Root: cancellation-aware su grant probe

Adds 14 tests. Gate: ruff + tsc 5.9.3 + esbuild + pytest (2185 passed) +
compileDebugKotlin all green.
This commit is contained in:
2026-06-09 16:35:08 +03:00
parent 7a12f39f49
commit 17dd2e02ba
42 changed files with 1358 additions and 152 deletions
+4
View File
@@ -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")
+2 -1
View File
@@ -14,7 +14,8 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
tools:targetApi="s" />
<!-- BLE hardware — required=false so non-BT boxes still install. -->
<uses-feature
@@ -1,7 +1,10 @@
package com.ledgrab.android
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.security.SecureRandom
/**
@@ -23,8 +26,23 @@ import java.security.SecureRandom
*/
class ApiKeyManager(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val appContext = context.applicationContext
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
// or absent keystore, or a key got corrupted), creation throws — fall back
// to plain SharedPreferences so a keystore failure NEVER bricks the local
// API key (which would 401 every LAN client). [encrypted] records which
// path we took so we don't repeatedly attempt migration.
private val encrypted: Boolean
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
encrypted = isEncrypted
if (isEncrypted) migrateLegacyKeyIfPresent()
}
// Once we've materialised a key in this process, cache it so
// subsequent reads don't hit prefs and don't risk re-checking
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
cached = existing
return existing
}
// Before minting a fresh key, fall back to any key still in the
// legacy plain store (covers a failed/partial encrypted migration:
// commit() can return false WITHOUT throwing, so migration may have
// left the live key only in the legacy file). Rotating the
// per-install key would 401 every already-paired client, so we
// generate a brand-new key ONLY when no key exists anywhere.
recoverLegacyKey()?.let { recovered ->
// 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<SharedPreferences, Boolean> {
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
@@ -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)")
@@ -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
@@ -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()
}
@@ -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)
@@ -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
}
@@ -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) {
@@ -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
@@ -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
@@ -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,
@@ -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",
@@ -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
@@ -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]:
@@ -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]:
@@ -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://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/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 <token>` 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"
)
@@ -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
@@ -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(
@@ -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)
@@ -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
@@ -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
@@ -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:
+7
View File
@@ -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:
@@ -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;
}
+37 -27
View File
@@ -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 */
+5 -1
View File
@@ -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);
@@ -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<void> {
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<void> {
// 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
@@ -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);
@@ -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<HTMLCanvasElement, CanvasRenderingContext2D>();
const _cssTestImageDataCache = new WeakMap<HTMLCanvasElement, { w: number; h: number; imageData: ImageData }>();
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();
@@ -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<void> {
_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<void> {
}
}
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 <input> 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 {
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
<input id="wizard-display-index-manual" class="form-input" type="number"
min="0" max="63" value="${state.displayIndex}"
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value)">
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value, true)">
</div>
</div>`;
} else {
+10
View File
@@ -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",
+10
View File
@@ -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": "Калибровка сохранена",
+10
View File
@@ -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": "校准已保存",
+46 -2
View File
@@ -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"])
@@ -56,10 +56,10 @@
<div class="calibration-preview">
<!-- Screen with direction toggle, total LEDs, and offset -->
<div class="preview-screen">
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
<button type="button" class="direction-toggle" onclick="toggleDirection()" data-i18n-title="calibration.direction.toggle" title="Toggle direction">
<span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
</button>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" data-i18n-title="calibration.edge_inputs.toggle" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-border-width">
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
<input type="number" id="cal-border-width" min="1" max="100" value="10">
@@ -104,16 +104,16 @@
</div>
<!-- Edge test toggle zones (outside container border, in tick area) -->
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')" data-i18n-aria-label="calibration.edge.top" aria-label="Toggle top edge test LEDs"></button>
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')" data-i18n-aria-label="calibration.edge.right" aria-label="Toggle right edge test LEDs"></button>
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')" data-i18n-aria-label="calibration.edge.bottom" aria-label="Toggle bottom edge test LEDs"></button>
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')" data-i18n-aria-label="calibration.edge.left" aria-label="Toggle left edge test LEDs"></button>
<!-- Corner start position buttons -->
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')"></button>
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></button>
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></button>
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></button>
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')" data-i18n-aria-label="calibration.corner.top_left" aria-label="Set start position: top left"></button>
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')" data-i18n-aria-label="calibration.corner.top_right" aria-label="Set start position: top right"></button>
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')" data-i18n-aria-label="calibration.corner.bottom_left" aria-label="Set start position: bottom left"></button>
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')" data-i18n-aria-label="calibration.corner.bottom_right" aria-label="Set start position: bottom right"></button>
<!-- Canvas overlay for ticks, arrows, start label -->
<canvas id="calibration-preview-canvas"></canvas>
@@ -144,7 +144,7 @@
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-md);">
<div class="form-group">
<div class="label-row">
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
@@ -170,13 +170,13 @@
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
</div>
<div class="form-group" style="margin-top: 16px;">
<div class="form-group" style="margin-top: var(--space-md);">
<div class="label-row">
<label data-i18n="calibration.roi">Capture region (%):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.roi.hint">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.</small>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: var(--space-md);">
<div>
<label for="cal-roi-x" class="time-range-label" data-i18n="calibration.roi.x">X (%)</label>
<input type="number" id="cal-roi-x" min="0" max="100" value="0">
@@ -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
# ---------------------------------------------------------------------------
@@ -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()
+172
View File
@@ -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
@@ -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"
+16
View File
@@ -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):
@@ -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)