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") implementation("androidx.core:core-splashscreen:1.0.1")
// QR code generation for displaying server URL on TV // QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3") 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 // USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
// driving Adalight/AmbiLED controllers plugged into Android TV boxes. // driving Adalight/AmbiLED controllers plugged into Android TV boxes.
implementation("com.github.mik3y:usb-serial-for-android:3.8.1") 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" <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" /> 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. --> <!-- BLE hardware — required=false so non-BT boxes still install. -->
<uses-feature <uses-feature
@@ -1,7 +1,10 @@
package com.ledgrab.android package com.ledgrab.android
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.security.SecureRandom import java.security.SecureRandom
/** /**
@@ -23,8 +26,23 @@ import java.security.SecureRandom
*/ */
class ApiKeyManager(context: Context) { class ApiKeyManager(context: Context) {
private val prefs = context.applicationContext private val appContext = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// 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 // Once we've materialised a key in this process, cache it so
// subsequent reads don't hit prefs and don't risk re-checking // subsequent reads don't hit prefs and don't risk re-checking
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
cached = existing cached = existing
return 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() val generated = generateKey()
// commit() (synchronous disk write) on the FIRST write so // commit() (synchronous disk write) on the FIRST write so
// the key is durable before MainActivity encodes it into a // 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 { private fun generateKey(): String {
val bytes = ByteArray(KEY_BYTES) val bytes = ByteArray(KEY_BYTES)
SecureRandom().nextBytes(bytes) SecureRandom().nextBytes(bytes)
@@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) {
companion object { companion object {
private const val TAG = "ApiKeyManager" private const val TAG = "ApiKeyManager"
private const val PREFS_NAME = "ledgrab_auth" private const val PREFS_NAME = "ledgrab_auth"
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
private const val KEY_API_KEY = "api_key" 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 KEY_BYTES = 32
private const val MIN_KEY_LENGTH = 32 private const val MIN_KEY_LENGTH = 32
@@ -103,12 +103,32 @@ object BleBridge {
} }
try { 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) Thread.sleep(timeoutMs)
} catch (_: InterruptedException) { } catch (_: InterruptedException) {
Thread.currentThread().interrupt() Thread.currentThread().interrupt()
} finally { } 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() return seen.values.toList()
} }
@@ -136,7 +156,18 @@ object BleBridge {
newState == BluetoothProfile.STATE_CONNECTED newState == BluetoothProfile.STATE_CONNECTED
&& status == BluetoothGatt.GATT_SUCCESS -> { && status == BluetoothGatt.GATT_SUCCESS -> {
Log.d(TAG, "GATT connected to $address, discovering services") Log.d(TAG, "GATT connected to $address, discovering services")
// 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() 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 -> { newState == BluetoothProfile.STATE_DISCONNECTED -> {
Log.w(TAG, "GATT disconnected from $address (status=$status)") 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
// CRITICAL: startForeground must be called IMMEDIATELY — before // CRITICAL (Android 14+): for the MediaProjection path, validate the
// any other work, especially before getMediaProjection(). The // projection token BEFORE promoting to a foreground service with the
// service type must match the work; pass it explicitly via // mediaProjection FGS type. On service recreation (system redelivery
// ServiceCompat so we stay compatible back to API 24. // 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 localIp = NetworkUtils.getLocalIpAddress(this) ?: ""
val url = "http://$localIp:$SERVER_PORT" 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 { try {
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
var t = if (useRoot) { var t = if (useRoot) {
@@ -152,20 +188,13 @@ class CaptureService : Service() {
// otherwise `isRunning=true` sticks forever when startForeground throws. // otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true 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 { try {
if (useRoot) { if (useRoot) {
startRootCapture(url) startRootCapture(url)
} else { } else {
startMediaProjectionCapture(intent!!, url) // mediaProjectionResultData is guaranteed non-null here — the
// token was validated before startForeground above.
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e) Log.e(TAG, "Failed to start capture", e)
@@ -294,21 +323,25 @@ 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") @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) intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else { } else {
intent.getParcelableExtra(EXTRA_RESULT_DATA) 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 = val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val projection = projectionManager.getMediaProjection(resultCode, resultData) val projection = projectionManager.getMediaProjection(resultCode, resultData)
@@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification
import android.util.Log import android.util.Log
import com.chaquo.python.Python import com.chaquo.python.Python
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
/** /**
* Captures posted OS notifications and forwards the posting app's display * 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 // 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 // disk write and may play a sound, so pushes must not overlap. Off the main
// looper to keep the system service responsive. // 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 // packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working. // Windows/Linux backends pass, so per-app colors/filters keep working.
@@ -51,7 +66,17 @@ class LedGrabNotificationListener : NotificationListenerService() {
val label = resolveAppLabel(notification.packageName) val label = resolveAppLabel(notification.packageName)
pushExecutor.execute { // 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 { try {
Python.getInstance() Python.getInstance()
.getModule(PY_MODULE) .getModule(PY_MODULE)
@@ -64,29 +89,71 @@ class LedGrabNotificationListener : NotificationListenerService() {
Log.d(TAG, "push_notification failed: ${t.message}") Log.d(TAG, "push_notification failed: ${t.message}")
} }
} }
}.onFailure { e ->
if (e is RejectedExecutionException) {
Log.d(TAG, "push rejected — listener disconnecting")
} else {
throw e
}
}
} }
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */ /** Resolve (and cache) a package's human-readable label; fall back to the package name. */
private fun resolveAppLabel(pkg: String): String { private fun resolveAppLabel(pkg: String): String {
labelCache[pkg]?.let { return it } 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 resolved = runCatching {
val info = packageManager.getApplicationInfo(pkg, 0) val info = packageManager.getApplicationInfo(pkg, 0)
packageManager.getApplicationLabel(info).toString() packageManager.getApplicationLabel(info).toString()
}.getOrDefault(pkg) }.getOrNull()
if (resolved != null) {
labelCache[pkg] = resolved labelCache[pkg] = resolved
return 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() { override fun onListenerConnected() {
Log.i(TAG, "Notification listener connected") 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() { override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected") 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() { 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() super.onDestroy()
} }
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
private const val REQUEST_POST_NOTIFICATIONS = 1002 private const val REQUEST_POST_NOTIFICATIONS = 1002
private const val REQUEST_RECORD_AUDIO = 1003 private const val REQUEST_RECORD_AUDIO = 1003
private const val REQUEST_CAMERA = 1004 private const val REQUEST_CAMERA = 1004
private const val REQUEST_BLUETOOTH = 1005
private const val QR_SIZE_PX = 560 private const val QR_SIZE_PX = 560
private const val NOTIF_PREFS = "ledgrab_notif" private const val NOTIF_PREFS = "ledgrab_notif"
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted" private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
toggleButton.text = getString(R.string.btn_starting) toggleButton.text = getString(R.string.btn_starting)
statusText.text = getString(R.string.status_checking_root) statusText.text = getString(R.string.status_checking_root)
uiScope.launch(Dispatchers.IO) { 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) { withContext(Dispatchers.Main) {
toggleButton.isEnabled = true toggleButton.isEnabled = true
toggleButton.text = originalText toggleButton.text = originalText
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
ensureNotificationPermission() ensureNotificationPermission()
ensureNotificationListenerAccess() ensureNotificationListenerAccess()
ensureCameraPermission() ensureCameraPermission()
ensureBluetoothPermissions()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this)) ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI() updateUI()
} }
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
ensureNotificationListenerAccess() ensureNotificationListenerAccess()
ensureAudioPermission() ensureAudioPermission()
ensureCameraPermission() ensureCameraPermission()
ensureBluetoothPermissions()
val intent = CaptureService.createIntent(this, resultCode, resultData) val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent) ContextCompat.startForegroundService(this, intent)
updateUI() 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. */ /** Whether the user has granted notification-listener access to this app. */
private fun isNotificationAccessGranted(): Boolean = private fun isNotificationAccessGranted(): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName) NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
object Root { object Root {
private const val TAG = "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( private val SU_PATHS = listOf(
"/system/bin/su", "/system/bin/su",
"/system/xbin/su", "/system/xbin/su",
@@ -49,17 +54,19 @@ object Root {
return false return false
} }
var process: Process? = null
val granted = try { val granted = try {
// redirectErrorStream merges stderr into stdout so a single // redirectErrorStream merges stderr into stdout so a single
// drain thread is enough — avoids the classic pipe-buffer // drain thread is enough — avoids the classic pipe-buffer
// deadlock where waitFor() blocks because stderr filled up. // deadlock where waitFor() blocks because stderr filled up.
val process = ProcessBuilder("su", "-c", "id") val proc = ProcessBuilder("su", "-c", "id")
.redirectErrorStream(true) .redirectErrorStream(true)
.start() .start()
process = proc
val outputBuilder = StringBuilder() val outputBuilder = StringBuilder()
val drain = Thread({ val drain = Thread({
try { try {
BufferedReader(InputStreamReader(process.inputStream)).use { r -> BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
val buf = CharArray(512) val buf = CharArray(512)
while (true) { while (true) {
val n = r.read(buf) val n = r.read(buf)
@@ -72,17 +79,35 @@ object Root {
} }
}, "Root-su-drain").apply { isDaemon = true; start() } }, "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) { if (!finished) {
process.destroyForcibly() proc.destroyForcibly()
drain.join(500) drain.join(500)
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s") Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
false false
} else { } else {
drain.join(500) drain.join(500)
val output = synchronized(outputBuilder) { outputBuilder.toString() } val output = synchronized(outputBuilder) { outputBuilder.toString() }
if (process.exitValue() != 0) { if (proc.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'") Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
false false
} else { } else {
val rooted = output.contains("uid=0") val rooted = output.contains("uid=0")
@@ -90,8 +115,17 @@ object Root {
rooted 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) { } catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}") Log.w(TAG, "su invocation failed: ${e.message}")
runCatching { process?.destroyForcibly() }
false false
} }
@@ -89,14 +89,15 @@ class RootScreenrecord(
running = true running = true
try { try {
imageReader = buildImageReader() val reader = buildImageReader().also { imageReader = it }
decoder = buildDecoder(imageReader!!) val codec = buildDecoder(reader).also { decoder = it }
process = spawnScreenrecord() ?: run { val proc = spawnScreenrecord() ?: run {
stop() stop()
return false return false
} }
startInputPump(process!!.inputStream, decoder!!) process = proc
startOutputDrain(decoder!!) startInputPump(proc.inputStream, codec)
startOutputDrain(codec)
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)") Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
@@ -178,6 +179,14 @@ class RootScreenrecord(
buffer.get(frameBuffer, row * rowBytes, rowBytes) 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) bridge.pushRootFrame(frameBuffer, width, height)
framesDeliveredCounter.incrementAndGet() framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) { } 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) bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
// Advance the pacing accumulator. If we fell badly behind // Advance the pacing accumulator. If we fell badly behind
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
import threading import threading
import time import time
from collections import defaultdict
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request 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]] = {} _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]]: def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert a JSON Schema object into a flat list of field descriptors. """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. called before standard API auth.
No AuthRequired dependency — adapter-level auth is used instead. 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: try:
config = store.get_integration(integration_id) config = store.get_integration(integration_id)
except EntityNotFoundError: except EntityNotFoundError:
@@ -405,6 +482,7 @@ async def ingest_event(
# Adapter-level auth check # Adapter-level auth check
headers = dict(request.headers) headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config): 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") raise HTTPException(status_code=403, detail="Adapter authentication failed")
# Parse payload through adapter # Parse payload through adapter
@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query 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_source import HomeAssistantSource
from ledgrab.storage.home_assistant_store import HomeAssistantStore from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.utils import get_logger from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import validate_lan_host
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -37,6 +39,23 @@ router = APIRouter()
_REDACTED_TOKEN = "***" _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( def _to_response(
source: HomeAssistantSource, source: HomeAssistantSource,
manager: HomeAssistantManager, manager: HomeAssistantManager,
@@ -99,6 +118,7 @@ async def create_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager), manager: HomeAssistantManager = Depends(get_ha_manager),
): ):
try: try:
_validate_ha_host(data.host)
source = store.create_source( source = store.create_source(
name=data.name, name=data.name,
host=data.host, host=data.host,
@@ -153,6 +173,7 @@ async def update_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager), manager: HomeAssistantManager = Depends(get_ha_manager),
): ):
try: try:
_validate_ha_host(data.host)
source = store.update_source( source = store.update_source(
source_id, source_id,
name=data.name, name=data.name,
@@ -824,6 +824,30 @@ def create_default_calibration(
right_count = max(1, right_count) right_count = max(1, right_count)
left_count = max(1, left_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( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
start_position="bottom_left", start_position="bottom_left",
@@ -154,6 +154,15 @@ class DDPClient:
all buses (observed in multi-bus setups). We reorder pixel channels all buses (observed in multi-bus setups). We reorder pixel channels
here so the hardware receives the correct byte order directly. 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: Args:
pixel_array: (N, 3) uint8 numpy array in RGB order pixel_array: (N, 3) uint8 numpy array in RGB order
@@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter):
auth_section = payload.get("auth", {}) auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "") 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 @classmethod
def get_config_schema(cls) -> dict[str, Any]: def get_config_schema(cls) -> dict[str, Any]:
@@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter):
auth_section = payload.get("auth", {}) auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "") 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 @classmethod
def get_config_schema(cls) -> dict[str, Any]: 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. rather than a YAML file. Delegates all parsing logic to MappingAdapter.
""" """
import secrets
from typing import Any, ClassVar from typing import Any, ClassVar
from ledgrab.core.game_integration.base_adapter import GameAdapter from ledgrab.core.game_integration.base_adapter import GameAdapter
@@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter):
payload: dict[str, Any], payload: dict[str, Any],
adapter_config: dict[str, Any], adapter_config: dict[str, Any],
) -> bool: ) -> 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") expected_token = adapter_config.get("auth_token")
if not expected_token: if not expected_token:
# No auth configured # No token configured — reject (secure-by-default for a
return True # network-facing adapter; an open webhook is a LAN attack surface).
return False
auth_header = adapter_config.get("auth_header", "Authorization") auth_header = adapter_config.get("auth_header", "Authorization")
actual_value = headers.get(auth_header, "") actual_value = headers.get(auth_header, "")
@@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter):
if actual_value.startswith("Bearer "): if actual_value.startswith("Bearer "):
actual_value = actual_value[7:] 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 @classmethod
def get_config_schema(cls) -> dict[str, Any]: def get_config_schema(cls) -> dict[str, Any]:
"""Return generic webhook config schema.""" """Return generic webhook config schema."""
return { return {
"type": "object", "type": "object",
"required": ["auth_token"],
"properties": { "properties": {
"auth_token": { "auth_token": {
"type": "string", "type": "string",
"title": "Auth Token", "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": { "auth_header": {
"type": "string", "type": "string",
@@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter):
"HTTP POST requests with JSON payloads.\n\n" "HTTP POST requests with JSON payloads.\n\n"
"**Steps:**\n" "**Steps:**\n"
"1. Configure your event mappings above — map JSON paths to standard events\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" "3. Point your game/application to:\n"
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n" " `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
"**Mapping example:**\n" "**Mapping example:**\n"
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\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" "- 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" "- Set `Authorization: Bearer <token>` header in your webhook sender\n"
"- Or configure a custom auth header name in the adapter config\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. entirely driven by the parsed YAML definition.
""" """
import secrets
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter):
expected_key = "auth_token" expected_key = "auth_token"
expected_value = adapter_config.get(expected_key, "") expected_value = adapter_config.get(expected_key, "")
actual_value = headers.get(header_name, "") 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}'") logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False return False
@@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream):
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing # (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {} 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) # layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {} self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream) # layer_index -> (vs_id, value_stream)
@@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream):
n_src = len(colors) n_src = len(colors)
if n_src == target_n: if n_src == target_n:
return colors return colors
src_x = np.linspace(0, 1, n_src) # Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n)
dst_x = np.linspace(0, 1, 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 buf = self._resize_buf
for ch in range(3): for ch in range(3):
np.copyto( np.copyto(
@@ -137,16 +137,34 @@ class DeviceTestModeMixin:
await self._send_pixels_to_device(device_id, pixels) await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None: 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] ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count 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: async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client. """Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices). (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: try:
client = await self._get_idle_client(device_id) client = await self._get_idle_client(device_id)
@@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream):
# Use noise at very low frequency for blob movement # Use noise at very low frequency for blob movement
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a) 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 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) np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
self._s_f32_a += t * speed * 0.07 + 100.0 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 # Combine: create blob-like shapes with soft edges
combined = self._s_f32_a combined = self._s_f32_a
@@ -739,10 +739,19 @@ class WledTargetProcessor(TargetProcessor):
self._last_preview_data = data 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: try:
await ws.send_bytes(data) await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout)
return True return True
except asyncio.TimeoutError:
logger.debug("LED preview broadcast WS send timed out (slow client dropped)")
return False
except Exception as e: except Exception as e:
logger.debug("LED preview broadcast WS send failed: %s", e) logger.debug("LED preview broadcast WS send failed: %s", e)
return False return False
@@ -138,22 +138,42 @@ class PlaylistEngine:
"""Stop the playlist only if ``playlist_id`` is the one running. """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 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: async with self._lifecycle_lock:
await self.stop() 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) ===== # ===== Query API (used by routes) =====
def is_running(self) -> bool: 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: 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: def get_state(self) -> dict:
if self._state is not None and self.is_running(): # Snapshot both refs once: the event loop won't preempt this sync method
return self._state.to_dict() # 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) return dict(_IDLE_STATE)
# ===== Internal ===== # ===== Internal =====
@@ -201,13 +221,25 @@ class PlaylistEngine:
break break
# Natural end (non-loop or guard). Clear state without recursing # Natural end (non-loop or guard). Clear state without recursing
# through stop() (which would try to cancel this very task). Guard # through stop() (which would try to cancel this very task). Take
# against a concurrent start_playlist having already replaced us: # the lifecycle lock so the clear + 'stopped' event are atomic with
# only clear if we are still the engine's current task. # 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(): if self._task is asyncio.current_task():
self._task = None self._task = None
ended_id = self._state.playlist_id if self._state else None ended_id = self._state.playlist_id if self._state else None
self._state = None self._state = None
should_fire = True
if should_fire:
self._fire_event("stopped", playlist_id=ended_id) self._fire_event("stopped", playlist_id=ended_id)
logger.info("Playlist '%s' finished", playlist_id) logger.info("Playlist '%s' finished", playlist_id)
except asyncio.CancelledError: 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. # 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) 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 # Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager. # firing events into a shutting-down processor manager.
if discovery_watcher is not None: if discovery_watcher is not None:
@@ -79,6 +79,12 @@
opacity: 1; opacity: 1;
} }
.preview-screen-total:focus-visible {
opacity: 1;
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.preview-screen-total.mismatch { .preview-screen-total.mismatch {
color: #FFC107; color: #FFC107;
} }
@@ -123,6 +129,12 @@
background: rgba(128, 128, 128, 0.25); 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 { .preview-edge.edge-disabled {
opacity: 0.25; opacity: 0.25;
pointer-events: none; pointer-events: none;
@@ -374,6 +386,13 @@
color: rgba(76, 175, 80, 0.6); 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 { .preview-corner.active:hover {
transform: none; transform: none;
} }
@@ -412,6 +431,12 @@
background: rgba(255, 255, 255, 0.25); 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 { .direction-toggle #direction-icon {
font-size: 14px; font-size: 14px;
} }
+37 -27
View File
@@ -328,7 +328,7 @@ select.field-invalid {
display: block; display: block;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-sm); 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; overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease; 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. */ /* Token palette — restrained, three accents plus muted operators. */
.jinja-hl .tok-str { color: var(--success-color); } .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-fn { color: var(--primary-color); font-weight: 600; }
.jinja-hl .tok-raw { color: #c678dd; font-style: italic; } .jinja-hl .tok-raw { color: var(--ch-violet); font-style: italic; }
.jinja-hl .tok-var { color: #61afef; } .jinja-hl .tok-var { color: var(--ch-cyan); }
.jinja-hl .tok-op { color: var(--text-muted); } .jinja-hl .tok-op { color: var(--text-muted); }
/* ── Template-input rows ─────────────────────────────────────── */ /* ── Template-input rows ─────────────────────────────────────── */
@@ -496,8 +496,8 @@ select.field-invalid {
font-size: 0.76rem; font-size: 0.76rem;
padding: 1px 6px; padding: 1px 6px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: color-mix(in srgb, #61afef 16%, transparent); background: color-mix(in srgb, var(--ch-cyan) 16%, transparent);
color: #61afef; color: var(--ch-cyan);
} }
.jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; } .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, .discovery-item:focus-visible,
.tag-chip-remove:focus-visible, .tag-chip-remove:focus-visible,
.cs-filter-reset: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: 2px solid var(--primary-color);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -1118,7 +1123,8 @@ textarea:focus-visible {
font-weight: 700; font-weight: 700;
line-height: 1; 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: background:
repeating-linear-gradient(135deg, repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px, color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
@@ -1625,14 +1631,14 @@ textarea:focus-visible {
padding: 6px 12px; padding: 6px 12px;
border: 1px solid var(--border-color, #333); border: 1px solid var(--border-color, #333);
border-radius: 6px; border-radius: 6px;
background: var(--surface-2, #1e1e2e); background: var(--lux-bg-2);
color: var(--text-secondary, #999); color: var(--text-secondary, #999);
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s; transition: background 0.15s, color 0.15s, border-color 0.15s;
} }
.type-picker-tab:hover { .type-picker-tab:hover {
background: var(--surface-3, #2a2a3e); background: var(--lux-bg-3);
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
} }
.type-picker-tab.active { .type-picker-tab.active {
@@ -2326,7 +2332,7 @@ textarea:focus-visible {
.autocal-step-desc { .autocal-step-desc {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color)); color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
} }
@@ -2355,7 +2361,8 @@ textarea:focus-visible {
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
} }
.autocal-corner-btn:hover { .autocal-corner-btn:hover,
.autocal-corner-btn:focus-visible {
border-color: var(--primary-color); border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)); background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color); color: var(--primary-color);
@@ -2424,7 +2431,8 @@ textarea:focus-visible {
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
} }
.autocal-direction-btn:hover { .autocal-direction-btn:hover,
.autocal-direction-btn:focus-visible {
border-color: var(--primary-color); border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)); background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color); 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: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
border-radius: var(--radius-sm, 6px); border-radius: var(--radius-sm, 6px);
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted, var(--secondary-text-color)); color: var(--text-muted);
} }
.autocal-led-dot { .autocal-led-dot {
@@ -2489,7 +2497,7 @@ textarea:focus-visible {
justify-content: center; justify-content: center;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; 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; transition: border-color 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -2618,7 +2626,7 @@ textarea:focus-visible {
} }
.autocal-solved-key { .autocal-solved-key {
color: var(--text-muted, var(--secondary-text-color)); color: var(--text-muted);
flex-shrink: 0; flex-shrink: 0;
min-width: 68px; min-width: 68px;
} }
@@ -2720,7 +2728,7 @@ textarea:focus-visible {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
background: var(--bg-secondary, var(--bg-2, #2a2a2a)); 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); border: 1.5px solid var(--border-color);
transition: background 0.2s, color 0.2s, border-color 0.2s; transition: background 0.2s, color 0.2s, border-color 0.2s;
} }
@@ -2775,7 +2783,7 @@ textarea:focus-visible {
} }
.wizard-step-desc { .wizard-step-desc {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color)); color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
} }
@@ -2829,7 +2837,7 @@ textarea:focus-visible {
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted, var(--secondary-text-color)); color: var(--text-muted);
padding-bottom: 4px; padding-bottom: 4px;
} }
.wizard-section-label--scan { .wizard-section-label--scan {
@@ -2860,7 +2868,7 @@ textarea:focus-visible {
gap: 10px; gap: 10px;
padding: 14px 12px; padding: 14px 12px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color)); color: var(--text-muted);
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px); border-radius: var(--radius-md, 8px);
@@ -2868,7 +2876,7 @@ textarea:focus-visible {
.wizard-discovery-empty { .wizard-discovery-empty {
padding: 14px 12px; padding: 14px 12px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color)); color: var(--text-muted);
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px); border-radius: var(--radius-md, 8px);
@@ -2891,7 +2899,8 @@ textarea:focus-visible {
width: 100%; width: 100%;
transition: border-color 0.15s, background 0.15s; 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); border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg)); 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-icon .icon { width: 20px; height: 20px; }
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } .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-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 { .wizard-discovery-badge {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
@@ -2926,7 +2935,8 @@ textarea:focus-visible {
width: 100%; width: 100%;
transition: border-color 0.15s, background 0.15s; 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); border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg)); 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-icon .icon { width: 20px; height: 20px; }
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; } .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-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 { color: var(--primary-color); }
.wizard-display-check .icon { width: 16px; height: 16px; } .wizard-display-check .icon { width: 16px; height: 16px; }
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; } .wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
@@ -2953,7 +2963,7 @@ textarea:focus-visible {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px); 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 */ /* Calibrate container */
.wizard-calibrate-container { .wizard-calibrate-container {
@@ -2990,7 +3000,7 @@ textarea:focus-visible {
padding: 12px 16px; padding: 12px 16px;
} }
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; } .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-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
/* Wizard form rows */ /* Wizard form rows */
+5 -1
View File
@@ -2512,7 +2512,11 @@
padding-top: 8px; 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 */ background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
color: var(--danger-color); color: var(--danger-color);
@@ -98,6 +98,9 @@ export interface AutoCalOptions {
let _state: AutoCalState | null = null; let _state: AutoCalState | null = null;
let _opts: AutoCalOptions | null = null; let _opts: AutoCalOptions | null = null;
let _deviceEntitySelect: EntitySelect | 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 ───────────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────────
@@ -119,6 +122,7 @@ let _deviceEntitySelect: EntitySelect | null = null;
*/ */
export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> { export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> {
await unmountAutoCalibration(); await unmountAutoCalibration();
_firstRender = true;
_opts = opts; _opts = opts;
let cssSourceType = 'picture'; let cssSourceType = 'picture';
@@ -177,6 +181,27 @@ function _render(): void {
case 'corners': _renderCorners(); break; case 'corners': _renderCorners(); break;
case 'preview': _renderPreview(); 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 ────────────────────────────────────────────────── // ── 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 // LED is at index 0; advance to ~5% to show movement direction
await _setPosition(0); await _setPosition(0);
await _delay(350); 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)); const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
await _setPosition(advance); await _setPosition(advance);
if (!_state) return;
_state.busy = false; _state.busy = false;
} catch (err: unknown) { } catch (err: unknown) {
// _state may have been torn down mid-await by a cancel.
if (!_state) return;
_state.busy = false; _state.busy = false;
_state.errorMsg = _errMsg(err); _state.errorMsg = _errMsg(err);
_state.step = 'corner'; // revert on error _state.step = 'corner'; // revert on error
@@ -64,6 +64,14 @@ class CalibrationModal extends Modal {
if (testGroup) testGroup.style.display = 'none'; if (testGroup) testGroup.style.display = 'none';
const testSection = document.getElementById('calibration-test-setup-section'); const testSection = document.getElementById('calibration-test-setup-section');
if (testSection) testSection.style.display = 'none'; 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 { } else {
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value; const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
if (deviceId) clearTestMode(deviceId); if (deviceId) clearTestMode(deviceId);
@@ -151,6 +151,15 @@ let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null; let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null; let _cssTestLatestRgb: Uint8Array | null = null;
let _cssTestMeta: any = 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 _cssTestSourceId: string | null = null;
let _cssTestIsComposite: boolean = false; let _cssTestIsComposite: boolean = false;
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array } let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
@@ -234,6 +243,7 @@ function _testKeyColorsSource(sourceId: string) {
_cssTestLatestRgb = null; _cssTestLatestRgb = null;
_cssTestMeta = null; _cssTestMeta = null;
_cssTestLayerData = null; _cssTestLayerData = null;
_cssTestFrameDirty = false;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return; if (!modal) return;
@@ -414,6 +424,7 @@ function _openTestModal(sourceId: string) {
_cssTestIsComposite = false; _cssTestIsComposite = false;
_cssTestIsKeyColors = false; _cssTestIsKeyColors = false;
_cssTestLayerData = null; _cssTestLayerData = null;
_cssTestFrameDirty = false;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return; if (!modal) return;
@@ -636,6 +647,8 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
// Standard format: raw RGB // Standard format: raw RGB
_cssTestLatestRgb = raw; _cssTestLatestRgb = raw;
} }
// Mark a new frame so the render loop repaints on the next RAF tick.
_cssTestFrameDirty = true;
// Track FPS for api_input sources // Track FPS for api_input sources
if (_cssTestIsApiInput) { if (_cssTestIsApiInput) {
@@ -756,6 +769,7 @@ export function applyCssTestSettings() {
_cssTestLatestRgb = null; _cssTestLatestRgb = null;
_cssTestMeta = null; _cssTestMeta = null;
_cssTestLayerData = null; _cssTestLayerData = null;
_cssTestFrameDirty = false;
// Read selected input source from selector (both CSS and CSPT modes) // Read selected input source from selector (both CSS and CSPT modes)
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null; const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
@@ -774,6 +788,9 @@ export function applyCssTestSettings() {
function _cssTestRenderLoop() { function _cssTestRenderLoop() {
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop); _cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
if (!_cssTestMeta) return; 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; 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) { function _cssTestRenderStrip(rgbBytes: Uint8Array) {
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null; const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
if (!canvas) return; if (!canvas) return;
const ledCount = rgbBytes.length / 3; const ledCount = rgbBytes.length / 3;
canvas.width = ledCount; const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
canvas.height = 1; if (!acquired) return;
const ctx = canvas.getContext('2d')!; const { ctx, imageData } = acquired;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data; const data = imageData.data;
for (let i = 0; i < ledCount; i++) { for (let i = 0; i < ledCount; i++) {
const si = i * 3; const si = i * 3;
@@ -824,10 +867,9 @@ function _cssTestRenderLayers(data: any) {
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) { function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
const ledCount = rgbBytes.length / 3; const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return; if (ledCount <= 0) return;
canvas.width = ledCount; const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
canvas.height = 1; if (!acquired) return;
const ctx = canvas.getContext('2d')!; const { ctx, imageData } = acquired;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data; const data = imageData.data;
for (let i = 0; i < ledCount; i++) { for (let i = 0; i < ledCount; i++) {
const si = i * 3; 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; const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
if (!canvas) continue; if (!canvas) continue;
const count = indices.length; 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'; const isH = edge === 'top' || edge === 'bottom';
canvas.width = isH ? count : 1; const w = isH ? count : 1;
canvas.height = isH ? 1 : count; const h = isH ? 1 : count;
const ctx = canvas.getContext('2d')!; const acquired = _cssTestAcquireCanvas(canvas, w, h);
const imageData = ctx.createImageData(canvas.width, canvas.height); if (!acquired) continue;
const { ctx, imageData } = acquired;
const px = imageData.data; const px = imageData.data;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const si = indices[i] * 3; const si = indices[i] * 3;
@@ -1185,6 +1231,7 @@ export function closeTestCssSourceModal() {
_cssTestIsComposite = false; _cssTestIsComposite = false;
_cssTestIsKeyColors = false; _cssTestIsKeyColors = false;
_cssTestLayerData = null; _cssTestLayerData = null;
_cssTestFrameDirty = false;
_cssTestNotificationIds = []; _cssTestNotificationIds = [];
_cssTestIsApiInput = false; _cssTestIsApiInput = false;
_cssTestStopFpsSampling(); _cssTestStopFpsSampling();
@@ -76,6 +76,9 @@ interface WizardState {
let _state: WizardState | null = null; let _state: WizardState | null = null;
let _modal: SetupWizardModal | 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']; 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). */ /** Open the wizard (first-run or on-demand). */
export function openSetupWizard(): void { export function openSetupWizard(): void {
if (!_modal) _modal = new SetupWizardModal(); if (!_modal) _modal = new SetupWizardModal();
_firstRender = true;
_state = { _state = {
step: 'welcome', step: 'welcome',
deviceId: '', deviceId: '',
@@ -201,8 +205,18 @@ export async function wizardNext(): Promise<void> {
_renderStep(); _renderStep();
await _runScaffold(); await _runScaffold();
} else if (step === 'calibrate') { } else if (step === 'calibrate') {
// "Skip calibration" path — move to start // "Skip calibration" path — move to start.
void unmountAutoCalibration(); // 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'; _state.step = 'start';
_renderStep(); _renderStep();
await _startOutput(); 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; if (!_state) return;
_state.displayIndex = index; _state.displayIndex = index;
_state.displayName = displayName; _state.displayName = displayName;
_state.errorMsg = ''; _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); const html = _buildStepHtml(_state);
container.innerHTML = html; container.innerHTML = html;
_attachStepListeners(_state.step); _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 { function _renderProgressBar(): void {
@@ -654,7 +690,7 @@ function _buildDisplayStep(state: WizardState): string {
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label> <label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
<input id="wizard-display-index-manual" class="form-input" type="number" <input id="wizard-display-index-manual" class="form-input" type="number"
min="0" max="63" value="${state.displayIndex}" 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>
</div>`; </div>`;
} else { } else {
+10
View File
@@ -645,6 +645,16 @@
"calibration.roi.width": "Width (%)", "calibration.roi.width": "Width (%)",
"calibration.roi.height": "Height (%)", "calibration.roi.height": "Height (%)",
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", "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.cancel": "Cancel",
"calibration.button.save": "Save", "calibration.button.save": "Save",
"calibration.saved": "Calibration saved", "calibration.saved": "Calibration saved",
+10
View File
@@ -702,6 +702,16 @@
"calibration.roi.width": "Ширина (%)", "calibration.roi.width": "Ширина (%)",
"calibration.roi.height": "Высота (%)", "calibration.roi.height": "Высота (%)",
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", "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.cancel": "Отмена",
"calibration.button.save": "Сохранить", "calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка сохранена", "calibration.saved": "Калибровка сохранена",
+10
View File
@@ -698,6 +698,16 @@
"calibration.roi.width": "宽度 (%)", "calibration.roi.width": "宽度 (%)",
"calibration.roi.height": "高度 (%)", "calibration.roi.height": "高度 (%)",
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100", "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.cancel": "取消",
"calibration.button.save": "保存", "calibration.button.save": "保存",
"calibration.saved": "校准已保存", "calibration.saved": "校准已保存",
+46 -2
View File
@@ -10,6 +10,48 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, List 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 @dataclass
class EventMapping: class EventMapping:
@@ -89,7 +131,8 @@ class GameIntegrationConfig:
"name": self.name, "name": self.name,
"adapter_type": self.adapter_type, "adapter_type": self.adapter_type,
"enabled": self.enabled, "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], "event_mappings": [m.to_dict() for m in self.event_mappings],
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
@@ -110,7 +153,8 @@ class GameIntegrationConfig:
name=data["name"], name=data["name"],
adapter_type=data["adapter_type"], adapter_type=data["adapter_type"],
enabled=data.get("enabled", True), 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, event_mappings=mappings,
created_at=( created_at=(
datetime.fromisoformat(data["created_at"]) datetime.fromisoformat(data["created_at"])
@@ -56,10 +56,10 @@
<div class="calibration-preview"> <div class="calibration-preview">
<!-- Screen with direction toggle, total LEDs, and offset --> <!-- Screen with direction toggle, total LEDs, and offset -->
<div class="preview-screen"> <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> <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> </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"> <div class="preview-screen-border-width">
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label> <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"> <input type="number" id="cal-border-width" min="1" max="100" value="10">
@@ -104,16 +104,16 @@
</div> </div>
<!-- Edge test toggle zones (outside container border, in tick area) --> <!-- 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-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')"></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')"></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')"></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 --> <!-- 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-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')"></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')"></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')"></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 overlay for ticks, arrows, start label -->
<canvas id="calibration-preview-canvas"></canvas> <canvas id="calibration-preview-canvas"></canvas>
@@ -144,7 +144,7 @@
<span class="ds-section-index" aria-hidden="true">03</span> <span class="ds-section-index" aria-hidden="true">03</span>
</div> </div>
<div class="ds-section-body"> <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="form-group">
<div class="label-row"> <div class="label-row">
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label> <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()"> <input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div> </div>
</div> </div>
<div class="form-group" style="margin-top: 16px;"> <div class="form-group" style="margin-top: var(--space-md);">
<div class="label-row"> <div class="label-row">
<label data-i18n="calibration.roi">Capture region (%):</label> <label data-i18n="calibration.roi">Capture region (%):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </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> <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> <div>
<label for="cal-roi-x" class="time-range-label" data-i18n="calibration.roi.x">X (%)</label> <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"> <input type="number" id="cal-roi-x" min="0" max="100" value="0">
@@ -341,6 +341,64 @@ class TestEventIngestion:
assert len(recent) == 1 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 # Status / diagnostics tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -102,9 +102,20 @@ class TestGenericWebhookParsing:
class TestGenericWebhookAuth: 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({}, {}, {}) 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: def test_bearer_auth_valid(self) -> None:
result = GenericWebhookAdapter.validate_auth( result = GenericWebhookAdapter.validate_auth(
@@ -156,6 +167,8 @@ class TestGenericWebhookMetadata:
assert "auth_token" in schema["properties"] assert "auth_token" in schema["properties"]
assert "mappings" in schema["properties"] assert "mappings" in schema["properties"]
assert "auth_header" 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: def test_setup_instructions(self) -> None:
instructions = GenericWebhookAdapter.get_setup_instructions() instructions = GenericWebhookAdapter.get_setup_instructions()
+172
View File
@@ -315,3 +315,175 @@ class TestEvents:
async def test_stop_when_idle_fires_nothing(self, engine): async def test_stop_when_idle_fires_nothing(self, engine):
await engine.stop() await engine.stop()
assert _fired_actions(engine) == [] 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.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping, GameIntegrationConfig from ledgrab.storage.game_integration import EventMapping, GameIntegrationConfig
from ledgrab.storage.game_integration_store import GameIntegrationStore from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import secret_box
@pytest.fixture @pytest.fixture
@@ -272,3 +273,83 @@ class TestGetReferences:
config = store.create_integration(name="Test", adapter_type="webhook") config = store.create_integration(name="Test", adapter_type="webhook")
refs = store.get_references(config.id) refs = store.get_references(config.id)
assert refs == [] 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 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(): def test_create_default_calibration_invalid():
"""Test default calibration with invalid LED count.""" """Test default calibration with invalid LED count."""
with pytest.raises(ValueError): 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)